From 33c4aa3afe90fdf42aed8c442e6e1c603ae14809 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Tue, 22 Oct 2024 14:32:23 +0200 Subject: [PATCH 1/6] feat: add `onOverflowChange` callback Adds a callback to inform userland about overflow state to power features that are outside of the overflow component that need to know about overflow state. --- .../library/src/Overflow.cy.tsx | 44 +++++++ .../library/src/components/Overflow.tsx | 14 +- .../react-overflow/library/src/index.ts | 2 +- .../src/Overflow/ListenToChanges.stories.tsx | 121 ++++++++++++++++++ .../stories/src/Overflow/index.stories.tsx | 1 + 5 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 packages/react-components/react-overflow/stories/src/Overflow/ListenToChanges.stories.tsx 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 7662165fc6e1c..97c88c21d7ba4 100644 --- a/packages/react-components/react-overflow/library/src/Overflow.cy.tsx +++ b/packages/react-components/react-overflow/library/src/Overflow.cy.tsx @@ -9,6 +9,7 @@ import { useIsOverflowGroupVisible, useOverflowMenu, useOverflowContext, + type OnOverflowChangeData, } from '@fluentui/react-overflow'; import { Portal } from '@fluentui/react-portal'; import { OverflowAxis } from '@fluentui/priority-overflow'; @@ -1066,4 +1067,47 @@ describe('Overflow', () => { cy.get('#toggle').click(); } }); + + it('shoud call onOverflowChange', () => { + const itemCount = 10; + const mapHelper = new Array(itemCount).fill(0).map((_, i) => i); + let latestUpdate: OnOverflowChangeData | undefined; + mount( + (latestUpdate = data)}> + {mapHelper.map(i => ( + + {i} + + ))} + + , + ); + const overflowCases = [ + { containerSize: 450, overflowCount: 2 }, + { containerSize: 400, overflowCount: 3 }, + { containerSize: 350, overflowCount: 4 }, + { containerSize: 300, overflowCount: 5 }, + { containerSize: 250, overflowCount: 6 }, + { containerSize: 200, overflowCount: 7 }, + { containerSize: 150, overflowCount: 8 }, + { containerSize: 100, overflowCount: 9 }, + { containerSize: 50, overflowCount: 10 }, + ]; + + overflowCases.forEach(({ overflowCount, containerSize }) => { + setContainerWidth(containerSize); + cy.get(`[${selectors.menu}]`).should('have.text', `+${overflowCount}`); + cy.then(() => { + expect(latestUpdate?.hasOverflow).to.equal(true); + const visibleBoundary = itemCount - overflowCount; + for (let i = 0; i < visibleBoundary; i++) { + expect(latestUpdate?.itemVisibility[i.toString()]).to.equal(true); + } + + for (let i = visibleBoundary; i < itemCount; i++) { + expect(latestUpdate?.itemVisibility[i.toString()]).to.equal(false); + } + }); + }); + }); }); 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 5105cbd493fc2..4d4997a10c2f0 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -13,6 +13,8 @@ interface OverflowState { groupVisibility: Record; } +export interface OnOverflowChangeData extends OverflowState {}; + /** * Overflow Props */ @@ -20,6 +22,10 @@ export type OverflowProps = Partial< Pick > & { children: React.ReactElement; + + // overflow is not caused by DOM event + // eslint-disable-next-line @nx/workspace-consistent-callback-type + onOverflowChange?: (ev: null, data: OverflowState) => void; }; /** @@ -28,7 +34,7 @@ export type OverflowProps = Partial< export const Overflow = React.forwardRef((props: OverflowProps, ref) => { const styles = useOverflowStyles(); - const { children, minimumVisible, overflowAxis = 'horizontal', overflowDirection, padding } = props; + const { children, minimumVisible, overflowAxis = 'horizontal', overflowDirection, padding, onOverflowChange } = props; const [overflowState, setOverflowState] = React.useState({ hasOverflow: false, @@ -47,11 +53,15 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { invisibleItems.forEach(x => (itemVisibility[x.id] = false)); setOverflowState(() => { - return { + const newState = { hasOverflow: data.invisibleItems.length > 0, itemVisibility, groupVisibility, }; + + onOverflowChange?.(null, { ...newState }); + + return newState; }); }; diff --git a/packages/react-components/react-overflow/library/src/index.ts b/packages/react-components/react-overflow/library/src/index.ts index 14a87ae8b260f..e834e5444d36d 100644 --- a/packages/react-components/react-overflow/library/src/index.ts +++ b/packages/react-components/react-overflow/library/src/index.ts @@ -1,5 +1,5 @@ export { Overflow } from './components/Overflow'; -export type { OverflowProps } from './components/Overflow'; +export type { OverflowProps, OnOverflowChangeData } from './components/Overflow'; export { DATA_OVERFLOWING, DATA_OVERFLOW_ITEM, DATA_OVERFLOW_MENU, DATA_OVERFLOW_DIVIDER } from './constants'; export type { UseOverflowContainerReturn } from './types'; export { useIsOverflowGroupVisible } from './useIsOverflowGroupVisible'; diff --git a/packages/react-components/react-overflow/stories/src/Overflow/ListenToChanges.stories.tsx b/packages/react-components/react-overflow/stories/src/Overflow/ListenToChanges.stories.tsx new file mode 100644 index 0000000000000..bc548bd025eca --- /dev/null +++ b/packages/react-components/react-overflow/stories/src/Overflow/ListenToChanges.stories.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import { + makeStyles, + Button, + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + MenuButton, + tokens, + mergeClasses, + Overflow, + OverflowItem, + OverflowItemProps, + useIsOverflowItemVisible, + useOverflowMenu, +} from '@fluentui/react-components'; + +const useStyles = makeStyles({ + container: { + display: 'flex', + flexWrap: 'nowrap', + minWidth: 0, + overflow: 'hidden', + }, + + resizableArea: { + minWidth: '200px', + maxWidth: '800px', + border: `2px solid ${tokens.colorBrandBackground}`, + padding: '20px 10px 10px 10px', + position: 'relative', + resize: 'horizontal', + '::after': { + content: `'Resizable Area'`, + position: 'absolute', + 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 ListenToChanges = () => { + const styles = useStyles(); + + const itemIds = new Array(8).fill(0).map((_, i) => i.toString()); + const [overflowState, setOverflowState] = React.useState({}); + + return ( + <> + setOverflowState(data)}> +
+ {itemIds.map(i => ( + + + + ))} + +
+
+
{JSON.stringify(overflowState, null, 2)}
+ + ); +}; + +const OverflowMenuItem: React.FC> = props => { + const { id } = props; + const isVisible = useIsOverflowItemVisible(id); + + if (isVisible) { + return null; + } + + // As an union between button props and div props may be conflicting, casting is required + return Item {id}; +}; + +const OverflowMenu: React.FC<{ itemIds: string[] }> = ({ itemIds }) => { + const { ref, overflowCount, isOverflowing } = useOverflowMenu(); + + if (!isOverflowing) { + return null; + } + + return ( + + + +{overflowCount} items + + + + + {itemIds.map(i => { + return ; + })} + + + + ); +}; + +ListenToChanges.parameters = { + docs: { + description: { + story: [ + 'You can listen to changes with the `onOnverflowChange` prop which will return the overflow', + 'state. This can be useful when you have other UI features that need to be triggered on changes', + 'to item visibility.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-overflow/stories/src/Overflow/index.stories.tsx b/packages/react-components/react-overflow/stories/src/Overflow/index.stories.tsx index b7b4b3479bbbd..410d22bc87892 100644 --- a/packages/react-components/react-overflow/stories/src/Overflow/index.stories.tsx +++ b/packages/react-components/react-overflow/stories/src/Overflow/index.stories.tsx @@ -12,6 +12,7 @@ export { Dividers } from './Dividers.stories'; export { LargerDividers } from './LargerDividers.stories'; export { PriorityWithDividers } from './PriorityWithDividers.stories'; export { CustomComponent } from './CustomComponent.stories'; +export { ListenToChanges } from './ListenToChanges.stories'; // Typing with as Meta generates a type error for the `subcomponents` property. // https://github.com/storybookjs/storybook/issues/27535 From c03cdd2554cf22bfcbfea79ec3f15d9b814030bd Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Tue, 22 Oct 2024 14:35:03 +0200 Subject: [PATCH 2/6] changefile --- ...eact-overflow-a31beb0d-76b2-491e-add6-1f3fd27db85f.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-react-overflow-a31beb0d-76b2-491e-add6-1f3fd27db85f.json diff --git a/change/@fluentui-react-overflow-a31beb0d-76b2-491e-add6-1f3fd27db85f.json b/change/@fluentui-react-overflow-a31beb0d-76b2-491e-add6-1f3fd27db85f.json new file mode 100644 index 0000000000000..46d496852d5da --- /dev/null +++ b/change/@fluentui-react-overflow-a31beb0d-76b2-491e-add6-1f3fd27db85f.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add `onOverflowChange` callback", + "packageName": "@fluentui/react-overflow", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} From 253a418d716840e68664881f817e019f84b10092 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Wed, 23 Oct 2024 18:43:17 +0200 Subject: [PATCH 3/6] api md --- .../react-overflow/library/etc/react-overflow.api.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 6ea1abca7aa5a..3ac97e08f5426 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 @@ -24,9 +24,14 @@ export const DATA_OVERFLOW_MENU = "data-overflow-menu"; // @public (undocumented) export const DATA_OVERFLOWING = "data-overflowing"; +// @public (undocumented) +export interface OnOverflowChangeData extends OverflowState { +} + // @public export const Overflow: React_2.ForwardRefExoticComponent> & { children: React_2.ReactElement; + onOverflowChange?: ((ev: null, data: OverflowState) => void) | undefined; } & React_2.RefAttributes>; // @public @@ -46,6 +51,7 @@ export type OverflowItemProps = { // @public export type OverflowProps = Partial> & { children: React_2.ReactElement; + onOverflowChange?: (ev: null, data: OverflowState) => void; }; // @public (undocumented) From a1a597f15a105beb07a0cdb750fd8e20fe420fc1 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Thu, 24 Oct 2024 11:40:34 +0000 Subject: [PATCH 4/6] formatting --- .../react-overflow/library/src/components/Overflow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4d4997a10c2f0..40149569ea2ff 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -13,7 +13,7 @@ interface OverflowState { groupVisibility: Record; } -export interface OnOverflowChangeData extends OverflowState {}; +export interface OnOverflowChangeData extends OverflowState {} /** * Overflow Props From ac47ad15817b0fc1d1f6b0dd1694c7c703a06c2a Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Thu, 24 Oct 2024 12:01:12 +0000 Subject: [PATCH 5/6] avoid nested state update --- .../library/src/components/Overflow.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) 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 40149569ea2ff..cf24e739c40f5 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -51,18 +51,14 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { itemVisibility[item.id] = true; }); invisibleItems.forEach(x => (itemVisibility[x.id] = false)); - - setOverflowState(() => { - const newState = { - hasOverflow: data.invisibleItems.length > 0, - itemVisibility, - groupVisibility, - }; - - onOverflowChange?.(null, { ...newState }); - - return newState; - }); + const newState = { + hasOverflow: data.invisibleItems.length > 0, + itemVisibility, + groupVisibility, + }; + onOverflowChange?.(null, { ...newState }); + + setOverflowState(newState); }; const { containerRef, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = useOverflowContainer( From dd9ed06d4bf588a7b24674d5a778571dce35759a Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Thu, 24 Oct 2024 16:08:48 +0000 Subject: [PATCH 6/6] fix lint --- .../stories/src/Overflow/ListenToChanges.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/react-overflow/stories/src/Overflow/ListenToChanges.stories.tsx b/packages/react-components/react-overflow/stories/src/Overflow/ListenToChanges.stories.tsx index bc548bd025eca..84df35fb05fbc 100644 --- a/packages/react-components/react-overflow/stories/src/Overflow/ListenToChanges.stories.tsx +++ b/packages/react-components/react-overflow/stories/src/Overflow/ListenToChanges.stories.tsx @@ -53,7 +53,7 @@ export const ListenToChanges = () => { const styles = useStyles(); const itemIds = new Array(8).fill(0).map((_, i) => i.toString()); - const [overflowState, setOverflowState] = React.useState({}); + const [overflowState, setOverflowState] = React.useState({}); return ( <>