Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions change/@fluentui-priority-overflow-pr2-subscribe.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: expose getSnapshot and subscribe on OverflowManager",
"packageName": "@fluentui/priority-overflow",
"email": "bsunderhus@microsoft.com",
"dependentChangeType": "patch"
}
7 changes: 7 additions & 0 deletions change/@fluentui-react-overflow-pr2-subscribe.json
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ObserveOptions>) => void;
subscribe: (listener: () => void) => () => void;
Comment on lines +71 to +77
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaking change? @layershifter

update: () => void;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createOverflowManager } from './overflowManager';
import type { ObserveOptions, OverflowEventPayload } from './types';
import type { ObserveOptions } from './types';

describe('overflowManager', () => {
beforeAll(() => {
Expand Down Expand Up @@ -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<typeof createOverflowManager>) =>
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<typeof createOverflowManager>) =>
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);
Expand All @@ -63,93 +70,90 @@ 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);

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.removeItem('a');
manager.forceUpdate();

const dispatch = lastDispatch(onUpdateOverflow);
expect(dispatch.visibleItems).toEqual([]);
expect(dispatch.invisibleItems).toEqual([]);
expect(manager.getSnapshot()).toEqual({
visibleItems: [],
invisibleItems: [],
groupVisibility: {},
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
OverflowManager,
ObserveOptions,
OverflowDividerEntry,
OverflowEventPayload,
} from './types';

const DEFAULT_OPTIONS: Required<ObserveOptions> = {
Expand Down Expand Up @@ -46,10 +47,22 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
const options: Required<ObserveOptions> = { ...DEFAULT_OPTIONS, ...initialOptions };
const overflowItems: Record<string, OverflowItemEntry> = {};
const overflowDividers: Record<string, OverflowDividerEntry> = {};
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<string>, queueToEnqueue: PriorityQueue<string>) => {
const nextItem = queueToDequeue.dequeue();
queueToEnqueue.enqueue(nextItem);
Expand Down Expand Up @@ -159,9 +172,15 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
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;
Expand Down Expand Up @@ -271,6 +290,13 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
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 => {
Expand Down Expand Up @@ -351,6 +377,14 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
}
};

const subscribe: OverflowManager['subscribe'] = listener => {
listeners.add(listener);

return () => {
listeners.delete(listener);
};
};

return {
addItem,
disconnect,
Expand All @@ -363,6 +397,8 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
addDivider,
removeDivider,
setOptions,
getSnapshot,
subscribe,
};
}

Expand Down
10 changes: 10 additions & 0 deletions packages/react-components/priority-overflow/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -72,12 +72,15 @@ export function useIsOverflowItemVisible(id: string): boolean;
export const useOverflowContainer: <TElement extends HTMLElement>(update: OnUpdateOverflow, options: Omit<ObserveOptions, "onUpdateOverflow">) => UseOverflowContainerReturn<TElement>;

// @internal (undocumented)
export interface UseOverflowContainerReturn<TElement extends HTMLElement> extends Pick<OverflowContextValue, 'registerItem' | 'updateOverflow' | 'registerOverflowMenu' | 'registerDivider'> {
export interface UseOverflowContainerReturn<TElement extends HTMLElement> extends Pick<OverflowContextValue, 'registerItem' | 'updateOverflow' | 'registerOverflowMenu' | 'registerDivider' | 'getSnapshot' | 'subscribe'> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaking change? @layershifter

containerRef: React_2.RefObject<TElement | null>;
}

// @internal (undocumented)
export const useOverflowContext: <SelectedValue>(selector: ContextSelector<OverflowContextValue, SelectedValue>) => SelectedValue;
export function useOverflowContext(): OverflowContextValue;

// @internal (undocumented)
export function useOverflowContext<SelectedValue>(selector: (context: OverflowContextValue) => SelectedValue): SelectedValue;

// @public (undocumented)
export const useOverflowCount: () => number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
OverflowReorderObserver,
useIsOverflowGroupVisible,
useOverflowMenu,
useOverflowContext,
useOverflowVisibility,
type OverflowProps,
type OverflowItemProps,
type OnOverflowChangeData,
Expand Down Expand Up @@ -96,7 +96,7 @@ const Item = ({ children, width, ...overflowItemProps }: ItemProps) => {

const Menu: React.FC<{ width?: number }> = ({ width }) => {
const { isOverflowing, ref, overflowCount } = useOverflowMenu<HTMLButtonElement>();
const itemVisibility = useOverflowContext(ctx => ctx.itemVisibility);
const { itemVisibility } = useOverflowVisibility();
const selector = {
[selectors.menu]: '',
};
Expand Down
Loading
Loading