diff --git a/packages/react-components/react-breadcrumb/etc/react-breadcrumb.api.md b/packages/react-components/react-breadcrumb/etc/react-breadcrumb.api.md index e9aa54106b689d..5c69858c04bd82 100644 --- a/packages/react-components/react-breadcrumb/etc/react-breadcrumb.api.md +++ b/packages/react-components/react-breadcrumb/etc/react-breadcrumb.api.md @@ -118,6 +118,23 @@ export type BreadcrumbSlots = { // @public export type BreadcrumbState = ComponentState & Required>; +// @public (undocumented) +export type PartitionBreadcrumbItems = { + startDisplayedItems: readonly T[]; + overflowItems?: readonly T[]; + endDisplayedItems?: readonly T[]; +}; + +// @public +export const partitionBreadcrumbItems: (options: PartitionBreadcrumbItemsOptions) => PartitionBreadcrumbItems; + +// @public (undocumented) +export type PartitionBreadcrumbItemsOptions = { + items: readonly T[]; + maxDisplayedItems?: number; + overflowIndex?: number; +}; + // @public export const renderBreadcrumb_unstable: (state: BreadcrumbState, contextValues: BreadcrumbContextValues) => JSX.Element; diff --git a/packages/react-components/react-breadcrumb/src/index.ts b/packages/react-components/react-breadcrumb/src/index.ts index 0488598d5f2099..9aad9d3e51deaf 100644 --- a/packages/react-components/react-breadcrumb/src/index.ts +++ b/packages/react-components/react-breadcrumb/src/index.ts @@ -22,6 +22,8 @@ export { useBreadcrumbItem_unstable, } from './BreadcrumbItem'; export type { BreadcrumbItemProps, BreadcrumbItemSlots, BreadcrumbItemState } from './BreadcrumbItem'; +export { partitionBreadcrumbItems } from './utils/index'; +export type { PartitionBreadcrumbItemsOptions, PartitionBreadcrumbItems } from './utils/index'; export { BreadcrumbButton, breadcrumbButtonClassNames, diff --git a/packages/react-components/react-breadcrumb/src/utils/index.ts b/packages/react-components/react-breadcrumb/src/utils/index.ts new file mode 100644 index 00000000000000..2dd789f3fdc64f --- /dev/null +++ b/packages/react-components/react-breadcrumb/src/utils/index.ts @@ -0,0 +1,2 @@ +export { partitionBreadcrumbItems } from './partitionBreadcrumbItems'; +export type { PartitionBreadcrumbItems, PartitionBreadcrumbItemsOptions } from './partitionBreadcrumbItems'; diff --git a/packages/react-components/react-breadcrumb/src/utils/partitionBreadcrumbItems.test.ts b/packages/react-components/react-breadcrumb/src/utils/partitionBreadcrumbItems.test.ts new file mode 100644 index 00000000000000..89266c11a55333 --- /dev/null +++ b/packages/react-components/react-breadcrumb/src/utils/partitionBreadcrumbItems.test.ts @@ -0,0 +1,74 @@ +import { partitionBreadcrumbItems, PartitionBreadcrumbItemsOptions } from './partitionBreadcrumbItems'; +const items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const testData = [ + [ + { items, overflowIndex: 2, maxDisplayedItems: 3 }, + { startDisplayedItems: [0, 1], overflowItems: [2, 3, 4, 5, 6, 7, 8, 9], endDisplayedItems: [10] }, + ], + [ + { items, maxDisplayedItems: 8, overflowIndex: 7 }, + { startDisplayedItems: [0, 1, 2, 3, 4, 5, 6], overflowItems: [7, 8, 9], endDisplayedItems: [10] }, + ], + [ + { items, maxDisplayedItems: 2, overflowIndex: 2 }, + { startDisplayedItems: [0], overflowItems: [1, 2, 3, 4, 5, 6, 7, 8, 9], endDisplayedItems: [10] }, + ], + [ + { items, maxDisplayedItems: 3, overflowIndex: 3 }, + { startDisplayedItems: [0, 1], overflowItems: [2, 3, 4, 5, 6, 7, 8, 9], endDisplayedItems: [10] }, + ], + [ + { items, maxDisplayedItems: 7, overflowIndex: 7 }, + { startDisplayedItems: [0, 1, 2, 3, 4, 5], overflowItems: [6, 7, 8, 9], endDisplayedItems: [10] }, + ], + [ + { items, maxDisplayedItems: 9, overflowIndex: 9 }, + { startDisplayedItems: [0, 1, 2, 3, 4, 5, 6, 7], overflowItems: [8, 9], endDisplayedItems: [10] }, + ], +]; + +const maxDisplayedItemsData = [ + [ + { items, maxDisplayedItems: 3 }, + { startDisplayedItems: [0], overflowItems: [1, 2, 3, 4, 5, 6, 7, 8], endDisplayedItems: [9, 10] }, + ], + [ + { items, maxDisplayedItems: 2 }, + { startDisplayedItems: [0], overflowItems: [1, 2, 3, 4, 5, 6, 7, 8, 9], endDisplayedItems: [10] }, + ], +]; +const overflowIndexData = [ + [ + { items, overflowIndex: 2 }, + { startDisplayedItems: [0, 1], overflowItems: [2, 3, 4, 5, 6], endDisplayedItems: [7, 8, 9, 10] }, + ], + [ + { items, overflowIndex: 0 }, + { startDisplayedItems: [], overflowItems: [0, 1, 2, 3, 4], endDisplayedItems: [5, 6, 7, 8, 9, 10] }, + ], +]; + +describe('partitionBreadcrumbItems method', () => { + it.each(testData)("splits items correctly '%s'", (testItems, expected) => { + expect(partitionBreadcrumbItems(testItems as PartitionBreadcrumbItemsOptions)).toStrictEqual(expected); + }); + it.each(maxDisplayedItemsData)( + "splits items correctly if maxDisplayedItems are passed '%s'", + (testItems, expected) => { + expect(partitionBreadcrumbItems(testItems as PartitionBreadcrumbItemsOptions)).toStrictEqual(expected); + }, + ); + it.each(overflowIndexData)("splits items correctly if overflowINdex is passed '%s'", (testItems, expected) => { + expect(partitionBreadcrumbItems(testItems as PartitionBreadcrumbItemsOptions)).toStrictEqual(expected); + }); + expect(partitionBreadcrumbItems({ items } as PartitionBreadcrumbItemsOptions)).toStrictEqual({ + startDisplayedItems: [0], + overflowItems: [1, 2, 3, 4, 5], + endDisplayedItems: [6, 7, 8, 9, 10], + }); + expect(partitionBreadcrumbItems({} as PartitionBreadcrumbItemsOptions)).toStrictEqual({ + startDisplayedItems: [], + overflowItems: undefined, + endDisplayedItems: undefined, + }); +}); diff --git a/packages/react-components/react-breadcrumb/src/utils/partitionBreadcrumbItems.ts b/packages/react-components/react-breadcrumb/src/utils/partitionBreadcrumbItems.ts new file mode 100644 index 00000000000000..c53485db1c19b6 --- /dev/null +++ b/packages/react-components/react-breadcrumb/src/utils/partitionBreadcrumbItems.ts @@ -0,0 +1,56 @@ +const DEFAULT_OVERFLOW_INDEX = 1; +const DEFAULT_MAX_DISPLAYED_ITEMS = 6; + +export type PartitionBreadcrumbItemsOptions = { + items: readonly T[]; + maxDisplayedItems?: number; + overflowIndex?: number; +}; + +export type PartitionBreadcrumbItems = { + startDisplayedItems: readonly T[]; + overflowItems?: readonly T[]; + endDisplayedItems?: readonly T[]; +}; + +/** + * Get the displayed items and overflowing items based on the array of BreadcrumbItems needed for Breadcrumb. + * + * @param options - Configure the partition options + * + * @returns Three arrays split into displayed items and overflow items based on maxDisplayedItems. + */ +export const partitionBreadcrumbItems = ( + options: PartitionBreadcrumbItemsOptions, +): PartitionBreadcrumbItems => { + let startDisplayedItems; + let overflowItems; + let endDisplayedItems; + + const { items = [] } = options; + const itemsCount = items.length; + const maxDisplayedItems = getMaxDisplayedItems(options.maxDisplayedItems); + let overflowIndex = options.overflowIndex ?? DEFAULT_OVERFLOW_INDEX; + startDisplayedItems = items.slice(0, overflowIndex); + + const numberItemsToHide = itemsCount - maxDisplayedItems; + + if (numberItemsToHide > 0) { + overflowIndex = overflowIndex === maxDisplayedItems ? overflowIndex - 1 : overflowIndex; + const menuLastItemIdx = overflowIndex + numberItemsToHide; + + startDisplayedItems = startDisplayedItems.slice(0, overflowIndex); + overflowItems = items.slice(overflowIndex, menuLastItemIdx); + endDisplayedItems = items.slice(menuLastItemIdx, itemsCount); + } + + return { + startDisplayedItems, + overflowItems, + endDisplayedItems, + }; +}; + +function getMaxDisplayedItems(maxDisplayedItems: number | undefined) { + return maxDisplayedItems && maxDisplayedItems >= 0 ? maxDisplayedItems : DEFAULT_MAX_DISPLAYED_ITEMS; +} diff --git a/packages/react-components/react-breadcrumb/stories/Breadcrumb/BreadcrumbWithOverflow.stories.tsx b/packages/react-components/react-breadcrumb/stories/Breadcrumb/BreadcrumbWithOverflow.stories.tsx new file mode 100644 index 00000000000000..a0e5f7bff89e8c --- /dev/null +++ b/packages/react-components/react-breadcrumb/stories/Breadcrumb/BreadcrumbWithOverflow.stories.tsx @@ -0,0 +1,280 @@ +import * as React from 'react'; +import { + ButtonProps, + makeStyles, + mergeClasses, + shorthands, + tokens, + Button, + Menu, + MenuList, + MenuPopover, + MenuTrigger, + useIsOverflowItemVisible, + useIsOverflowGroupVisible, + useOverflowMenu, + Overflow, + OverflowItem, + MenuItem, +} from '@fluentui/react-components'; +import { + CalendarMonthFilled, + CalendarMonthRegular, + MoreHorizontalRegular, + MoreHorizontalFilled, + bundleIcon, +} from '@fluentui/react-icons'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbButton, + BreadcrumbDivider, + partitionBreadcrumbItems, +} from '@fluentui/react-breadcrumb'; +import type { PartitionBreadcrumbItems } from '@fluentui/react-breadcrumb'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); +const MoreHorizontal = bundleIcon(MoreHorizontalFilled, MoreHorizontalRegular); + +type Item = { + key: number; + item?: string; + buttonProps?: { + onClick?: () => void; + icon?: ButtonProps['icon']; + disabled?: boolean; + iconPosition?: 'before' | 'after'; + }; +}; + +export const buttonItems: Item[] = [ + { + key: 0, + item: 'Item 0', + buttonProps: { + onClick: () => console.log('item 0 was clicked'), + }, + }, + { + key: 1, + item: 'Item 1', + buttonProps: { + icon: , + onClick: () => console.log('item 1 was clicked'), + }, + }, + { + key: 2, + item: 'Item 2', + buttonProps: { + onClick: () => console.log('item 2 was clicked'), + }, + }, + { + key: 3, + item: 'Item 3', + buttonProps: { + onClick: () => console.log('item 3 was clicked'), + }, + }, + { + key: 4, + item: 'Item 4', + buttonProps: { + onClick: () => console.log('item 4 was clicked'), + }, + }, + { + key: 5, + item: 'Item 5', + buttonProps: { + icon: , + iconPosition: 'after', + onClick: () => console.log('item 5 was clicked'), + }, + }, + { + key: 6, + item: 'Item 6', + buttonProps: { + onClick: () => console.log('item 6 was clicked'), + disabled: true, + }, + }, + { + key: 7, + item: 'Item 7', + buttonProps: { + onClick: () => console.log('item 7 was clicked'), + }, + }, +]; + +const useOverflowMenuStyles = makeStyles({ + menu: { + backgroundColor: tokens.colorNeutralBackground1, + }, + menuButton: { + alignSelf: 'center', + }, +}); + +const useExampleStyles = makeStyles({ + example: { + backgroundColor: tokens.colorNeutralBackground2, + ...shorthands.overflow('hidden'), + ...shorthands.padding('5px'), + zIndex: 0, //stop the browser resize handle from piercing the overflow menu + }, + horizontal: { + height: 'fit-content', + minWidth: '150px', + resize: 'horizontal', + width: '600px', + }, +}); + +const useStyles = makeStyles({ + root: { + alignItems: 'flex-start', + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + ...shorthands.overflow('auto'), + ...shorthands.padding('50px', '20px'), + rowGap: '20px', + minHeight: '600px', //lets the page remain at a minimum height when vertical tabs are resized + }, +}); + +const OverflowBreadcrumbButton: React.FC<{ id: string; item: Item }> = props => { + const { item, id } = props; + const isVisible = useIsOverflowItemVisible(id); + + if (isVisible) { + return null; + } + + return {item.item}; +}; + +const OverflowGroupDivider: React.FC<{ + groupId: number; +}> = props => { + const groupVisibility = useIsOverflowGroupVisible(props.groupId.toString()); + if (groupVisibility === 'hidden') { + return null; + } + + return ; +}; + +const ControlledOverflowMenu = (props: PartitionBreadcrumbItems) => { + const { overflowItems, startDisplayedItems, endDisplayedItems } = props; + const { ref, isOverflowing, overflowCount } = useOverflowMenu(); + + const styles = useOverflowMenuStyles(); + + if (!isOverflowing && overflowItems && overflowItems.length === 0) { + return null; + } + + return ( + + + + ); +}; +const BreadcrumbControlledOverflowExample = () => { + const styles = useExampleStyles(); + + const { startDisplayedItems, overflowItems, endDisplayedItems }: PartitionBreadcrumbItems = + partitionBreadcrumbItems({ + items: buttonItems, + maxDisplayedItems: 4, + }); + + return ( +
+ + + {startDisplayedItems.map((item: Item) => { + return ( + + + + {item.item} + + + + + ); + })} + + + {endDisplayedItems && + endDisplayedItems.map((item: Item) => { + const isLastItem = item.key === buttonItems.length - 1; + + return ( + + + + + {item.item} + + + + {!isLastItem && } + + ); + })} + + +
+ ); +}; + +export const BreadcrumbWithOverflow = () => { + const styles = useStyles(); + + return ( +
+ +
+ ); +}; diff --git a/packages/react-components/react-breadcrumb/stories/Breadcrumb/index.stories.tsx b/packages/react-components/react-breadcrumb/stories/Breadcrumb/index.stories.tsx index 4698d7df0fee75..e8f0fd5aad0f75 100644 --- a/packages/react-components/react-breadcrumb/stories/Breadcrumb/index.stories.tsx +++ b/packages/react-components/react-breadcrumb/stories/Breadcrumb/index.stories.tsx @@ -4,6 +4,7 @@ import descriptionMd from './BreadcrumbDescription.md'; import bestPracticesMd from './BreadcrumbBestPractices.md'; export { Default } from './BreadcrumbDefault.stories'; +export { BreadcrumbWithOverflow } from './BreadcrumbWithOverflow.stories'; export default { title: 'Preview Components/Breadcrumb',