diff --git a/change/@fluentui-priority-overflow-8e128a48-5402-4886-b088-900334ce1883.json b/change/@fluentui-priority-overflow-8e128a48-5402-4886-b088-900334ce1883.json new file mode 100644 index 00000000000000..92a84dfb011d20 --- /dev/null +++ b/change/@fluentui-priority-overflow-8e128a48-5402-4886-b088-900334ce1883.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: Added support for custom divider", + "packageName": "@fluentui/priority-overflow", + "email": "vkozlova@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-components-7b21b68d-a82b-4ee6-802c-cdbba6a8b25d.json b/change/@fluentui-react-components-7b21b68d-a82b-4ee6-802c-cdbba6a8b25d.json new file mode 100644 index 00000000000000..208339dac9ef6e --- /dev/null +++ b/change/@fluentui-react-components-7b21b68d-a82b-4ee6-802c-cdbba6a8b25d.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: Added Overflow divider", + "packageName": "@fluentui/react-components", + "email": "vkozlova@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-overflow-96369afb-91ef-4417-88b6-bd79e7796730.json b/change/@fluentui-react-overflow-96369afb-91ef-4417-88b6-bd79e7796730.json new file mode 100644 index 00000000000000..0e62e403b83156 --- /dev/null +++ b/change/@fluentui-react-overflow-96369afb-91ef-4417-88b6-bd79e7796730.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Added support for custom divider", + "packageName": "@fluentui/react-overflow", + "email": "vkozlova@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/priority-overflow/etc/priority-overflow.api.md b/packages/react-components/priority-overflow/etc/priority-overflow.api.md index a8a2ef84d50ce2..1889a2cbb621d8 100644 --- a/packages/react-components/priority-overflow/etc/priority-overflow.api.md +++ b/packages/react-components/priority-overflow/etc/priority-overflow.api.md @@ -37,6 +37,13 @@ export type OverflowAxis = 'horizontal' | 'vertical'; // @public (undocumented) export type OverflowDirection = 'start' | 'end'; +// @public (undocumented) +export interface OverflowDividerEntry { + element: HTMLElement; + // (undocumented) + groupId: string; +} + // @public export interface OverflowEventPayload { // (undocumented) @@ -61,11 +68,13 @@ export interface OverflowItemEntry { // @internal (undocumented) export interface OverflowManager { + addDivider: (divider: OverflowDividerEntry) => void; addItem: (items: OverflowItemEntry) => void; addOverflowMenu: (element: HTMLElement) => void; disconnect: () => void; forceUpdate: () => void; observe: (container: HTMLElement, options: ObserveOptions) => void; + removeDivider: (groupId: string) => void; removeItem: (itemId: string) => void; removeOverflowMenu: () => void; update: () => void; diff --git a/packages/react-components/priority-overflow/src/consts.ts b/packages/react-components/priority-overflow/src/consts.ts new file mode 100644 index 00000000000000..cb1c1cad5c1c79 --- /dev/null +++ b/packages/react-components/priority-overflow/src/consts.ts @@ -0,0 +1,2 @@ +export const DATA_OVERFLOWING = 'data-overflowing'; +export const DATA_OVERFLOW_GROUP = 'data-overflow-group'; diff --git a/packages/react-components/priority-overflow/src/index.ts b/packages/react-components/priority-overflow/src/index.ts index 3f7027bfbaeb88..ba4668ceab6d56 100644 --- a/packages/react-components/priority-overflow/src/index.ts +++ b/packages/react-components/priority-overflow/src/index.ts @@ -9,5 +9,6 @@ export type { OverflowEventPayload, OverflowGroupState, OverflowItemEntry, + OverflowDividerEntry, OverflowManager, } from './types'; diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index 3dece40e104728..445b541e6f0061 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -1,6 +1,13 @@ +import { DATA_OVERFLOWING, DATA_OVERFLOW_GROUP } from './consts'; import { debounce } from './debounce'; -import { createPriorityQueue } from './priorityQueue'; -import type { OverflowGroupState, OverflowItemEntry, OverflowManager, ObserveOptions } from './types'; +import { createPriorityQueue, PriorityQueue } from './priorityQueue'; +import type { + OverflowGroupState, + OverflowItemEntry, + OverflowManager, + ObserveOptions, + OverflowDividerEntry, +} from './types'; /** * @internal @@ -24,7 +31,7 @@ export function createOverflowManager(): OverflowManager { }; const overflowItems: Record = {}; - const overflowGroups: Record; invisibleItemIds: Set }> = {}; + const overflowDividers: Record = {}; const resizeObserver = new ResizeObserver(entries => { if (!entries[0] || !container) { return; @@ -33,6 +40,66 @@ export function createOverflowManager(): OverflowManager { update(); }); + const getNextItem = (queueToDequeue: PriorityQueue, queueToEnqueue: PriorityQueue) => { + const nextItem = queueToDequeue.dequeue(); + queueToEnqueue.enqueue(nextItem); + return overflowItems[nextItem]; + }; + + const createGroupManager = () => { + const groupVisibility: Record = {}; + const groups: Record; invisibleItemIds: Set }> = {}; + function updateGroupVisibility(groupId: string) { + const group = groups[groupId]; + if (group.invisibleItemIds.size && group.visibleItemIds.size) { + groupVisibility[groupId] = 'overflow'; + } else if (group.visibleItemIds.size === 0) { + groupVisibility[groupId] = 'hidden'; + } else { + groupVisibility[groupId] = 'visible'; + } + } + function isGroupVisible(groupId: string) { + return groupVisibility[groupId] === 'visible' || groupVisibility[groupId] === 'overflow'; + } + return { + groupVisibility: () => groupVisibility, + isSingleItemVisible(itemId: string, groupId: string) { + return ( + isGroupVisible(groupId) && + groups[groupId].visibleItemIds.has(itemId) && + groups[groupId].visibleItemIds.size === 1 + ); + }, + addItem(itemId: string, groupId: string) { + groups[groupId] ??= { + visibleItemIds: new Set(), + invisibleItemIds: new Set(), + }; + + groups[groupId].visibleItemIds.add(itemId); + updateGroupVisibility(groupId); + }, + removeItem(itemId: string, groupId: string) { + groups[groupId].invisibleItemIds.delete(itemId); + groups[groupId].visibleItemIds.delete(itemId); + updateGroupVisibility(groupId); + }, + showItem(itemId: string, groupId: string) { + groups[groupId].invisibleItemIds.delete(itemId); + groups[groupId].visibleItemIds.add(itemId); + updateGroupVisibility(groupId); + }, + hideItem(itemId: string, groupId: string) { + groups[groupId].invisibleItemIds.add(itemId); + groups[groupId].visibleItemIds.delete(itemId); + updateGroupVisibility(groupId); + }, + }; + }; + + const groupManager = createGroupManager(); + const invisibleItemQueue = createPriorityQueue((a, b) => { const itemA = overflowItems[a]; const itemB = overflowItems[b]; @@ -72,30 +139,41 @@ export function createOverflowManager(): OverflowManager { return options.overflowAxis === 'horizontal' ? el.offsetWidth : el.offsetHeight; }; - const makeItemVisible = () => { - const nextVisible = invisibleItemQueue.dequeue(); - visibleItemQueue.enqueue(nextVisible); + function computeSizeChange(entry: OverflowItemEntry) { + const dividerWidth = + entry.groupId && groupManager.isSingleItemVisible(entry.id, entry.groupId) && overflowDividers[entry.groupId] + ? getOffsetSize(overflowDividers[entry.groupId].element) + : 0; + + return getOffsetSize(entry.element) + dividerWidth; + } - const item = overflowItems[nextVisible]; + const showItem = () => { + const item = getNextItem(invisibleItemQueue, visibleItemQueue); options.onUpdateItemVisibility({ item, visible: true }); + if (item.groupId) { - overflowGroups[item.groupId].invisibleItemIds.delete(item.id); - overflowGroups[item.groupId].visibleItemIds.add(item.id); + groupManager.showItem(item.id, item.groupId); + + if (groupManager.isSingleItemVisible(item.id, item.groupId)) { + overflowDividers[item.groupId]?.element.removeAttribute(DATA_OVERFLOWING); + } } - return getOffsetSize(item.element); + return computeSizeChange(item); }; - const makeItemInvisible = () => { - const nextInvisible = visibleItemQueue.dequeue(); - invisibleItemQueue.enqueue(nextInvisible); - - const item = overflowItems[nextInvisible]; - const width = getOffsetSize(item.element); + const hideItem = () => { + const item = getNextItem(visibleItemQueue, invisibleItemQueue); + const width = computeSizeChange(item); options.onUpdateItemVisibility({ item, visible: false }); + if (item.groupId) { - overflowGroups[item.groupId].visibleItemIds.delete(item.id); - overflowGroups[item.groupId].invisibleItemIds.add(item.id); + if (groupManager.isSingleItemVisible(item.id, item.groupId)) { + overflowDividers[item.groupId]?.element.setAttribute(DATA_OVERFLOWING, ''); + } + + groupManager.hideItem(item.id, item.groupId); } return width; @@ -108,66 +186,45 @@ export function createOverflowManager(): OverflowManager { const visibleItems = visibleItemIds.map(itemId => overflowItems[itemId]); const invisibleItems = invisibleItemIds.map(itemId => overflowItems[itemId]); - const groupVisibility: Record = {}; - Object.entries(overflowGroups).forEach(([groupId, groupState]) => { - if (groupState.invisibleItemIds.size && groupState.visibleItemIds.size) { - groupVisibility[groupId] = 'overflow'; - } else if (groupState.visibleItemIds.size === 0) { - groupVisibility[groupId] = 'hidden'; - } else { - groupVisibility[groupId] = 'visible'; - } - }); - - options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility }); + options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility: groupManager.groupVisibility() }); }; const processOverflowItems = (): boolean => { if (!container) { return false; } + const totalDividersWidth = Object.values(overflowDividers) + .map(dvdr => (dvdr.groupId ? getOffsetSize(dvdr.element) : 0)) + .reduce((prev, current) => prev + current, 0); - const availableSize = getOffsetSize(container) - options.padding; - const overflowMenuOffset = overflowMenu ? getOffsetSize(overflowMenu) : 0; + const availableSize = + getOffsetSize(container) - + options.padding - + totalDividersWidth - + (overflowMenu ? getOffsetSize(overflowMenu) : 0); // Snapshot of the visible/invisible state to compare for updates const visibleTop = visibleItemQueue.peek(); const invisibleTop = invisibleItemQueue.peek(); - const visibleItemIds = visibleItemQueue.all(); - let currentWidth = visibleItemIds.reduce((sum, visibleItemId) => { - const child = overflowItems[visibleItemId].element; - return sum + getOffsetSize(child); - }, 0); + let currentWidth = visibleItemQueue + .all() + .map(id => overflowItems[id].element) + .map(getOffsetSize) + .reduce((prev, current) => prev + current, 0); // Add items until available width is filled - can result in overflow while (currentWidth < availableSize && invisibleItemQueue.size() > 0) { - currentWidth += makeItemVisible(); + currentWidth += showItem(); } // Remove items until there's no more overflow - while (currentWidth > availableSize && visibleItemQueue.size() > 0) { - if (visibleItemQueue.size() <= options.minimumVisible) { - break; - } - currentWidth -= makeItemInvisible(); - } - - // make sure the overflow menu can fit - if ( - visibleItemQueue.size() > options.minimumVisible && - invisibleItemQueue.size() > 0 && - currentWidth + overflowMenuOffset > availableSize - ) { - makeItemInvisible(); + while (currentWidth > availableSize && visibleItemQueue.size() > options.minimumVisible) { + currentWidth -= hideItem(); } // only update when the state of visible/invisible items has changed - if (visibleItemQueue.peek() !== visibleTop || invisibleItemQueue.peek() !== invisibleTop) { - return true; - } - - return false; + return visibleItemQueue.peek() !== visibleTop || invisibleItemQueue.peek() !== invisibleTop; }; const forceUpdate: OverflowManager['forceUpdate'] = () => { @@ -210,14 +267,8 @@ export function createOverflowManager(): OverflowManager { } if (item.groupId) { - if (!overflowGroups[item.groupId]) { - overflowGroups[item.groupId] = { - visibleItemIds: new Set(), - invisibleItemIds: new Set(), - }; - } - - overflowGroups[item.groupId].visibleItemIds.add(item.id); + groupManager.addItem(item.id, item.groupId); + item.element.setAttribute(DATA_OVERFLOW_GROUP, item.groupId); } update(); @@ -227,10 +278,30 @@ export function createOverflowManager(): OverflowManager { overflowMenu = el; }; + const addDivider: OverflowManager['addDivider'] = divider => { + if (!divider.groupId || overflowDividers[divider.groupId]) { + return; + } + + divider.element.setAttribute(DATA_OVERFLOW_GROUP, divider.groupId); + overflowDividers[divider.groupId] = divider; + }; + const removeOverflowMenu: OverflowManager['removeOverflowMenu'] = () => { overflowMenu = undefined; }; + const removeDivider: OverflowManager['removeDivider'] = groupId => { + if (!overflowDividers[groupId]) { + return; + } + const divider = overflowDividers[groupId]; + if (divider.groupId) { + delete overflowDividers[groupId]; + divider.element.removeAttribute(DATA_OVERFLOW_GROUP); + } + }; + const removeItem: OverflowManager['removeItem'] = itemId => { if (!overflowItems[itemId]) { return; @@ -241,8 +312,8 @@ export function createOverflowManager(): OverflowManager { invisibleItemQueue.remove(itemId); if (item.groupId) { - overflowGroups[item.groupId].visibleItemIds.delete(item.id); - overflowGroups[item.groupId].invisibleItemIds.delete(item.id); + groupManager.removeItem(item.id, item.groupId); + item.element.removeAttribute(DATA_OVERFLOW_GROUP); } delete overflowItems[itemId]; @@ -258,5 +329,7 @@ export function createOverflowManager(): OverflowManager { update, addOverflowMenu, removeOverflowMenu, + addDivider, + removeDivider, }; } diff --git a/packages/react-components/priority-overflow/src/types.ts b/packages/react-components/priority-overflow/src/types.ts index 75cb10d193a937..a5a9a41d1516df 100644 --- a/packages/react-components/priority-overflow/src/types.ts +++ b/packages/react-components/priority-overflow/src/types.ts @@ -19,6 +19,15 @@ export interface OverflowItemEntry { groupId?: string; } +export interface OverflowDividerEntry { + /** + * HTML element that will be disappear when overflowed + */ + element: HTMLElement; + + groupId: string; +} + /** * signature similar to standard event listeners, but typed to handle the custom event */ @@ -111,6 +120,16 @@ export interface OverflowManager { */ addOverflowMenu: (element: HTMLElement) => void; + /** + * Add overflow divider + */ + addDivider: (divider: OverflowDividerEntry) => void; + + /** + * Remove overflow divider + */ + removeDivider: (groupId: string) => void; + /** * Unsets the overflow menu element */ diff --git a/packages/react-components/react-breadcrumb/stories/Breadcrumb/BreadcrumbWithOverflow.stories.tsx b/packages/react-components/react-breadcrumb/stories/Breadcrumb/BreadcrumbWithOverflow.stories.tsx index b5a5c1c9d26c0a..8775694e3482a3 100644 --- a/packages/react-components/react-breadcrumb/stories/Breadcrumb/BreadcrumbWithOverflow.stories.tsx +++ b/packages/react-components/react-breadcrumb/stories/Breadcrumb/BreadcrumbWithOverflow.stories.tsx @@ -11,11 +11,11 @@ import { MenuPopover, MenuTrigger, useIsOverflowItemVisible, - useIsOverflowGroupVisible, useOverflowMenu, Overflow, OverflowItem, MenuItem, + OverflowDivider, } from '@fluentui/react-components'; import { CalendarMonthFilled, @@ -161,12 +161,11 @@ const OverflowBreadcrumbButton: React.FC<{ id: string; item: Item }> = props => const OverflowGroupDivider: React.FC<{ groupId: number; }> = props => { - const groupVisibility = useIsOverflowGroupVisible(props.groupId.toString()); - if (groupVisibility === 'hidden') { - return null; - } - - return ; + return ( + + + + ); }; const ControlledOverflowMenu = (props: PartitionBreadcrumbItems) => { diff --git a/packages/react-components/react-components/etc/react-components.api.md b/packages/react-components/react-components/etc/react-components.api.md index 966d033f0de62f..a0503fd40ab55e 100644 --- a/packages/react-components/react-components/etc/react-components.api.md +++ b/packages/react-components/react-components/etc/react-components.api.md @@ -163,6 +163,7 @@ import { createTableColumn } from '@fluentui/react-table'; import { CreateTableColumnOptions } from '@fluentui/react-table'; import { createTeamsDarkTheme } from '@fluentui/react-theme'; import { CurveTokens } from '@fluentui/react-theme'; +import { DATA_OVERFLOW_DIVIDER } from '@fluentui/react-overflow'; import { DATA_OVERFLOW_ITEM } from '@fluentui/react-overflow'; import { DATA_OVERFLOW_MENU } from '@fluentui/react-overflow'; import { DATA_OVERFLOWING } from '@fluentui/react-overflow'; @@ -407,6 +408,7 @@ import { OptionProps } from '@fluentui/react-combobox'; import { OptionSlots } from '@fluentui/react-combobox'; import { OptionState } from '@fluentui/react-combobox'; import { Overflow } from '@fluentui/react-overflow'; +import { OverflowDivider } from '@fluentui/react-overflow'; import { OverflowItem } from '@fluentui/react-overflow'; import { OverflowItemProps } from '@fluentui/react-overflow'; import { OverflowProps } from '@fluentui/react-overflow'; @@ -1341,6 +1343,8 @@ export { createTeamsDarkTheme } export { CurveTokens } +export { DATA_OVERFLOW_DIVIDER } + export { DATA_OVERFLOW_ITEM } export { DATA_OVERFLOW_MENU } @@ -1829,6 +1833,8 @@ export { OptionState } export { Overflow } +export { OverflowDivider } + export { OverflowItem } export { OverflowItemProps } diff --git a/packages/react-components/react-components/src/index.ts b/packages/react-components/react-components/src/index.ts index e1a462d7e1d6f2..53e0e52a7e7368 100644 --- a/packages/react-components/react-components/src/index.ts +++ b/packages/react-components/react-components/src/index.ts @@ -832,6 +832,7 @@ export type { ProgressBarProps, ProgressBarState, ProgressBarSlots } from '@flue export { Overflow, OverflowItem, + OverflowDivider, useIsOverflowGroupVisible, useIsOverflowItemVisible, useOverflowCount, @@ -839,6 +840,7 @@ export { DATA_OVERFLOWING, DATA_OVERFLOW_MENU, DATA_OVERFLOW_ITEM, + DATA_OVERFLOW_DIVIDER, } from '@fluentui/react-overflow'; export type { OverflowProps, OverflowItemProps } from '@fluentui/react-overflow'; diff --git a/packages/react-components/react-overflow/etc/react-overflow.api.md b/packages/react-components/react-overflow/etc/react-overflow.api.md index 23ccaca79cc5a6..5e73d2df4ffe4e 100644 --- a/packages/react-components/react-overflow/etc/react-overflow.api.md +++ b/packages/react-components/react-overflow/etc/react-overflow.api.md @@ -7,10 +7,14 @@ import { ContextSelector } from '@fluentui/react-context-selector'; import type { ObserveOptions } from '@fluentui/priority-overflow'; import type { OnUpdateOverflow } from '@fluentui/priority-overflow'; +import type { OverflowDividerEntry } from '@fluentui/priority-overflow'; import { OverflowGroupState } from '@fluentui/priority-overflow'; import type { OverflowItemEntry } from '@fluentui/priority-overflow'; import * as React_2 from 'react'; +// @public (undocumented) +export const DATA_OVERFLOW_DIVIDER = "data-overflow-divider"; + // @public (undocumented) export const DATA_OVERFLOW_ITEM = "data-overflow-item"; @@ -25,6 +29,9 @@ export const Overflow: React_2.ForwardRefExoticComponent>; +// @public +export const OverflowDivider: React_2.ForwardRefExoticComponent>; + // @public export const OverflowItem: React_2.ForwardRefExoticComponent>; @@ -51,7 +58,7 @@ export function useIsOverflowItemVisible(id: string): boolean; export const useOverflowContainer: (update: OnUpdateOverflow, options: Omit) => UseOverflowContainerReturn; // @internal (undocumented) -export interface UseOverflowContainerReturn extends Pick { +export interface UseOverflowContainerReturn extends Pick { containerRef: React_2.RefObject; } @@ -61,6 +68,9 @@ export const useOverflowContext: (selector: ContextSelector number; +// @internal +export function useOverflowDivider(groupId?: string): React_2.RefObject; + // @internal export function useOverflowItem(id: string, priority?: number, groupId?: string): React_2.RefObject; diff --git a/packages/react-components/react-overflow/src/Overflow.cy.tsx b/packages/react-components/react-overflow/src/Overflow.cy.tsx index 66ee268948f971..fae4d28d9abcdf 100644 --- a/packages/react-components/react-overflow/src/Overflow.cy.tsx +++ b/packages/react-components/react-overflow/src/Overflow.cy.tsx @@ -3,6 +3,7 @@ import { mount } from '@cypress/react'; import { Overflow, OverflowItem, + OverflowDivider, OverflowItemProps, OverflowProps, useIsOverflowGroupVisible, @@ -140,6 +141,36 @@ export const Divider: React.FC<{ ); }; +export const CustomDivider: React.FC<{ + groupId: string; + children?: React.ReactNode; +}> = ({ groupId, children }) => { + const isGroupVisible = useIsOverflowGroupVisible(groupId); + + if (isGroupVisible === 'hidden') { + return null; + } + + const selector = { + [selectors.divider]: groupId, + }; + + const style = { + display: 'inline-block', + width: '30px', + backgroundColor: 'red', + height: '20px', + }; + + return ( + +
+ {children} +
+
+ ); +}; + describe('Overflow', () => { before(() => { cy.viewport(700, 700); @@ -456,6 +487,65 @@ describe('Overflow', () => { cy.get(`[${selectors.divider}]`).should('have.length', 1); }); + it('should collapse correctly with custom divider', () => { + mount( + + + 1 + + + + + + 2 + + + + + + 3 + + + 4 + + + + + + 5 + + + 6 + + + 7 + + + + + + 8 + + + , + ); + + cy.get(`[${selectors.item}="8"]`).should('not.be.visible'); + setContainerSize(350); + cy.get(`[${selectors.divider}="4"]`).should('not.exist'); + cy.get(`[${selectors.divider}]`).should('have.length', 3); + cy.get(`[${selectors.item}="5"]`).should('not.be.visible'); + setContainerSize(250); + cy.get(`[${selectors.divider}="3"]`).should('not.exist'); + cy.get(`[${selectors.divider}]`).should('have.length', 2); + cy.get(`[${selectors.item}="3"]`).should('not.be.visible'); + setContainerSize(200); + cy.get(`[${selectors.divider}="1"]`).should('exist'); + cy.get(`[${selectors.divider}]`).should('have.length', 1); + cy.get(`[${selectors.item}="1"]`).should('be.visible'); + cy.get(`[${selectors.item}="2"]`).should('not.be.visible'); + }); + it('should remove overflow menu if the last overflowed item can take its place', () => { const mapHelper = new Array(10).fill(0).map((_, i) => i); mount( diff --git a/packages/react-components/react-overflow/src/components/Overflow.tsx b/packages/react-components/react-overflow/src/components/Overflow.tsx index 1a599fd3c65f36..bd7fe38d107f23 100644 --- a/packages/react-components/react-overflow/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/src/components/Overflow.tsx @@ -41,7 +41,9 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { const { visibleItems, invisibleItems, groupVisibility } = data; const itemVisibility: Record = {}; - visibleItems.forEach(x => (itemVisibility[x.id] = true)); + visibleItems.forEach(item => { + itemVisibility[item.id] = true; + }); invisibleItems.forEach(x => (itemVisibility[x.id] = false)); setOverflowState(() => { @@ -53,13 +55,16 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { }); }; - const { containerRef, registerItem, updateOverflow, registerOverflowMenu } = useOverflowContainer(update, { - overflowDirection, - overflowAxis, - padding, - minimumVisible, - onUpdateItemVisibility: updateVisibilityAttribute, - }); + const { containerRef, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = useOverflowContainer( + update, + { + overflowDirection, + overflowAxis, + padding, + minimumVisible, + onUpdateItemVisibility: updateVisibilityAttribute, + }, + ); const clonedChild = applyTriggerPropsToChildren(children, { ref: useMergedRefs(containerRef, ref), @@ -75,6 +80,7 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { registerItem, updateOverflow, registerOverflowMenu, + registerDivider, }} > {clonedChild} diff --git a/packages/react-components/react-overflow/src/components/OverflowDivider/OverflowDivider.tsx b/packages/react-components/react-overflow/src/components/OverflowDivider/OverflowDivider.tsx new file mode 100644 index 00000000000000..2b8c179442147c --- /dev/null +++ b/packages/react-components/react-overflow/src/components/OverflowDivider/OverflowDivider.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { applyTriggerPropsToChildren, useMergedRefs } from '@fluentui/react-utilities'; +import { useOverflowDivider } from '../../useOverflowDivider'; +import { OverflowDividerProps } from './OverflowDivider.types'; + +/** + * Attaches overflow item behavior to its child registered with the OverflowContext. + * It does not render an element of its own. + */ +export const OverflowDivider = React.forwardRef((props: OverflowDividerProps, ref) => { + const { groupId, children } = props; + + const containerRef = useOverflowDivider(groupId); + return applyTriggerPropsToChildren(children, { + ref: useMergedRefs(containerRef, ref), + }); +}); diff --git a/packages/react-components/react-overflow/src/components/OverflowDivider/OverflowDivider.types.ts b/packages/react-components/react-overflow/src/components/OverflowDivider/OverflowDivider.types.ts new file mode 100644 index 00000000000000..c7aecfdfc49858 --- /dev/null +++ b/packages/react-components/react-overflow/src/components/OverflowDivider/OverflowDivider.types.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; + +/** + * OverflowDividerProps + */ +export type OverflowDividerProps = { + /** + * Assigns the item to a group, group visibility can be watched. + */ + groupId: string; + /** + * The single child that has overflow item behavior attached. + */ + children: React.ReactElement; +}; diff --git a/packages/react-components/react-overflow/src/constants.ts b/packages/react-components/react-overflow/src/constants.ts index a4cf687ac9dc35..f4d77032cd191d 100644 --- a/packages/react-components/react-overflow/src/constants.ts +++ b/packages/react-components/react-overflow/src/constants.ts @@ -1,3 +1,4 @@ export const DATA_OVERFLOWING = 'data-overflowing'; export const DATA_OVERFLOW_ITEM = 'data-overflow-item'; export const DATA_OVERFLOW_MENU = 'data-overflow-menu'; +export const DATA_OVERFLOW_DIVIDER = 'data-overflow-divider'; diff --git a/packages/react-components/react-overflow/src/index.ts b/packages/react-components/react-overflow/src/index.ts index 5466d7c6a93a36..dd4f52fbb2134f 100644 --- a/packages/react-components/react-overflow/src/index.ts +++ b/packages/react-components/react-overflow/src/index.ts @@ -1,6 +1,6 @@ export { Overflow } from './components/Overflow'; export type { OverflowProps } from './components/Overflow'; -export { DATA_OVERFLOWING, DATA_OVERFLOW_ITEM, DATA_OVERFLOW_MENU } from './constants'; +export { DATA_OVERFLOWING, DATA_OVERFLOW_ITEM, DATA_OVERFLOW_MENU, DATA_OVERFLOW_DIVIDER } from './constants'; export type { UseOverflowContainerReturn } from './types'; export { useIsOverflowGroupVisible } from './useIsOverflowGroupVisible'; export { useIsOverflowItemVisible } from './useIsOverflowItemVisible'; @@ -8,8 +8,10 @@ export { useOverflowContainer } from './useOverflowContainer'; export { useOverflowCount } from './useOverflowCount'; export { useOverflowItem } from './useOverflowItem'; export { useOverflowMenu } from './useOverflowMenu'; +export { useOverflowDivider } from './useOverflowDivider'; export { useOverflowContext } from './overflowContext'; export type { OverflowItemProps } from './components/OverflowItem/OverflowItem.types'; export { OverflowItem } from './components/OverflowItem/OverflowItem'; +export { OverflowDivider } from './components/OverflowDivider/OverflowDivider'; diff --git a/packages/react-components/react-overflow/src/overflowContext.ts b/packages/react-components/react-overflow/src/overflowContext.ts index 87a53f791f9c27..3f40467a45e15f 100644 --- a/packages/react-components/react-overflow/src/overflowContext.ts +++ b/packages/react-components/react-overflow/src/overflowContext.ts @@ -1,4 +1,4 @@ -import type { OverflowGroupState, OverflowItemEntry } from '@fluentui/priority-overflow'; +import type { OverflowGroupState, OverflowItemEntry, OverflowDividerEntry } from '@fluentui/priority-overflow'; import { ContextSelector, createContext, useContextSelector, Context } from '@fluentui/react-context-selector'; /** @@ -10,6 +10,7 @@ export interface OverflowContextValue { hasOverflow: boolean; registerItem: (item: OverflowItemEntry) => () => void; registerOverflowMenu: (el: HTMLElement) => () => void; + registerDivider: (divider: OverflowDividerEntry) => () => void; updateOverflow: (padding?: number) => void; } @@ -24,6 +25,7 @@ const overflowContextDefaultValue: OverflowContextValue = { registerItem: () => () => null, updateOverflow: () => null, registerOverflowMenu: () => () => null, + registerDivider: () => () => null, }; /** diff --git a/packages/react-components/react-overflow/src/types.ts b/packages/react-components/react-overflow/src/types.ts index 45a60daa71415d..4bf971e67fb3c0 100644 --- a/packages/react-components/react-overflow/src/types.ts +++ b/packages/react-components/react-overflow/src/types.ts @@ -5,7 +5,7 @@ import { OverflowContextValue } from './overflowContext'; * @internal */ export interface UseOverflowContainerReturn - extends Pick { + extends Pick { /** * Ref to apply to the container that will overflow */ diff --git a/packages/react-components/react-overflow/src/useOverflowContainer.test.ts b/packages/react-components/react-overflow/src/useOverflowContainer.test.ts index 978d95d8db7a1d..ac9854b8c020cc 100644 --- a/packages/react-components/react-overflow/src/useOverflowContainer.test.ts +++ b/packages/react-components/react-overflow/src/useOverflowContainer.test.ts @@ -15,6 +15,8 @@ const mockOverflowManager = (options: Partial = {}) => { removeItem: jest.fn(), removeOverflowMenu: jest.fn(), update: jest.fn(), + addDivider: jest.fn(), + removeDivider: jest.fn(), }; (createOverflowManager as jest.Mock).mockReturnValue({ ...defaultMock, diff --git a/packages/react-components/react-overflow/src/useOverflowContainer.ts b/packages/react-components/react-overflow/src/useOverflowContainer.ts index 9eb55e4b5ac368..32dc0f44ffa855 100644 --- a/packages/react-components/react-overflow/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/src/useOverflowContainer.ts @@ -8,12 +8,13 @@ import type { OnUpdateItemVisibility, OnUpdateOverflow, OverflowItemEntry, + OverflowDividerEntry, OverflowManager, ObserveOptions, } from '@fluentui/priority-overflow'; import { canUseDOM, useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import { UseOverflowContainerReturn } from './types'; -import { DATA_OVERFLOWING, DATA_OVERFLOW_ITEM, DATA_OVERFLOW_MENU } from './constants'; +import { DATA_OVERFLOWING, DATA_OVERFLOW_DIVIDER, DATA_OVERFLOW_ITEM, DATA_OVERFLOW_MENU } from './constants'; /** * @internal @@ -78,6 +79,20 @@ export const useOverflowContainer = ( [overflowManager], ); + const registerDivider = React.useCallback( + (divider: OverflowDividerEntry) => { + const el = divider.element; + overflowManager?.addDivider(divider); + el && el.setAttribute(DATA_OVERFLOW_DIVIDER, ''); + + return () => { + divider.groupId && overflowManager?.removeDivider(divider.groupId); + el.removeAttribute(DATA_OVERFLOW_DIVIDER); + }; + }, + [overflowManager], + ); + const updateOverflow = React.useCallback(() => { overflowManager?.update(); }, [overflowManager]); @@ -100,6 +115,7 @@ export const useOverflowContainer = ( registerItem, updateOverflow, registerOverflowMenu, + registerDivider, }; }; diff --git a/packages/react-components/react-overflow/src/useOverflowDivider.ts b/packages/react-components/react-overflow/src/useOverflowDivider.ts new file mode 100644 index 00000000000000..8385ae6ea6f24f --- /dev/null +++ b/packages/react-components/react-overflow/src/useOverflowDivider.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { useOverflowContext } from './overflowContext'; + +/** + * @internal + * Registers an overflow item + * @param groupId - assigns the item to a group, group visibility can be watched + * @returns ref to assign to an intrinsic HTML element + */ +export function useOverflowDivider(groupId?: string) { + const ref = React.useRef(null); + const registerDivider = useOverflowContext(v => v.registerDivider); + + useIsomorphicLayoutEffect(() => { + if (ref.current && groupId) { + return registerDivider({ + element: ref.current, + groupId, + }); + } + }, [registerDivider, groupId]); + + return ref; +} diff --git a/packages/react-components/react-overflow/stories/Overflow/LargerDividers.stories.tsx b/packages/react-components/react-overflow/stories/Overflow/LargerDividers.stories.tsx new file mode 100644 index 00000000000000..c802641f44ec2e --- /dev/null +++ b/packages/react-components/react-overflow/stories/Overflow/LargerDividers.stories.tsx @@ -0,0 +1,173 @@ +import * as React from 'react'; +import { + makeStyles, + shorthands, + Button, + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + MenuDivider, + MenuButton, + tokens, + mergeClasses, + Overflow, + OverflowItem, + useIsOverflowGroupVisible, + useIsOverflowItemVisible, + useOverflowMenu, + OverflowDivider, +} from '@fluentui/react-components'; +import { ChevronRight20Regular } from '@fluentui/react-icons'; + +const useStyles = makeStyles({ + container: { + display: 'flex', + flexWrap: 'nowrap', + minWidth: 0, + ...shorthands.overflow('hidden'), + }, + + resizableArea: { + minWidth: '200px', + maxWidth: '800px', + ...shorthands.border('2px', 'solid', tokens.colorBrandBackground), + ...shorthands.padding('20px', '10px', '10px', '10px'), + position: 'relative', + resize: 'horizontal', + '::after': { + content: `'Resizable Area'`, + position: 'absolute', + ...shorthands.padding('1px', '4px', '1px'), + top: '-2px', + left: '-2px', + fontFamily: 'monospace', + fontSize: '15px', + fontWeight: 900, + lineHeight: 1, + letterSpacing: '1px', + color: tokens.colorNeutralForegroundOnBrand, + backgroundColor: tokens.colorBrandBackground, + }, + }, +}); + +export const LargerDividers = () => { + const styles = useStyles(); + + return ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +const OverflowGroupDivider: React.FC<{ + groupId: string; +}> = props => { + return ( + +
+ +
+
+ ); +}; + +const OverflowMenu: React.FC<{ itemIds: string[] }> = ({ itemIds }) => { + const { ref, overflowCount, isOverflowing } = useOverflowMenu(); + + if (!isOverflowing) { + return null; + } + + return ( + + + +{overflowCount} items + + + + + {itemIds.map(i => { + // This is purely a simplified convention for documentation examples + // Could be done in other ways too + if (typeof i === 'string' && i.startsWith('divider')) { + const groupId = i.split('-')[1]; + return ; + } + return ; + })} + + + + ); +}; + +const OverflowMenuItem: React.FC<{ id: string }> = props => { + const { id } = props; + const isVisible = useIsOverflowItemVisible(id); + + if (isVisible) { + return null; + } + + return Item {id}; +}; + +const OverflowMenuDivider: React.FC<{ + id: string; +}> = props => { + const isGroupVisible = useIsOverflowGroupVisible(props.id); + + if (isGroupVisible === 'visible') { + return null; + } + + return ; +}; + +LargerDividers.parameters = { + docs: { + description: { + story: [ + 'For smaller dividers the `padding` prop can be set to take into account the unmeasured space that the divider takes up.', + 'When a larger divider is used its width is not calculated. This causes items to overflow later than needed.', + 'The `OverflowDivider` divider component can be used for larger dividers to include its width to the calculation.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-overflow/stories/Overflow/index.stories.tsx b/packages/react-components/react-overflow/stories/Overflow/index.stories.tsx index ea07735698c6b3..ac995aaa8a1f5d 100644 --- a/packages/react-components/react-overflow/stories/Overflow/index.stories.tsx +++ b/packages/react-components/react-overflow/stories/Overflow/index.stories.tsx @@ -10,6 +10,7 @@ export { OverflowByPriority } from './OverflowByPriority.stories'; export { Wrapped } from './Wrapped.stories'; export { Pinned } from './Pinned.stories'; export { Dividers } from './Dividers.stories'; +export { LargerDividers } from './LargerDividers.stories'; export { PriorityWithDividers } from './PriorityWithDividers.stories'; export default {