diff --git a/change/@fluentui-priority-overflow-pr2-subscribe.json b/change/@fluentui-priority-overflow-pr2-subscribe.json new file mode 100644 index 00000000000000..a77b1fb535a592 --- /dev/null +++ b/change/@fluentui-priority-overflow-pr2-subscribe.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: expose getSnapshot and subscribe on OverflowManager", + "packageName": "@fluentui/priority-overflow", + "email": "bsunderhus@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-overflow-pr2-subscribe.json b/change/@fluentui-react-overflow-pr2-subscribe.json new file mode 100644 index 00000000000000..78e7ba9fc7e01d --- /dev/null +++ b/change/@fluentui-react-overflow-pr2-subscribe.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: subscribe overflow hooks directly to the manager snapshot", + "packageName": "@fluentui/react-overflow", + "email": "bsunderhus@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 2a720d068daf9e..7186ac842abd47 100644 --- a/packages/react-components/priority-overflow/etc/priority-overflow.api.md +++ b/packages/react-components/priority-overflow/etc/priority-overflow.api.md @@ -68,11 +68,13 @@ export interface OverflowManager { addOverflowMenu: (element: HTMLElement) => void; disconnect: () => void; forceUpdate: () => void; + getSnapshot: () => OverflowEventPayload; observe: (container: HTMLElement, options?: ObserveOptions) => void; removeDivider: (groupId: string) => void; removeItem: (itemId: string) => void; removeOverflowMenu: () => void; setOptions: (options: Partial) => void; + subscribe: (listener: () => void) => () => void; update: () => void; } diff --git a/packages/react-components/priority-overflow/src/overflowManager.test.ts b/packages/react-components/priority-overflow/src/overflowManager.test.ts index d937f4615f9ff1..8e33bde836bf98 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.test.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.test.ts @@ -1,5 +1,5 @@ import { createOverflowManager } from './overflowManager'; -import type { ObserveOptions, OverflowEventPayload } from './types'; +import type { ObserveOptions } from './types'; describe('overflowManager', () => { beforeAll(() => { @@ -45,13 +45,20 @@ describe('overflowManager', () => { ...options, }); - const lastDispatch = (onUpdateOverflow: jest.Mock): OverflowEventPayload => - onUpdateOverflow.mock.calls[onUpdateOverflow.mock.calls.length - 1][0]; + const getVisibleIds = (manager: ReturnType) => + manager + .getSnapshot() + .visibleItems.map(item => item.id) + .sort(); - it('should dispatch overflow update after forceUpdate', () => { - const onUpdateOverflow = jest.fn(); - const options = createObserveOptions({ onUpdateOverflow }); - const manager = createOverflowManager(options); + const getInvisibleIds = (manager: ReturnType) => + manager + .getSnapshot() + .invisibleItems.map(item => item.id) + .sort(); + + it('should expose a stable snapshot after forceUpdate', () => { + const manager = createOverflowManager(createObserveOptions()); const container = createContainer(100); const itemA = createElementWithSize('button', 40); const itemB = createElementWithSize('button', 40); @@ -63,79 +70,74 @@ describe('overflowManager', () => { manager.observe(container); manager.forceUpdate(); - const dispatch = lastDispatch(onUpdateOverflow); - expect(dispatch.visibleItems.map(item => item.id).sort()).toEqual(['a', 'b']); - expect(dispatch.invisibleItems).toEqual([]); - expect(dispatch.groupVisibility).toEqual({}); + expect(getVisibleIds(manager)).toEqual(['a', 'b']); + expect(getInvisibleIds(manager)).toEqual([]); + expect(manager.getSnapshot().groupVisibility).toEqual({}); }); - it('should re-dispatch when setOptions changes a relevant option', () => { - const onUpdateOverflow = jest.fn(); - const options = createObserveOptions({ onUpdateOverflow }); - const manager = createOverflowManager(options); + it('should update snapshot and notify subscribers when options change', () => { + const manager = createOverflowManager(createObserveOptions()); const container = createContainer(100); const itemA = createElementWithSize('button', 40); const itemB = createElementWithSize('button', 40); const menu = createElementWithSize('button', 20); + const listener = jest.fn(); manager.addItem({ element: itemA, id: 'a', priority: 1 }); manager.addItem({ element: itemB, id: 'b', priority: 0 }); manager.addOverflowMenu(menu); manager.observe(container); manager.forceUpdate(); + const unsubscribe = manager.subscribe(listener); - onUpdateOverflow.mockClear(); manager.setOptions({ padding: 30 }); - expect(onUpdateOverflow).toHaveBeenCalled(); - const dispatch = lastDispatch(onUpdateOverflow); - expect(dispatch.visibleItems.map(item => item.id)).toEqual(['a']); - expect(dispatch.invisibleItems.map(item => item.id)).toEqual(['b']); + expect(listener).toHaveBeenCalled(); + expect(getVisibleIds(manager)).toEqual(['a']); + expect(getInvisibleIds(manager)).toEqual(['b']); + expect(manager.getSnapshot().groupVisibility).toEqual({}); + + unsubscribe(); }); - it('should not re-dispatch when setOptions is called with a partial that does not change anything', () => { - const onUpdateOverflow = jest.fn(); - const options = createObserveOptions({ onUpdateOverflow }); - const manager = createOverflowManager(options); + it('should not notify subscribers when setOptions is called with a partial that does not change anything', () => { + const manager = createOverflowManager(createObserveOptions()); const container = createContainer(100); const itemA = createElementWithSize('button', 40); manager.addItem({ element: itemA, id: 'a', priority: 1 }); - manager.observe(container, options); + manager.observe(container); manager.forceUpdate(); - onUpdateOverflow.mockClear(); + const listener = jest.fn(); + manager.subscribe(listener); manager.setOptions({ padding: 10 }); // padding is already 10; no real change - expect(onUpdateOverflow).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); }); - it('disconnect stops observation and re-observe restarts dispatching', () => { - const onUpdateOverflow = jest.fn(); - const options = createObserveOptions({ onUpdateOverflow }); - const manager = createOverflowManager(options); + it('should reset snapshot state when disconnect runs', () => { + const manager = createOverflowManager(createObserveOptions()); const container = createContainer(100); const item = createElementWithSize('button', 40); manager.addItem({ element: item, id: 'a', priority: 1 }); manager.observe(container); manager.forceUpdate(); - expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']); + + expect(getVisibleIds(manager)).toEqual(['a']); manager.disconnect(); - onUpdateOverflow.mockClear(); - manager.addItem({ element: item, id: 'a', priority: 1 }); - manager.observe(container); - manager.forceUpdate(); - expect(onUpdateOverflow).toHaveBeenCalled(); - expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']); + expect(manager.getSnapshot()).toEqual({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }); }); it('should remove items through removeItem', () => { - const onUpdateOverflow = jest.fn(); - const options = createObserveOptions({ onUpdateOverflow }); - const manager = createOverflowManager(options); + const manager = createOverflowManager(createObserveOptions()); const container = createContainer(100); const item = createElementWithSize('button', 40); @@ -143,13 +145,15 @@ describe('overflowManager', () => { manager.observe(container); manager.forceUpdate(); - expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']); + expect(getVisibleIds(manager)).toEqual(['a']); manager.removeItem('a'); manager.forceUpdate(); - const dispatch = lastDispatch(onUpdateOverflow); - expect(dispatch.visibleItems).toEqual([]); - expect(dispatch.invisibleItems).toEqual([]); + expect(manager.getSnapshot()).toEqual({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }); }); }); diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index c41f43617cb099..bf288a5548dc9b 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -9,6 +9,7 @@ import type { OverflowManager, ObserveOptions, OverflowDividerEntry, + OverflowEventPayload, } from './types'; const DEFAULT_OPTIONS: Required = { @@ -46,10 +47,22 @@ export function createOverflowManager(initialOptions: Partial = const options: Required = { ...DEFAULT_OPTIONS, ...initialOptions }; const overflowItems: Record = {}; const overflowDividers: Record = {}; + const listeners = new Set<() => void>(); let disposeResizeObserver: () => void = () => { /* noop */ }; + let snapshot: OverflowEventPayload = { + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }; + const takeSnapshot = (nextSnapshot: OverflowEventPayload) => { + snapshot = nextSnapshot; + options.onUpdateOverflow(snapshot); + listeners.forEach(listener => listener()); + }; + const getNextItem = (queueToDequeue: PriorityQueue, queueToEnqueue: PriorityQueue) => { const nextItem = queueToDequeue.dequeue(); queueToEnqueue.enqueue(nextItem); @@ -159,9 +172,15 @@ export function createOverflowManager(initialOptions: Partial = const visibleItems = visibleItemIds.map(itemId => overflowItems[itemId]); const invisibleItems = invisibleItemIds.map(itemId => overflowItems[itemId]); - options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility: groupManager.groupVisibility() }); + takeSnapshot({ + visibleItems, + invisibleItems, + groupVisibility: groupManager.groupVisibility(), + }); }; + const getSnapshot: OverflowManager['getSnapshot'] = () => snapshot; + const processOverflowItems = (): boolean => { if (!container) { return false; @@ -271,6 +290,13 @@ export function createOverflowManager(initialOptions: Partial = Object.keys(overflowDividers).forEach(dividerId => removeDivider(dividerId)); removeOverflowMenu(); sizeCache.clear(); + + // notify subscribers that the manager is no longer tracking anything + takeSnapshot({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }); }; const addItem: OverflowManager['addItem'] = items => { @@ -351,6 +377,14 @@ export function createOverflowManager(initialOptions: Partial = } }; + const subscribe: OverflowManager['subscribe'] = listener => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }; + return { addItem, disconnect, @@ -363,6 +397,8 @@ export function createOverflowManager(initialOptions: Partial = addDivider, removeDivider, setOptions, + getSnapshot, + subscribe, }; } diff --git a/packages/react-components/priority-overflow/src/types.ts b/packages/react-components/priority-overflow/src/types.ts index 51faa5a5ee4fd7..bf4bda0d5b08cf 100644 --- a/packages/react-components/priority-overflow/src/types.ts +++ b/packages/react-components/priority-overflow/src/types.ts @@ -204,4 +204,14 @@ export interface OverflowManager { * Unsets the overflow menu element */ removeOverflowMenu: () => void; + + /** + * Returns the current canonical overflow snapshot. + */ + getSnapshot: () => OverflowEventPayload; + + /** + * Subscribes to snapshot changes. + */ + subscribe: (listener: () => void) => () => void; } diff --git a/packages/react-components/react-overflow/library/etc/react-overflow.api.md b/packages/react-components/react-overflow/library/etc/react-overflow.api.md index 4c435fc9a02e79..f9a9c4759d7c83 100644 --- a/packages/react-components/react-overflow/library/etc/react-overflow.api.md +++ b/packages/react-components/react-overflow/library/etc/react-overflow.api.md @@ -4,11 +4,11 @@ ```ts -import type { 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 { OverflowEventPayload } from '@fluentui/priority-overflow'; +import type { OverflowGroupState } from '@fluentui/priority-overflow'; import type { OverflowItemEntry } from '@fluentui/priority-overflow'; import * as React_2 from 'react'; @@ -72,12 +72,15 @@ 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; } // @internal (undocumented) -export const useOverflowContext: (selector: ContextSelector) => SelectedValue; +export function useOverflowContext(): OverflowContextValue; + +// @internal (undocumented) +export function useOverflowContext(selector: (context: OverflowContextValue) => SelectedValue): SelectedValue; // @public (undocumented) export const useOverflowCount: () => number; diff --git a/packages/react-components/react-overflow/library/package.json b/packages/react-components/react-overflow/library/package.json index 3fe0edb5f8ac3c..8306f8188439ea 100644 --- a/packages/react-components/react-overflow/library/package.json +++ b/packages/react-components/react-overflow/library/package.json @@ -13,7 +13,6 @@ "license": "MIT", "dependencies": { "@fluentui/priority-overflow": "^9.3.0", - "@fluentui/react-context-selector": "^9.2.17", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", "@fluentui/react-utilities": "^9.26.4", diff --git a/packages/react-components/react-overflow/library/src/Overflow.cy.tsx b/packages/react-components/react-overflow/library/src/Overflow.cy.tsx index f4c15e969796ab..faa43c29313665 100644 --- a/packages/react-components/react-overflow/library/src/Overflow.cy.tsx +++ b/packages/react-components/react-overflow/library/src/Overflow.cy.tsx @@ -7,7 +7,7 @@ import { OverflowReorderObserver, useIsOverflowGroupVisible, useOverflowMenu, - useOverflowContext, + useOverflowVisibility, type OverflowProps, type OverflowItemProps, type OnOverflowChangeData, @@ -96,7 +96,7 @@ const Item = ({ children, width, ...overflowItemProps }: ItemProps) => { const Menu: React.FC<{ width?: number }> = ({ width }) => { const { isOverflowing, ref, overflowCount } = useOverflowMenu(); - const itemVisibility = useOverflowContext(ctx => ctx.itemVisibility); + const { itemVisibility } = useOverflowVisibility(); const selector = { [selectors.menu]: '', }; diff --git a/packages/react-components/react-overflow/library/src/components/Overflow.tsx b/packages/react-components/react-overflow/library/src/components/Overflow.tsx index 85563028ad8967..66a65c34e9546b 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -2,7 +2,12 @@ import * as React from 'react'; import { mergeClasses } from '@griffel/react'; -import type { OnUpdateOverflow, OverflowGroupState, ObserveOptions } from '@fluentui/priority-overflow'; +import type { + ObserveOptions, + OnUpdateOverflow, + OverflowEventPayload, + OverflowGroupState, +} from '@fluentui/priority-overflow'; import { applyTriggerPropsToChildren, getTriggerChild, @@ -10,7 +15,7 @@ import { useMergedRefs, } from '@fluentui/react-utilities'; -import { OverflowContext } from '../overflowContext'; +import { OverflowContext, type OverflowContextValue } from '../overflowContext'; import { updateVisibilityAttribute, useOverflowContainer } from '../useOverflowContainer'; import { useOverflowStyles } from './useOverflowStyles.styles'; @@ -51,42 +56,22 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { hasHiddenItems, } = props; - const [overflowState, setOverflowState] = React.useState({ - hasOverflow: false, - itemVisibility: {}, - groupVisibility: {}, - }); - - // useOverflowContainer wraps this method in a useEventCallback. const update: OnUpdateOverflow = data => { - const { visibleItems, invisibleItems, groupVisibility } = data; - - const itemVisibility: Record = {}; - visibleItems.forEach(item => { - itemVisibility[item.id] = true; - }); - invisibleItems.forEach(x => (itemVisibility[x.id] = false)); - const newState = { - hasOverflow: data.invisibleItems.length > 0, - itemVisibility, - groupVisibility, - }; - onOverflowChange?.(null, { ...newState }); - - setOverflowState(newState); + if (!onOverflowChange) { + return; + } + onOverflowChange(null, _overflowPayloadToState(data)); }; - const { containerRef, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = useOverflowContainer( - update, - { + const { containerRef, getSnapshot, subscribe, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = + useOverflowContainer(update, { overflowDirection, overflowAxis, padding, minimumVisible, hasHiddenItems, onUpdateItemVisibility: updateVisibilityAttribute, - }, - ); + }); const child = getTriggerChild(children); const clonedChild = applyTriggerPropsToChildren(children, { @@ -94,20 +79,37 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { className: mergeClasses('fui-Overflow', styles.overflowMenu, styles.overflowingItems, child?.props.className), }); - return ( - - {clonedChild} - + const ctx: OverflowContextValue = React.useMemo( + () => ({ + groupVisibility: {}, + itemVisibility: {}, + hasOverflow: false, + registerItem, + updateOverflow, + registerOverflowMenu, + registerDivider, + containerRef, + getSnapshot, + subscribe, + }), + [getSnapshot, subscribe, registerItem, updateOverflow, registerOverflowMenu, registerDivider, containerRef], ); + + return {clonedChild}; }); + +/** + * @internal + */ +export const _overflowPayloadToState = (data: OverflowEventPayload): OverflowState => { + const itemVisibility: Record = {}; + data.visibleItems.forEach(item => { + itemVisibility[item.id] = true; + }); + data.invisibleItems.forEach(x => (itemVisibility[x.id] = false)); + return { + itemVisibility, + groupVisibility: data.groupVisibility, + hasOverflow: data.invisibleItems.length > 0, + }; +}; diff --git a/packages/react-components/react-overflow/library/src/overflowContext.ts b/packages/react-components/react-overflow/library/src/overflowContext.ts index 3cbe5d28c24430..6bd5578b5af665 100644 --- a/packages/react-components/react-overflow/library/src/overflowContext.ts +++ b/packages/react-components/react-overflow/library/src/overflowContext.ts @@ -1,41 +1,75 @@ 'use client'; -import type * as React from 'react'; -import type { OverflowGroupState, OverflowItemEntry, OverflowDividerEntry } from '@fluentui/priority-overflow'; -import type { ContextSelector, Context } from '@fluentui/react-context-selector'; -import { createContext, useContextSelector } from '@fluentui/react-context-selector'; +import type { + OverflowItemEntry, + OverflowDividerEntry, + OverflowGroupState, + OverflowEventPayload, +} from '@fluentui/priority-overflow'; +import * as React from 'react'; /** * @internal */ export interface OverflowContextValue { + /** + * @deprecated This value is not guaranteed to be up to date and should not be used directly. Use getSnapshot or the provided hooks instead + */ itemVisibility: Record; + /** + * @deprecated This value is not guaranteed to be up to date and should not be used directly. Use getSnapshot or the provided hooks instead + */ groupVisibility: Record; + /** + * @deprecated This value is not guaranteed to be up to date and should not be used directly. Use getSnapshot or the provided hooks instead + */ hasOverflow: boolean; registerItem: (item: OverflowItemEntry) => () => void; registerOverflowMenu: (el: HTMLElement) => () => void; registerDivider: (divider: OverflowDividerEntry) => () => void; updateOverflow: (padding?: number) => void; containerRef?: React.RefObject; + getSnapshot: () => OverflowEventPayload; + subscribe: (listener: () => void) => () => void; } -export const OverflowContext = createContext( +export const OverflowContext = React.createContext( undefined, -) as Context; +) as React.Context; + +const noop = () => { + /* noop */ +}; const overflowContextDefaultValue: OverflowContextValue = { + hasOverflow: false, itemVisibility: {}, groupVisibility: {}, - hasOverflow: false, - registerItem: () => () => null, - updateOverflow: () => null, - registerOverflowMenu: () => () => null, - registerDivider: () => () => null, + registerItem: () => noop, + updateOverflow: noop, + registerOverflowMenu: () => noop, + registerDivider: () => noop, + getSnapshot: () => ({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }), + subscribe: () => noop, }; /** * @internal */ -export const useOverflowContext = ( - selector: ContextSelector, -): SelectedValue => useContextSelector(OverflowContext, (ctx = overflowContextDefaultValue) => selector(ctx)); +export function useOverflowContext(): OverflowContextValue; +/** + * @internal + */ +export function useOverflowContext( + selector: (context: OverflowContextValue) => SelectedValue, +): SelectedValue; +export function useOverflowContext( + selector?: (context: OverflowContextValue) => SelectedValue, +): SelectedValue | OverflowContextValue { + const context = React.useContext(OverflowContext) ?? overflowContextDefaultValue; + return selector ? selector(context) : context; +} diff --git a/packages/react-components/react-overflow/library/src/types.ts b/packages/react-components/react-overflow/library/src/types.ts index 22a2bccd3292f2..fadd260165a5b1 100644 --- a/packages/react-components/react-overflow/library/src/types.ts +++ b/packages/react-components/react-overflow/library/src/types.ts @@ -5,7 +5,10 @@ import type { OverflowContextValue } from './overflowContext'; * @internal */ export interface UseOverflowContainerReturn - extends Pick { + extends Pick< + OverflowContextValue, + 'registerItem' | 'updateOverflow' | 'registerOverflowMenu' | 'registerDivider' | 'getSnapshot' | 'subscribe' + > { /** * Ref to apply to the container that will overflow */ diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts index a389d36fc46224..145bef2063fed2 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts @@ -1,12 +1,12 @@ 'use client'; import type { OverflowGroupState } from '@fluentui/priority-overflow'; -import { useOverflowContext } from './overflowContext'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @param id - unique identifier for a group of overflow items * @returns visibility state of the group */ export function useIsOverflowGroupVisible(id: string): OverflowGroupState { - return useOverflowContext(ctx => ctx.groupVisibility[id]); + return useOverflowSnapshot().groupVisibility[id]; } diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts index f23b26765de17f..f7e066af9fe976 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts @@ -1,11 +1,11 @@ 'use client'; -import { useOverflowContext } from './overflowContext'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @param id - unique identifier for the item used by the overflow manager * @returns visibility state of an overflow item */ export function useIsOverflowItemVisible(id: string): boolean { - return !!useOverflowContext(ctx => ctx.itemVisibility[id]); + return useOverflowSnapshot().visibleItems.some(item => item.id === id); } diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx index d6badfedd4d649..a48bef71294df8 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx @@ -20,6 +20,8 @@ const mockOverflowManager = (options: Partial = {}) => { addDivider: jest.fn(), removeDivider: jest.fn(), setOptions: jest.fn(), + getSnapshot: jest.fn(() => ({ visibleItems: [], invisibleItems: [], groupVisibility: {} })), + subscribe: jest.fn(() => () => null), }; (createOverflowManager as jest.Mock).mockReturnValue({ ...defaultMock, diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 54fcd01abe93ad..6f4146a5dcf5c5 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -118,12 +118,24 @@ export const useOverflowContainer = ( managerRef.current?.update(); }, []); + const getSnapshot = React.useCallback( + () => managerRef.current?.getSnapshot() ?? defaultSnapshot, + [], + ); + + const subscribe = React.useCallback( + listener => managerRef.current?.subscribe(listener) ?? noop, + [], + ); + return { registerItem, registerDivider, registerOverflowMenu, updateOverflow, containerRef, + getSnapshot, + subscribe, }; }; @@ -131,6 +143,12 @@ const noop = () => { /* noop */ }; +const defaultSnapshot = { + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, +}; + export const updateVisibilityAttribute: OnUpdateItemVisibility = ({ item, visible }) => { if (visible) { item.element.removeAttribute(DATA_OVERFLOWING); diff --git a/packages/react-components/react-overflow/library/src/useOverflowCount.ts b/packages/react-components/react-overflow/library/src/useOverflowCount.ts index 91fb75f14a7bc8..d57225462baf92 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowCount.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowCount.ts @@ -1,17 +1,8 @@ 'use client'; -import { useOverflowContext } from './overflowContext'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @returns Number of items that are overflowing */ -export const useOverflowCount = (): number => - useOverflowContext(v => { - return Object.entries(v.itemVisibility).reduce((acc, [id, visible]) => { - if (!visible) { - acc++; - } - - return acc; - }, 0); - }); +export const useOverflowCount = (): number => useOverflowSnapshot().invisibleItems.length; diff --git a/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts new file mode 100644 index 00000000000000..7747c53f69ad43 --- /dev/null +++ b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts @@ -0,0 +1,17 @@ +'use client'; + +import type { OverflowEventPayload } from '@fluentui/priority-overflow'; +import * as React from 'react'; +import { useOverflowContext } from './overflowContext'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; + +export const useOverflowSnapshot = (): OverflowEventPayload => { + const { getSnapshot, subscribe } = useOverflowContext(); + const [snapshot, setSnapshot] = React.useState(() => getSnapshot()); + useIsomorphicLayoutEffect(() => { + return subscribe(() => { + setSnapshot(getSnapshot()); + }); + }, [subscribe, getSnapshot]); + return snapshot; +}; diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx index 508d0d1e8f3382..55a256891b41e7 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx @@ -5,26 +5,30 @@ import type { OverflowContextValue } from './overflowContext'; import { OverflowContext } from './overflowContext'; describe('useOverflowVisibility', () => { - it('should return item and group visiblity', () => { + it('should return item and group visiblity derived from the snapshot', () => { const groupVisibility = { foo: 'hidden', bar: 'overflow', baz: 'visible', } as const; - const itemVisibility = { - foo: true, - bar: true, - baz: false, - } as const; - const Wrapper: React.FC = props => { + const snapshot = { + visibleItems: [ + { id: 'foo', element: document.createElement('div'), priority: 0 }, + { id: 'bar', element: document.createElement('div'), priority: 0 }, + ], + invisibleItems: [{ id: 'baz', element: document.createElement('div'), priority: 0 }], + groupVisibility, + }; + + const Wrapper: React.FC<{ children?: React.ReactNode }> = props => { return ( snapshot, + subscribe: () => () => null, } as unknown as OverflowContextValue } /> @@ -32,6 +36,6 @@ describe('useOverflowVisibility', () => { }; const { result } = renderHook(useOverflowVisibility, { wrapper: Wrapper }); expect(result.current.groupVisibility).toEqual(groupVisibility); - expect(result.current.itemVisibility).toEqual(itemVisibility); + expect(result.current.itemVisibility).toEqual({ foo: true, bar: true, baz: false }); }); }); diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts index 6e406ff85ddb58..327fa10e6ff780 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts @@ -1,7 +1,8 @@ 'use client'; +import type { OverflowEventPayload, OverflowGroupState } from '@fluentui/priority-overflow'; import * as React from 'react'; -import { useOverflowContext } from './overflowContext'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * A hook that returns the visibility status of all items and groups. @@ -14,10 +15,27 @@ import { useOverflowContext } from './overflowContext'; */ export function useOverflowVisibility(): { itemVisibility: Record; - groupVisibility: Record; + groupVisibility: Record; } { - const itemVisibility = useOverflowContext(ctx => ctx.itemVisibility); - const groupVisibility = useOverflowContext(ctx => ctx.groupVisibility); - - return React.useMemo(() => ({ itemVisibility, groupVisibility }), [itemVisibility, groupVisibility]); + const snapshot = useOverflowSnapshot(); + return React.useMemo(() => snapshotToVisibility(snapshot), [snapshot]); } + +const snapshotToVisibility = ( + snapshot: OverflowEventPayload, +): { + itemVisibility: Record; + groupVisibility: Record; +} => { + const itemVisibility: Record = {}; + snapshot.visibleItems.forEach(item => { + itemVisibility[item.id] = true; + }); + snapshot.invisibleItems.forEach(item => { + itemVisibility[item.id] = false; + }); + return { + itemVisibility, + groupVisibility: snapshot.groupVisibility, + }; +};