diff --git a/package.json b/package.json index fa2c7bd7d..f9ad5bd3e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Breadcrumb/Breadcrumb.Collapsed.tsx b/src/components/Breadcrumb/Breadcrumb.Collapsed.tsx new file mode 100644 index 000000000..b60c03033 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.Collapsed.tsx @@ -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(() => { + return items.map((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: ( + + {labelText} + + ), + icon: labelIcon ?
{labelIcon}
: undefined, + onClick: ({ domEvent }) => item.onClick?.(domEvent as React.MouseEvent), + } + }) + }, [items]) + + const dropdown = useMemo(() => { + return ( +
+
+ +
+
+ ) + }, [menuItems]) + + return isLoading + ? + : ( + <> + dropdown} + getPopupContainer={(trigger) => getDropdownContainer() ?? trigger} + placement="bottomRight" + trigger={['click']} + > +
+ +
+
+ + + ) +} diff --git a/src/components/Breadcrumb/Breadcrumb.Dropdown.tsx b/src/components/Breadcrumb/Breadcrumb.Dropdown.tsx new file mode 100644 index 000000000..0c54b2fe8 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.Dropdown.tsx @@ -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 = (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(() => { + 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((menuItemData, currentIndex) => { + return { + key: buildMenuItemKey(menuItemData, currentIndex), + icon: menuItemData?.icon, + label: ( + + {menuItemData?.label} + + ), + } + }), [filteredItems]) + + return ( +
+ { + Boolean(item.menu?.search) && ( +
+ } + onChange={(event) => { + const onChange = getSearchOption(item.menu?.search, 'onChange') + + if (onChange) { + onChange(event) + } else { + debounce(() => setSearchValue(event.target.value), 300)() + } + }} + /> +
+ ) + } + { + filteredItems.length > 0 + ? ( +
+ { + item.menu?.onClick?.(key, domEvent) + + if (item.menu?.isOpen === undefined) { + setOpen(false) + } + }} + /> +
+ ) + : ( +
+ + {item.menu?.emptyText ?? 'No items'} + +
+ ) + } +
+ ) +} diff --git a/src/components/Breadcrumb/Breadcrumb.Item.tsx b/src/components/Breadcrumb/Breadcrumb.Item.tsx new file mode 100644 index 000000000..db77300ca --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.Item.tsx @@ -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 && ( +
+ + {labelText} + +
+ ) + } + + ) + }, [item]) + + const onItemClick = useCallback>((...args) => { + if (isHidden) { return } + + return item.onClick?.(...args) + }, [isHidden, item]) + + const itemButton = useMemo(() => { + if (!label && !hasMenu) { + return ( +
+
+
+ ) + } + + const dropdownProps: DropdownProps = { + destroyPopupOnHide: true, + dropdownRender: () => , + 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 ( + +
+
+ {label} + +
+
+
+ ) + } + + return ( +
+
+ { + label && ( +
+ {label} +
+ ) + } + { + hasMenu && ( + +
+ +
+
+ ) + } +
+
+ ) + }, [label, hasMenu, isHidden, item, dropdownOpen, isLastItem, onItemClick, getDropdownContainer]) + + return ( + <> + {isLoading ? : itemButton} + {!isLastItem && !isHidden && } + + ) +} diff --git a/src/components/Breadcrumb/Breadcrumb.Separator.tsx b/src/components/Breadcrumb/Breadcrumb.Separator.tsx new file mode 100644 index 000000000..a2566e795 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.Separator.tsx @@ -0,0 +1,40 @@ +/** + * 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 { ReactElement } from 'react' + +import { Icon } from '../Icon' +import { useTheme } from '../../hooks/useTheme' + +export const BREADCRUMB_SEPARATOR_SIZE = 16 +export const BREADCRUMB_SEPARATOR_PADDING = 4 + +const separatorStyle: React.CSSProperties = { + width: BREADCRUMB_SEPARATOR_SIZE, + height: BREADCRUMB_SEPARATOR_SIZE, +} + +export const BreadcrumbSeparator = (): ReactElement => { + const { palette } = useTheme() + + return ( +
+ +
+ ) +} diff --git a/src/components/Breadcrumb/Breadcrumb.mocks.tsx b/src/components/Breadcrumb/Breadcrumb.mocks.tsx new file mode 100644 index 000000000..7f9f6a2d2 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.mocks.tsx @@ -0,0 +1,178 @@ +/** + * 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 { action } from '@storybook/addon-actions' + +import { BreadcrumbProps } from './Breadcrumb.props' +import { Icon } from '../Icon' + +export const breadcrumbIcon = +export const breadcrumbLabel = 'Text' + +export const defaultProps: BreadcrumbProps = { + items: [ + { + onClick: action('click'), + label: 'Orders', + icon: , + }, + { + menu: { + onClick: action('click'), + activeKey: '1', + items: [ + { key: '1', label: 'Order #1', icon: }, + { key: '2', label: 'Order #2', icon: }, + { key: '3', label: 'Order #3', icon: }, + ], + }, + }, + { label: 'Details' }, + ], +} + +export const withoutMenuProps: BreadcrumbProps = { + items: [ + { onClick: action('click'), label: 'Text only' }, + { onClick: action('click'), icon: breadcrumbIcon }, + { onClick: action('click'), label: 'Text & icon', icon: breadcrumbIcon }, + { onClick: action('click'), label: 'Very long text that should be ellipsed at some point' }, + { label: 'Not clickable' }, + { onClick: action('click'), label: 'Last item' }, + ], +} + +export const withMenuProps: BreadcrumbProps = { + items: [ + { + onClick: action('click'), + icon: breadcrumbIcon, + label: 'Clickable button', + menu: { + items: [ + { key: 'sibling-1', label: 'Sibling 1', icon: breadcrumbIcon }, + { key: 'sibling-2', label: 'Sibling 2' }, + { key: 'sibling-3', label: 'Sibling 3 with a very long text that should be ellipsed at some point' }, + ], + onDropdownVisibleChange: action('dropdown open'), + onClick: action('click'), + }, + }, + { + icon: breadcrumbIcon, + label: 'Not clickable button', + menu: { + items: [ + { key: 'sibling-1', label: 'Sibling 1', icon: breadcrumbIcon }, + { key: 'sibling-2', label: 'Sibling 2' }, + ], + onDropdownVisibleChange: action('dropdown open'), + onClick: action('click'), + }, + }, + { + label: 'With search', + menu: { + items: [ + { key: 'orders', label: 'Orders', icon: breadcrumbIcon }, + { key: 'shipping', label: 'Shipping', icon: breadcrumbIcon }, + { key: 'refunds', label: 'Refunds', icon: breadcrumbIcon }, + { key: 'packaging', label: 'Packaging', icon: breadcrumbIcon }, + ], + onDropdownVisibleChange: action('dropdown open'), + onClick: action('click'), + search: { allowClear: true }, + }, + }, + { + menu: { + activeKey: 'sibling-1', + items: [ + { key: 'sibling-1', label: 'Sibling 1', icon: breadcrumbIcon }, + { key: 'sibling-2', label: 'Sibling 2' }, + ], + onDropdownVisibleChange: action('dropdown open'), + onClick: action('click'), + }, + }, + ], +} + +export const uncontrolledProps: BreadcrumbProps = { + items: [ + { + label: 'Fallback label', + menu: { + onClick: action('click'), + items: [ + { key: 'orders', label: 'Orders', icon: breadcrumbIcon }, + { key: 'shipping', label: 'Shipping', icon: breadcrumbIcon }, + { key: 'refunds', label: 'Refunds', icon: breadcrumbIcon }, + { key: 'packaging', label: 'Packaging', icon: breadcrumbIcon }, + ], + search: true, + }, + }, + ], +} + +export const controlledProps: BreadcrumbProps = { + items: [ + { + label: 'Fallback label', + menu: { + activeKey: 'orders', + items: [ + { key: 'orders', label: 'Orders', icon: breadcrumbIcon }, + { key: 'shipping', label: 'Shipping', icon: breadcrumbIcon }, + { key: 'refunds', label: 'Refunds', icon: breadcrumbIcon }, + { key: 'packaging', label: 'Packaging', icon: breadcrumbIcon }, + ], + onClick: action('click'), + isOpen: true, + onDropdownVisibleChange: action('dropdown open'), + search: { + onChange: action('search'), + }, + }, + }, + ], +} + +export const loadingProps: BreadcrumbProps = { + isLoading: true, + items: [ + { onClick: action('click'), label: 'Text' }, + { onClick: action('click'), label: 'Text' }, + { onClick: action('click'), label: 'Text' }, + ], +} + +export const collapsedProps: BreadcrumbProps = { + items: [ + { onClick: action('click'), label: 'Text' }, + { onClick: action('click'), icon: breadcrumbIcon, label: 'Text' }, + { onClick: action('click'), label: 'Text' }, + { onClick: action('click'), icon: breadcrumbIcon, label: 'Text' }, + { onClick: action('click'), icon: breadcrumbIcon, label: 'Text' }, + { onClick: action('click'), icon: breadcrumbIcon, label: 'Text' }, + { onClick: action('click'), label: 'Text' }, + { onClick: action('click'), label: 'Text' }, + { onClick: action('click'), label: 'Text' }, + ], +} diff --git a/src/components/Breadcrumb/Breadcrumb.module.css b/src/components/Breadcrumb/Breadcrumb.module.css new file mode 100644 index 000000000..abb09ba35 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.module.css @@ -0,0 +1,253 @@ +/** + * 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 + */ + +.breadcrumb { + /* TODO: these CSS variables are missing in DS */ + --item-bg: var(--palette-background-neutral, #89898914); + --item-bg-hover: var(--palette-action-alternate-secondary-hover, #8989891F); + --item-bg-active: var(--palette-action-alternate-secondary-selected, #8989893D); + --item-bg-transition: background-color 0.25s ease-in-out; + + width: 100%; + overflow: hidden; + + & .breadcrumbItems { + display: flex; + justify-content: flex-start; + align-items: center; + gap: var(--spacing-gap-xs, 4px); + + & .breadcrumbItemLast { + & .breadcrumbItemLabelText { + font-weight: var(--typography-bodyLBold-fontWeight, 600); + } + } + + & .breadcrumbItemButton { + display: flex; + align-items: center; + justify-content: flex-start; + cursor: pointer; + height: var(--shape-size-xl, 32px); + box-sizing: border-box; + + & .breadcrumbItemButtonEmpty { + background-color: var(--item-bg); + transition: var(--item-bg-transition); + border-radius: var(--shape-border-radius-md, 4px); + width: var(--shape-size-xl, 32px); + height: var(--shape-size-xl, 32px); + + &.breadcrumbItemButtonNoInteraction { + cursor: auto; + } + + &:not(.breadcrumbItemButtonNoInteraction) { + &:hover { + background-color: var(--item-bg-hover); + } + + &:active { + background-color: var(--item-bg-active); + } + } + } + + &.breadcrumbItemButtonSegmented { + gap: 1px; + + &.breadcrumbItemButtonNoInteraction { + cursor: default; + + & .breadcrumbItemLabel { + &:hover { + background-color: var(--item-bg); + } + + &:active { + background-color: var(--item-bg); + } + } + } + + & .breadcrumbItemLabel { + display: flex; + align-items: center; + gap: var(--spacing-gap-xs, 4px); + background-color: var(--item-bg); + padding: var(--spacing-padding-xs, 4px) var(--spacing-padding-sm, 8px); + border-radius: var(--shape-border-radius-md, 4px); + transition: var(--item-bg-transition); + height: 100%; + box-sizing: border-box; + + &:hover { + background-color: var(--item-bg-hover); + } + + &:active { + background-color: var(--item-bg-active); + } + + &.withMenu { + border-radius: var(--shape-border-radius-md, 4px) 0 0 var(--shape-border-radius-md, 4px); + } + } + + & .breadcrumbMenuIcon { + display: flex; + align-items: center; + background-color: var(--item-bg); + padding: var(--spacing-padding-sm, 8px) var(--spacing-padding-xs, 4px); + border-radius: var(--shape-border-radius-md, 4px); + transition: var(--item-bg-transition); + cursor: pointer; + + &:hover { + background-color: var(--item-bg-hover); + } + + &:active { + background-color: var(--item-bg-active); + } + + &.withLabel { + border-radius: 0 var(--shape-border-radius-md, 4px) var(--shape-border-radius-md, 4px) 0; + } + } + } + + &.breadcrumbItemButtonConnected { + gap: 4px; + background-color: var(--item-bg); + padding: var(--spacing-padding-xs, 4px) var(--spacing-padding-sm, 8px); + border-radius: var(--shape-border-radius-md, 4px); + cursor: pointer; + transition: var(--item-bg-transition); + + &:hover { + background-color: var(--item-bg-hover); + } + + &:active { + background-color: var(--item-bg-active); + } + } + + & .breadcrumbItemLabelText { + max-width: 250px; + } + } + } + + & .breadcrumbHidden { + visibility: hidden; + position: absolute; + top: 0; + left: 0; + white-space: nowrap; + } +} + +.dropdownMenuContainer { + width: 300px; + max-height: 224px; + overflow: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-gap-xs, 4px); + padding: var(--spacing-padding-xs, 4px); + background-color: var(--palette-common-white, #ffffff); + border-radius: var(--shape-border-radius-lg, 8px); + box-shadow: 0 2px 4px 0 #0000000D, 0 1px 6px -1px #0000000D, 0 1px 2px 0 #00000014; + + &.dropdownCollapseMenuContainer { + width: 200px; + } + + & .noItemsContainer { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-padding-sm, 8px); + box-sizing: border-box; + + & > div { + color: var(--palette-common-grey-400, #ACACAC); + } + } + + & .dropdownMenuSearch { + display: flex; + justify-content: center; + + & :global(.mia-platform-input-affix-wrapper) { + &:hover { + border: 1px solid var(--palette-action-primary-hover, #1890FF); + } + + &:global(.mia-platform-input-affix-wrapper-focused) { + border: 1px solid var(--palette-action-primary-active, #005dc9); + box-shadow: 0px 0px 0px 2px #005DC940; + } + } + } + + & .dropdownMenu { + & :global(.mia-platform-dropdown-menu) { + border-radius: 0; + box-shadow: none; + padding: 0; + + & :global(.mia-platform-dropdown-menu-item) { + padding: var(--spacing-padding-sm, 8px) !important; + gap: var(--spacing-gap-sm, 8px); + + &:hover { + background-color: var(--palette-action-alternate-secondary-hover, #00000014) !important; + cursor: pointer; + } + + &:global(.mia-platform-dropdown-menu-item-selected) { + background-color: var(--palette-action-alternate-primary-active, #1890ff14) !important; + + &:hover { + background-color: var(--palette-action-alternate-primary-active, #1890ff14) !important; + } + } + + &.noInteraction { + cursor: default !important; + background-color: white !important; + } + + & :global(.mia-platform-dropdown-menu-title-content) { + width: 100%; + } + + & :global(.mia-platform-dropdown-menu-item-icon) { + width: 16px; + height: 16px; + min-width: unset; + margin-inline-end: unset; + } + } + } + } +} diff --git a/src/components/Breadcrumb/Breadcrumb.props.ts b/src/components/Breadcrumb/Breadcrumb.props.ts new file mode 100644 index 000000000..968967521 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.props.ts @@ -0,0 +1,67 @@ +/** + * 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 { BreadcrumbItemType } from './Breadcrumb.types' + +export type BreadcrumbProps = { + + /** + * Indicates whether the component is loading. + */ + isLoading?: boolean; + + /** + * The list of breadcrumb items. + * + * `object`: + * - icon: icon to be displayed alongside the breadcrumb item.
`ReactNode` + * - key: unique key for the breadcrumb item. Defaults to item index.
`string` + * - label: the label of the breadcrumb item.
`ReactNode` + * - onClick: callback function to handle click events on the breadcrumb item.
+ * `(event: React.MouseEvent) => void` + * - menu: menu associated with the breadcrumb item.
+ * `object`: + * - activeKey: the key of the currently active menu item.
`string` + * - onClick: callback function to handle click events on the menu item.
+ * `(key: string, event: React.MouseEvent | React.KeyboardEvent) => void` + * - isOpen: indicates whether the menu is open for a controlled behavior.
`boolean` + * - onDropdownVisibleChange: callback function to handle changes in dropdown visibility.
+ * `(open: boolean) => void` + * - emptyText: text to show if menu is empty or search matched no elements. Defaults to "No items"
+ * `string` + * - search: searchbar visibility and options.
+ * `boolean | object`: + * - onChange: callback function to handle search operations.
+ * `React.ChangeEventHandler` + * - allowClear: if true, allows clearing of the search input.
`boolean` + * - placeholder: placeholder text for the search input.
`string` + * - items: list of menu items in the breadcrumb.
+ * `object[]`: + * - icon: icon to be displayed alongside the menu item.
`ReactNode` + * - key: unique key for the menu item. Defaults to item index.
`string` + * - label: label of the menu item.
`string` + */ + items: BreadcrumbItemType[]; + + /** + * The DOM element where menu and collapse dropdowns are attached. Defaults to the Breadcrumb component itself. + * + * @param containerNode - The Breadcrumb HTML element. + */ + getPopupContainer?: (containerNode: HTMLElement) => HTMLElement; +} diff --git a/src/components/Breadcrumb/Breadcrumb.stories.tsx b/src/components/Breadcrumb/Breadcrumb.stories.tsx new file mode 100644 index 000000000..3cd8eb86b --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.stories.tsx @@ -0,0 +1,130 @@ +/** + * 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 { cloneDeep, set } from 'lodash-es' +import { useCallback, useMemo, useState } from 'react' +import { action } from '@storybook/addon-actions' + +import { collapsedProps, controlledProps, defaultProps, loadingProps, uncontrolledProps, withMenuProps, withoutMenuProps } from './Breadcrumb.mocks' +import { Breadcrumb } from './Breadcrumb' +import { BreadcrumbItemMenu } from './Breadcrumb.types' + +const meta = { + component: Breadcrumb, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: defaultProps, + decorators: [ + (_, { args, id }) => { + return ( + window.document.getElementById(`anchor--${id}`) ?? document.body} + /> + ) + }, + ], +} + +export const WithoutMenu: Story = { + args: withoutMenuProps, +} + +export const WithMenu: Story = { + args: withMenuProps, + decorators: [ + (_, { args, id }) => { + return ( + window.document.getElementById(`anchor--${id}`) ?? document.body} + /> + ) + }, + ], +} + +export const Uncontrolled: Story = { + args: uncontrolledProps, + decorators: [ + (_, { args, id }) => { + const [activeKey, setActiveKey] = useState() + + const onSubItemClick = useCallback>((key, event) => { + action('click')(key, event) + setActiveKey(key) + }, []) + + const items = useMemo(() => { + let nextItems = cloneDeep(args.items!) + + nextItems = set(nextItems, [0, 'menu', 'activeKey'], activeKey) + nextItems = set(nextItems, [0, 'menu', 'onClick'], onSubItemClick) + + return nextItems + }, [activeKey, args, onSubItemClick]) + + return ( + window.document.getElementById(`anchor--${id}`) ?? document.body} + items={items} + /> + ) + }, + ], +} + +export const Controlled: Story = { + args: controlledProps, + decorators: [ + (_, { args }) => { + return ( + window.document.getElementById('storybook-root') ?? document.body} + /> + ) + }, + ], +} + +export const Loading: Story = { + args: loadingProps, +} + +export const Collapsed: Story = { + args: collapsedProps, + decorators: [ + (_, { args, id }) => { + return ( +
+ window.document.getElementById(`anchor--${id}`) ?? document.body} + /> +
+ ) + }, + ], +} diff --git a/src/components/Breadcrumb/Breadcrumb.test.tsx b/src/components/Breadcrumb/Breadcrumb.test.tsx new file mode 100644 index 000000000..aa8ebefc0 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.test.tsx @@ -0,0 +1,248 @@ +/** + * 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 { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '../../test-utils' +import { Breadcrumb } from '.' +import { BreadcrumbProps } from './Breadcrumb.props' +import { breadcrumbIcon } from './Breadcrumb.mocks' + +describe('Breadcrumb Component', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + test('renders a breadcrumb with empty items', () => { + const { asFragment } = render() + expect(asFragment()).toMatchSnapshot() + }) + + test('renders a breadcrumb with different button types', () => { + const props: BreadcrumbProps = { + items: [ + { }, + { label: 'Text' }, + { label: 'Very long text that should be ellipsed at some point' }, + { icon: breadcrumbIcon }, + { label: 'Text', icon: breadcrumbIcon }, + { menu: { items: [] } }, + { label: 'Text', menu: { items: [] } }, + { icon: breadcrumbIcon, label: 'Text', menu: { items: [] } }, + { onClick: jest.fn, icon: breadcrumbIcon, label: 'Text', menu: { items: [] } }, + ], + } + + const { asFragment } = render() + expect(asFragment()).toMatchSnapshot() + }) + + test('renders a breadcrumb in loading state', () => { + const props: BreadcrumbProps = { + isLoading: true, + items: [ + { label: 'Text' }, + { label: 'Text' }, + { label: 'Text' }, + ], + } + + const { asFragment } = render() + + expect(asFragment()).toMatchSnapshot() + + // Fire a resize to trigger hidden items computation + fireEvent(window, new Event('resize')) + expect(asFragment()).toMatchSnapshot() + }) + + test('renders item menu correctly', async() => { + const buttonClickMock = jest.fn() + const menuItemClickMock = jest.fn() + const onDropdownVisibleChangeMock = jest.fn() + + const props: BreadcrumbProps = { + items: [ + { + key: 'root', + onClick: buttonClickMock, + label: 'Text', + icon: breadcrumbIcon, + menu: { + onClick: menuItemClickMock, + onDropdownVisibleChange: onDropdownVisibleChangeMock, + search: true, + items: [ + { key: 'item-1', label: 'Item 1' }, + { key: 'item-2', label: 'Item 2', icon: breadcrumbIcon }, + ], + }, + }, + { + menu: { + activeKey: 'sub-item', + items: [{ key: 'sub-item', label: 'Sub item', icon: breadcrumbIcon }], + }, + }, + ], + getPopupContainer: (containerNode) => containerNode, + } + + const { asFragment } = render() + expect(asFragment()).toMatchSnapshot() + + const [button] = screen.getAllByText('Text') + fireEvent.click(button) + expect(buttonClickMock).toHaveBeenCalledTimes(1) + + const [dropdownTrigger1, dropdownTrigger2] = screen.getAllByLabelText('caret-full-down') + + fireEvent.click(dropdownTrigger1) + expect(buttonClickMock).toHaveBeenCalledTimes(1) + expect(onDropdownVisibleChangeMock).toHaveBeenCalledTimes(1) + expect(onDropdownVisibleChangeMock).toHaveBeenCalledWith(true) + + expect(asFragment()).toMatchSnapshot() + + fireEvent.click(screen.getByText('Item 1')) + expect(menuItemClickMock).toHaveBeenCalledTimes(1) + expect(menuItemClickMock).toHaveBeenCalledWith('item-1', expect.any(Object)) + + fireEvent.click(dropdownTrigger1) + fireEvent.click(screen.getByText('Item 2')) + expect(menuItemClickMock).toHaveBeenNthCalledWith(2, 'item-2', expect.any(Object)) + + fireEvent.click(dropdownTrigger1) + fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value: 'foo' } }) + await waitFor(() => expect(screen.getByText('No items')).toBeInTheDocument()) + + fireEvent.click(dropdownTrigger1) + expect(screen.queryByText('No items')).not.toBeInTheDocument() + + fireEvent.click(dropdownTrigger1) + expect(screen.queryByText('No items')).not.toBeInTheDocument() + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 2')).toBeInTheDocument() + fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value: '1' } }) + await waitFor(() => expect(screen.getByText('Item 1')).toBeInTheDocument()) + await waitForElementToBeRemoved(() => screen.queryByText('Item 2')) + + fireEvent.click(dropdownTrigger2) + expect(asFragment()).toMatchSnapshot() + }) + + test('renders controlled item menu correctly', () => { + const onSearchMock = jest.fn() + const menuItemClickMock = jest.fn() + const onDropdownVisibleChangeMock = jest.fn() + + const props: BreadcrumbProps = { + items: [ + { + key: 'root', + label: 'Text', + icon: breadcrumbIcon, + menu: { + isOpen: true, + onClick: menuItemClickMock, + onDropdownVisibleChange: onDropdownVisibleChangeMock, + search: { + onChange: onSearchMock, + }, + items: [ + { key: 'item-1', label: 'Item 1' }, + { key: 'item-2', label: 'Item 2', icon: breadcrumbIcon }, + ], + }, + }, + ], + } + + const { asFragment } = render() + expect(asFragment()).toMatchSnapshot() + + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 2')).toBeInTheDocument() + + const [button] = screen.getAllByText('Text') + fireEvent.click(button) + expect(onDropdownVisibleChangeMock).toHaveBeenNthCalledWith(1, false) + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 2')).toBeInTheDocument() + + fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value: 'foo' } }) + expect(onSearchMock).toHaveBeenNthCalledWith(1, expect.any(Object)) + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 2')).toBeInTheDocument() + }) + + test('renders a breadcrumb with collapsed items', () => { + const onClickMock = jest.fn() + const onDropdownVisibleChangeMock = jest.fn() + + const props: BreadcrumbProps = { + items: [ + { label: 'Text 1' }, + { label: 'Text 2' }, + { label: 'Text 3', onClick: jest.fn }, + { label: 'Text 4', icon: breadcrumbIcon }, + { label: 'Text 5', menu: { items: [{ label: 'Item 1' }] } }, + { + menu: { + activeKey: '1', + items: [{ key: '1', label: 'Item 1', icon: breadcrumbIcon }], + }, + }, + { label: 'Text 7', onClick: onClickMock }, + { + label: 'Text 8', + menu: { + onDropdownVisibleChange: onDropdownVisibleChangeMock, + items: [{ label: 'Item 2' }], + }, + }, + ], + } + + const { asFragment } = render() + + // Fire a resize to trigger hidden items computation + fireEvent(window, new Event('resize')) + + expect(asFragment()).toMatchSnapshot() + + expect(screen.getAllByText('Text 2')).toHaveLength(1) + + const collapseButton = screen.getByLabelText('PiDotsThree') + fireEvent.click(collapseButton) + + expect(asFragment()).toMatchSnapshot() + + expect(screen.getAllByText('Text 2')).toHaveLength(2) + + fireEvent.click(screen.getAllByText('Text 7')[1]) + expect(onClickMock).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getAllByText('Text 7')[0]) + expect(onClickMock).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getAllByText('Text 8')[0]) + expect(onDropdownVisibleChangeMock).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getAllByText('Text 8')[1]) + expect(onDropdownVisibleChangeMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx new file mode 100644 index 000000000..47668244c --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -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 { ReactElement, useCallback, useEffect, useRef, useState } from 'react' +import classNames from 'classnames' + +import { getItemsFittingInParent, renderItem } from './Breadcrumb.utils' +import { BreadcrumbButton } from './Breadcrumb.types' +import { BreadcrumbProps } from './Breadcrumb.props' +import styles from './Breadcrumb.module.css' + +/** + * UI component for displaying the current location within an hierarchy. + * + * @returns {Breadcrumb} Breadcrumb component + */ +export const Breadcrumb = ({ isLoading, items, getPopupContainer }: BreadcrumbProps): ReactElement => { + const [visibleItems, setVisibleItems] = useState([]) + + const breadcrumbRef = useRef(null) + const hiddenContainerRef = useRef(null) + + const getDropdownContainer = useCallback(() => { + /* istanbul ignore next */ + if (!breadcrumbRef.current) { return } + + return getPopupContainer?.(breadcrumbRef.current) ?? breadcrumbRef.current + }, [getPopupContainer]) + + // Computes visible and collapsed items + useEffect(() => { + if (!items || items.length <= 2) { + setVisibleItems(items ?? []) + return + } + + const container = breadcrumbRef.current + const hiddenContainer = hiddenContainerRef.current + /* istanbul ignore if */ + if (!container || !hiddenContainer) { + setVisibleItems([]) + return + } + + const setItems = (): void => { + const nextItems = getItemsFittingInParent(items, container, hiddenContainer) + setVisibleItems(nextItems) + } + + setTimeout(() => setItems()) + + window.addEventListener('resize', setItems) + + return () => window.removeEventListener('resize', setItems) + }, [items]) + + return ( +
+
+ { + visibleItems.map((breadcrumbItem, index, itemList) => renderItem( + breadcrumbItem, + index, + itemList, + { isHidden: false, isLoading, getDropdownContainer } + )) + } +
+
+ { + items?.map((breadcrumbItem, index, itemList) => renderItem( + breadcrumbItem, + index, + itemList, + { isHidden: true, isLoading, getDropdownContainer } + )) + } +
+
+ ) +} diff --git a/src/components/Breadcrumb/Breadcrumb.types.ts b/src/components/Breadcrumb/Breadcrumb.types.ts new file mode 100644 index 000000000..b688e395a --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.types.ts @@ -0,0 +1,135 @@ +/** + * 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 { ReactNode } from 'react' + +export type BreadcrumbItemMenuItem = { + + /** + * Icon to be displayed alongside the menu item. + */ + icon?: ReactNode; + + /** + * Unique key for the menu item. + */ + key?: string; + + /** + * The label of the menu item. + */ + label?: string; +} + +export type SearchOptions = { + + /** + * If true, allows clearing of the search input. + */ + allowClear?: boolean; + + /** + * Placeholder text for the search input. + */ + placeholder?: string; + + /** + * Callback function to handle search operations. + */ + onChange?: React.ChangeEventHandler; +} + +export type BreadcrumbItemMenu = { + + /** + * List of menu items in the breadcrumb. + */ + items?: BreadcrumbItemMenuItem[]; + + /** + * The key of the currently active menu item. + */ + activeKey?: string; + + /** + * Callback function to handle click events on menu items. + * + * @param key - The key of the clicked item. + * @param event - The click event. + */ + onClick?: (key: string, event: React.MouseEvent | React.KeyboardEvent) => void; + + /** + * Indicates whether the menu is open for a controlled behavior. + */ + isOpen?: boolean; + + /** + * Callback function to handle changes in dropdown visibility. + * + * @param open - Indicates whether the dropdown is open. + */ + onDropdownVisibleChange?: (open: boolean) => void; + + /** + * Searchbar visibility and options. + */ + search?: boolean | SearchOptions; + + /** + * Text to show if menu is empty or search matched no elements. Defaults to "No items". + */ + emptyText?: string +} + +export type BreadcrumbItemType = { + + /** + * Icon to be displayed alongside the breadcrumb item. + */ + icon?: ReactNode; + + /** + * Unique key for the breadcrumb item. + */ + key?: string; + + /** + * The label of the breadcrumb item. + */ + label?: ReactNode; + + /** + * Menu associated with the breadcrumb item. + */ + menu?: BreadcrumbItemMenu; + + /** + * Callback function to handle click events on the breadcrumb item. + * + * @param event - The click event. + */ + onClick?: (event: React.MouseEvent) => void; +} + +export type BreadcrumbCollapsedItem = { + type: 'collapsed' + items: BreadcrumbItemType[] +} + +export type BreadcrumbButton = BreadcrumbItemType | BreadcrumbCollapsedItem diff --git a/src/components/Breadcrumb/Breadcrumb.utils.tsx b/src/components/Breadcrumb/Breadcrumb.utils.tsx new file mode 100644 index 000000000..2d6cd81e8 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.utils.tsx @@ -0,0 +1,100 @@ +/** + * 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 { ReactElement } from 'react' + +import { BREADCRUMB_COLLAPSED_WIDTH, BreadcrumbCollapsed } from './Breadcrumb.Collapsed' +import { BREADCRUMB_SEPARATOR_PADDING, BREADCRUMB_SEPARATOR_SIZE } from './Breadcrumb.Separator' +import { BreadcrumbButton, BreadcrumbItemMenuItem, BreadcrumbItemType } from './Breadcrumb.types' +import { BreadcrumbItem } from './Breadcrumb.Item' + +export const renderItem = ( + item: BreadcrumbButton, + idx: number, + itemList: BreadcrumbButton[], + ctx: { isHidden?: boolean, isLoading?: boolean, getDropdownContainer: () => HTMLElement | undefined } +): ReactElement => { + if ('type' in item && item.type === 'collapsed') { + return ( + + ) + } + + // SAFETY: we can infer the type given the if statement above + const typedItem = item as BreadcrumbItemType + + return ( + + ) +} + +export const getItemsFittingInParent = ( + items: BreadcrumbItemType[], + container: HTMLDivElement, + hiddenContainer: HTMLDivElement +): BreadcrumbButton[] => { + const maxWidth = container.getBoundingClientRect().width + + const separatorWidth = BREADCRUMB_SEPARATOR_SIZE + (BREADCRUMB_SEPARATOR_PADDING * 2) + + const elements = Array.from(hiddenContainer.children) + const nextVisibleItems: BreadcrumbButton[] = [] + + if (!elements.length || !items.length) { + return nextVisibleItems + } + + const nextCollapsedItems = items.slice(1, items.length - 1) + nextVisibleItems.push(items[items.length - 1]) + + let currWidth = 0 + currWidth += elements[elements.length - 1].getBoundingClientRect().width + currWidth += elements[0].getBoundingClientRect().width + separatorWidth + currWidth += BREADCRUMB_COLLAPSED_WIDTH + separatorWidth + + for (let i = elements.length - 2; i > 0; i--) { + currWidth += elements[i].getBoundingClientRect().width + separatorWidth + if (currWidth > maxWidth) { + break + } + + nextVisibleItems.unshift(nextCollapsedItems.pop()!) + } + + if (nextCollapsedItems.length > 0) { + nextVisibleItems.unshift({ type: 'collapsed', items: nextCollapsedItems }) + } + nextVisibleItems.unshift(items[0]) + + return nextVisibleItems +} + +export const buildItemKey = (item: BreadcrumbItemType, idx: number): string => item.key || `breadcrumb-item-${idx}` +export const buildMenuItemKey = (item: BreadcrumbItemMenuItem, idx: number): string => item.key || `breadcrumb-menu-item-${idx}` diff --git a/src/components/Breadcrumb/__snapshots__/Breadcrumb.test.tsx.snap b/src/components/Breadcrumb/__snapshots__/Breadcrumb.test.tsx.snap new file mode 100644 index 000000000..d84024145 --- /dev/null +++ b/src/components/Breadcrumb/__snapshots__/Breadcrumb.test.tsx.snap @@ -0,0 +1,2225 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Breadcrumb Component renders a breadcrumb in loading state 1`] = ` + +