diff --git a/change/@fluentui-react-headless-components-preview-4bdc7f70-88bc-4b7a-8c35-7ddd600b9a3c.json b/change/@fluentui-react-headless-components-preview-4bdc7f70-88bc-4b7a-8c35-7ddd600b9a3c.json new file mode 100644 index 00000000000000..1f1b3ce0383ea2 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-4bdc7f70-88bc-4b7a-8c35-7ddd600b9a3c.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add headless Overflow component", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js index 81812ffed92a06..f7488248bed5ff 100644 --- a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js @@ -20,6 +20,7 @@ import * as Link from '@fluentui/react-headless-components-preview/link'; import * as Menu from '@fluentui/react-headless-components-preview/menu'; import * as MessageBar from '@fluentui/react-headless-components-preview/message-bar'; import * as Nav from '@fluentui/react-headless-components-preview/nav'; +import * as Overflow from '@fluentui/react-headless-components-preview/overflow'; import * as Persona from '@fluentui/react-headless-components-preview/persona'; import * as Popover from '@fluentui/react-headless-components-preview/popover'; import * as ProgressBar from '@fluentui/react-headless-components-preview/progress-bar'; @@ -67,6 +68,7 @@ console.log({ Menu, MessageBar, Nav, + Overflow, Persona, Popover, ProgressBar, diff --git a/packages/react-components/react-headless-components-preview/library/etc/overflow.api.md b/packages/react-components/react-headless-components-preview/library/etc/overflow.api.md new file mode 100644 index 00000000000000..7329351f567d67 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/overflow.api.md @@ -0,0 +1,89 @@ +## API Report File for "@fluentui/react-headless-components-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +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'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { OnOverflowChangeData } from '@fluentui/react-overflow'; +import { OverflowComponentState } from '@fluentui/react-overflow'; +import { OverflowContextValues } from '@fluentui/react-overflow'; +import { OverflowDivider } from '@fluentui/react-overflow'; +import { OverflowDividerProps } from '@fluentui/react-overflow'; +import { OverflowItem } from '@fluentui/react-overflow'; +import { OverflowItemProps } from '@fluentui/react-overflow'; +import { OverflowProps } from '@fluentui/react-overflow'; +import { OverflowReorderObserver } from '@fluentui/react-overflow'; +import { OverflowState } from '@fluentui/react-overflow'; +import { renderOverflow_unstable as renderOverflow } from '@fluentui/react-overflow'; +import { useIsOverflowGroupVisible } from '@fluentui/react-overflow'; +import { useIsOverflowItemVisible } from '@fluentui/react-overflow'; +import { useOverflow_unstable as useOverflow } from '@fluentui/react-overflow'; +import { useOverflowContext } from '@fluentui/react-overflow'; +import { useOverflowContextValues_unstable as useOverflowContextValues } from '@fluentui/react-overflow'; +import { useOverflowCount } from '@fluentui/react-overflow'; +import { useOverflowDivider } from '@fluentui/react-overflow'; +import { useOverflowItem } from '@fluentui/react-overflow'; +import { useOverflowMenu } from '@fluentui/react-overflow'; +import { useOverflowVisibility } from '@fluentui/react-overflow'; + +export { DATA_OVERFLOW_DIVIDER } + +export { DATA_OVERFLOW_ITEM } + +export { DATA_OVERFLOW_MENU } + +export { DATA_OVERFLOWING } + +export { OnOverflowChangeData } + +// @public +export const Overflow: ForwardRefComponent; + +export { OverflowComponentState } + +export { OverflowContextValues } + +export { OverflowDivider } + +export { OverflowDividerProps } + +export { OverflowItem } + +export { OverflowItemProps } + +export { OverflowProps } + +export { OverflowReorderObserver } + +export { OverflowState } + +export { renderOverflow } + +export { useIsOverflowGroupVisible } + +export { useIsOverflowItemVisible } + +export { useOverflow } + +export { useOverflowContext } + +export { useOverflowContextValues } + +export { useOverflowCount } + +export { useOverflowDivider } + +export { useOverflowItem } + +export { useOverflowMenu } + +export { useOverflowVisibility } + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index 54c7d57430ef4d..ce51dc84fe63b9 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -41,6 +41,7 @@ "@fluentui/react-message-bar": "^9.7.1", "@fluentui/react-teaching-popover": "^9.7.0", "@fluentui/react-nav": "^9.4.0", + "@fluentui/react-overflow": "^9.8.0", "@fluentui/react-persona": "^9.7.4", "@fluentui/react-popover": "^9.14.3", "@fluentui/react-positioning": "^9.22.2", @@ -206,6 +207,12 @@ "import": "./lib/nav.js", "require": "./lib-commonjs/nav.js" }, + "./overflow": { + "types": "./dist/overflow.d.ts", + "node": "./lib-commonjs/overflow.js", + "import": "./lib/overflow.js", + "require": "./lib-commonjs/overflow.js" + }, "./persona": { "types": "./dist/persona.d.ts", "node": "./lib-commonjs/persona.js", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Overflow/Overflow.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/Overflow.test.tsx new file mode 100644 index 00000000000000..6098fd19644c2a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/Overflow.test.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { Overflow } from './Overflow'; +import { OverflowItem, useOverflowMenu } from './index'; + +describe('Overflow', () => { + isConformant({ + Component: Overflow, + displayName: 'Overflow', + // Overflow is renderless (clones its single child) and provides context, so the slot/render + // based conformance assertions do not apply. + disabledTests: ['component-handles-ref', 'component-has-root-ref', 'component-handles-classname'], + requiredProps: { children:
} as object, + }); + + it('forwards the ref to the single child', () => { + const overflowRef = jest.fn(); + const childRef = jest.fn(); + + render( + +
+ , + ); + + expect(overflowRef).toHaveBeenCalledWith(expect.objectContaining({ id: 'child', tagName: 'DIV' })); + expect(childRef).toHaveBeenCalledWith(expect.objectContaining({ id: 'child', tagName: 'DIV' })); + }); + + it('does not add any built-in className to the cloned child', () => { + const { container } = render( + +
+ , + ); + + // The headless Overflow must preserve only the child's own className — no Griffel/fui-* classes. + expect((container.firstChild as HTMLElement).className).toBe('my-container'); + }); + + it('registers overflow items on the child element', () => { + const { getByText } = render( + +
+ + + +
+
, + ); + + expect(getByText('foo')).toHaveAttribute('data-overflow-item'); + }); + + it('marks the overflow menu element with data-overflow-menu for styling', () => { + const Menu: React.FC = () => { + const { ref } = useOverflowMenu(); + return ( + + ); + }; + + const { getByTestId } = render( + +
+ +
+
, + ); + + // Headless: the engine sets the data attribute; consumers style it (e.g. `flex-shrink: 0`). + expect(getByTestId('menu')).toHaveAttribute('data-overflow-menu'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Overflow/Overflow.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/Overflow.tsx new file mode 100644 index 00000000000000..21d24f2d440afa --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/Overflow.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { OverflowProps } from './Overflow.types'; +import { useOverflow } from './useOverflow'; +import { useOverflowContextValues } from './useOverflowContextValues'; +import { renderOverflow } from './renderOverflow'; + +/** + * Provides an overflow context for `OverflowItem` descendants without any built-in styling. + */ +export const Overflow: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useOverflow(props, ref); + const contextValues = useOverflowContextValues(state); + + return renderOverflow(state, contextValues); +}); + +Overflow.displayName = 'Overflow'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Overflow/Overflow.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/Overflow.types.ts new file mode 100644 index 00000000000000..d6a60afd6096fc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/Overflow.types.ts @@ -0,0 +1,9 @@ +export type { + OverflowProps, + OnOverflowChangeData, + OverflowItemProps, + OverflowDividerProps, + OverflowState, + OverflowComponentState, + OverflowContextValues, +} from '@fluentui/react-overflow'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Overflow/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/index.ts new file mode 100644 index 00000000000000..412c2c84e80ee3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/index.ts @@ -0,0 +1,31 @@ +export { Overflow } from './Overflow'; +export { useOverflow } from './useOverflow'; +export { useOverflowContextValues } from './useOverflowContextValues'; +export { renderOverflow } from './renderOverflow'; +export type { + OverflowProps, + OverflowState, + OverflowComponentState, + OverflowContextValues, + OnOverflowChangeData, + OverflowItemProps, + OverflowDividerProps, +} from './Overflow.types'; + +export { + OverflowItem, + OverflowDivider, + OverflowReorderObserver, + useOverflowMenu, + useOverflowContext, + useOverflowCount, + useOverflowItem, + useOverflowDivider, + useIsOverflowItemVisible, + useIsOverflowGroupVisible, + useOverflowVisibility, + DATA_OVERFLOWING, + DATA_OVERFLOW_ITEM, + DATA_OVERFLOW_MENU, + DATA_OVERFLOW_DIVIDER, +} from '@fluentui/react-overflow'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Overflow/renderOverflow.ts b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/renderOverflow.ts new file mode 100644 index 00000000000000..672409cd1b3f36 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/renderOverflow.ts @@ -0,0 +1 @@ +export { renderOverflow_unstable as renderOverflow } from '@fluentui/react-overflow'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Overflow/useOverflow.ts b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/useOverflow.ts new file mode 100644 index 00000000000000..5ad327e9e5e408 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/useOverflow.ts @@ -0,0 +1 @@ +export { useOverflow_unstable as useOverflow } from '@fluentui/react-overflow'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Overflow/useOverflowContextValues.ts b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/useOverflowContextValues.ts new file mode 100644 index 00000000000000..c8e580b001b4c7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Overflow/useOverflowContextValues.ts @@ -0,0 +1 @@ +export { useOverflowContextValues_unstable as useOverflowContextValues } from '@fluentui/react-overflow'; diff --git a/packages/react-components/react-headless-components-preview/library/src/overflow.ts b/packages/react-components/react-headless-components-preview/library/src/overflow.ts new file mode 100644 index 00000000000000..58763b12e7955e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/overflow.ts @@ -0,0 +1,30 @@ +export { + Overflow, + useOverflow, + useOverflowContextValues, + renderOverflow, + useOverflowMenu, + OverflowItem, + OverflowDivider, + OverflowReorderObserver, + useOverflowContext, + useOverflowCount, + useOverflowItem, + useOverflowDivider, + useIsOverflowItemVisible, + useIsOverflowGroupVisible, + useOverflowVisibility, + DATA_OVERFLOWING, + DATA_OVERFLOW_ITEM, + DATA_OVERFLOW_MENU, + DATA_OVERFLOW_DIVIDER, +} from './components/Overflow'; +export type { + OverflowProps, + OverflowState, + OverflowComponentState, + OverflowContextValues, + OnOverflowChangeData, + OverflowItemProps, + OverflowDividerProps, +} from './components/Overflow'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowDefault.stories.tsx new file mode 100644 index 00000000000000..04cb21daa950e0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowDefault.stories.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { + Overflow, + OverflowItem, + useOverflowMenu, + useIsOverflowItemVisible, +} from '@fluentui/react-headless-components-preview/overflow'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; + +import styles from './overflow.module.css'; + +const itemIds = Array.from({ length: 10 }, (_, i) => (i + 1).toString()); + +const OverflowMenuItem = ({ id }: { id: string }): React.ReactElement | null => { + // Only the overflowed (hidden) items are listed in the menu. + const isVisible = useIsOverflowItemVisible(id); + return isVisible ? null : Item {id}; +}; + +/** + * `+N` button that opens a headless `Menu` listing the overflowed items. `MenuPopover` renders + * through a portal but preserves React context, so the overflow hooks inside still read the + * `Overflow` root's context. + */ +const OverflowMenu = ({ ids }: { ids: string[] }): React.ReactElement | null => { + const { ref, overflowCount, isOverflowing } = useOverflowMenu(); + + if (!isOverflowing) { + return null; + } + + return ( + + + + + + + {ids.map(id => ( + + ))} + + + + ); +}; + +/** + * Drag the dashed box's right edge to resize. Items that no longer fit are hidden and the `+N` + * button reflects the overflow count; click it to see the overflowed items. + */ +export const Default = (): React.ReactElement => ( +
+ +
+ {itemIds.map(id => ( + + + + ))} + +
+
+
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowDescription.md new file mode 100644 index 00000000000000..8b653af994be45 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowDescription.md @@ -0,0 +1,22 @@ +Overflow detects when its items no longer fit their container and moves the extras into an overflow +menu. It ships no styling — it only sets data attributes, which you style. Two of those rules are +required for the overflow engine to work, so add them alongside your own styling: + +```css +[data-overflowing] { + display: none; +} /* removes overflowed items from layout */ +[data-overflow-menu] { + flex-shrink: 0; +} /* keeps the menu at full size for measurement */ +``` + +Compose it from: + +- `Overflow` — the root; provides context and clones its single child to measure it. +- `OverflowItem` — marks a child as an overflow item (give each a unique `id`). +- `useOverflowMenu` — registers the overflow menu element and reports the overflow count. + +Additional hooks (`useOverflowCount`, `useIsOverflowItemVisible`, `useIsOverflowGroupVisible`, +`useOverflowVisibility`, `useOverflowContext`) and the `OverflowDivider` / `OverflowReorderObserver` +components are re-exported from `@fluentui/react-overflow` for advanced scenarios. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowLargerDividers.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowLargerDividers.stories.tsx new file mode 100644 index 00000000000000..8478a30055cf32 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowLargerDividers.stories.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { + Overflow, + OverflowItem, + OverflowDivider, + useOverflowMenu, + useIsOverflowItemVisible, + useIsOverflowGroupVisible, +} from '@fluentui/react-headless-components-preview/overflow'; +import { + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + MenuDivider, +} from '@fluentui/react-headless-components-preview/menu'; + +import styles from './overflow.module.css'; + +const GroupDivider = ({ groupId }: { groupId: string }): React.ReactElement => ( + +
+ +); + +const menuIds = ['1', 'divider-1', '2', 'divider-2', '3', '4', 'divider-3', '5', '6', '7', 'divider-4', '8']; + +const OverflowMenuItem = ({ id }: { id: string }): React.ReactElement | null => { + // Only the overflowed (hidden) items are listed in the menu. + const isVisible = useIsOverflowItemVisible(id); + return isVisible ? null : Item {id}; +}; + +const OverflowMenuDivider = ({ groupId }: { groupId: string }): React.ReactElement | null => { + const groupVisibility = useIsOverflowGroupVisible(groupId); + return groupVisibility === 'visible' ? null : ; +}; + +/** + * `+N` button that opens a headless `Menu` listing the overflowed items. `MenuPopover` renders + * through a portal but preserves React context, so the overflow hooks inside still read the + * `Overflow` root's context. Entries prefixed with `divider-` render a group divider (when that + * group is overflowing). + */ +const OverflowMenu = ({ ids }: { ids: string[] }): React.ReactElement | null => { + const { ref, overflowCount, isOverflowing } = useOverflowMenu(); + + if (!isOverflowing) { + return null; + } + + return ( + + + + + + + {ids.map(id => + id.startsWith('divider-') ? ( + + ) : ( + + ), + )} + + + + ); +}; + +/** + * `OverflowDivider` registers a divider with a `groupId` so its width is included in the overflow + * calculation. Group dividers are hidden (and rendered in the menu) once their group overflows. + */ +export const LargerDividers = (): React.ReactElement => ( +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowPinned.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowPinned.stories.tsx new file mode 100644 index 00000000000000..1e028be9da980f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowPinned.stories.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import { + Overflow, + OverflowItem, + useOverflowMenu, + useIsOverflowItemVisible, +} from '@fluentui/react-headless-components-preview/overflow'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; + +import styles from './overflow.module.css'; + +const itemIds = Array.from({ length: 10 }, (_, i) => (i + 1).toString()); + +const OverflowMenuItem = ({ id, onClick }: { id: string; onClick: () => void }): React.ReactElement | null => { + // Only the overflowed (hidden) items are listed in the menu. + const isVisible = useIsOverflowItemVisible(id); + return isVisible ? null : ( + + Item {id} + + ); +}; + +/** + * `+N` button that opens a headless `Menu` listing the overflowed items. `MenuPopover` renders + * through a portal but preserves React context, so the overflow hooks inside still read the + * `Overflow` root's context. + */ +const OverflowMenu = ({ + ids, + onItemClick, +}: { + ids: string[]; + onItemClick: (id: string) => void; +}): React.ReactElement | null => { + const { ref, overflowCount, isOverflowing } = useOverflowMenu(); + + if (!isOverflowing) { + return null; + } + + return ( + + + + + + + {ids.map(id => ( + onItemClick(id)} /> + ))} + + + + ); +}; + +/** + * An item can be pinned (always visible, never overflows) via the `pinned` prop on `OverflowItem` — + * useful for selection scenarios. Click items (or menu entries) to toggle their pinned state. + */ +export const Pinned = (): React.ReactElement => { + const [selected, setSelected] = React.useState>(() => new Set(['6'])); + + const toggle = (id: string) => + setSelected(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + + return ( +
+ +
+ {itemIds.map(id => ( + + + + ))} + +
+
+
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowPriorityWithDividers.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowPriorityWithDividers.stories.tsx new file mode 100644 index 00000000000000..089b6e706f3b9b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowPriorityWithDividers.stories.tsx @@ -0,0 +1,136 @@ +import * as React from 'react'; +import { + Overflow, + OverflowItem, + useOverflowMenu, + useIsOverflowItemVisible, + useIsOverflowGroupVisible, + useOverflowVisibility, +} from '@fluentui/react-headless-components-preview/overflow'; +import { + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + MenuDivider, +} from '@fluentui/react-headless-components-preview/menu'; + +import styles from './overflow.module.css'; + +const GROUPS = { ONE: 1, TWO: 2, THREE: 3, FOUR: 4, FIVE: 5 }; + +const menuIds = ['6', 'divider-1', '7', 'divider-2', '4', '5', 'divider-3', '1', '2', '3', 'divider-4', '8']; + +const OverflowMenuItem = ({ id }: { id: string }): React.ReactElement | null => { + // Only the overflowed (hidden) items are listed in the menu. + const isVisible = useIsOverflowItemVisible(id); + return isVisible ? null : Item {id}; +}; + +/** In-container divider — hidden once its whole group has overflowed. */ +const ContainerGroupDivider = ({ groupId }: { groupId: number }): React.ReactElement | null => { + const groupVisibility = useIsOverflowGroupVisible(groupId.toString()); + + if (groupVisibility === 'hidden') { + return null; + } + + return
; +}; + +/** + * Menu divider — because priority differs from DOM order, a divider may be needed in the menu only + * when an overflowing group precedes another overflowing group. This mirrors the styled story's + * reference implementation. + * + * Every group's visibility is read from a single `useOverflowVisibility()` subscription rather than + * calling `useIsOverflowGroupVisible` once per group — the latter would call a hook inside a loop + * and violate the rules of hooks. + */ +const MenuGroupDivider = ({ groupId }: { groupId: number }): React.ReactElement | null => { + const { groupVisibility } = useOverflowVisibility(); + const groups = Object.values(GROUPS); + + const currentGroupPosition = groups.indexOf(groupId); + const precedesOverflowingGroup = groups + .slice(currentGroupPosition + 1) + .some(group => groupVisibility[group.toString()] !== 'visible'); + + if (groupVisibility[groupId.toString()] === 'visible' || !precedesOverflowingGroup) { + return null; + } + + return ; +}; + +const PriorityOverflowMenu = ({ ids }: { ids: string[] }): React.ReactElement | null => { + const { ref, overflowCount, isOverflowing } = useOverflowMenu(); + + if (!isOverflowing) { + return null; + } + + return ( + + + + + + + {ids.map(id => + id.startsWith('divider-') ? ( + + ) : ( + + ), + )} + + + + ); +}; + +/** + * Overflow groups respect item `priority`. Managing divider visibility here is non-trivial because + * dividers can appear both in the container and the menu — read the code carefully before adopting. + */ +export const PriorityWithDividers = (): React.ReactElement => ( +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowReverseDomOrder.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowReverseDomOrder.stories.tsx new file mode 100644 index 00000000000000..063e2e4d6bf7be --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowReverseDomOrder.stories.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { + Overflow, + OverflowItem, + useOverflowMenu, + useIsOverflowItemVisible, +} from '@fluentui/react-headless-components-preview/overflow'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; + +import styles from './overflow.module.css'; + +const itemIds = Array.from({ length: 10 }, (_, i) => (i + 1).toString()); + +const OverflowMenuItem = ({ id }: { id: string }): React.ReactElement | null => { + // Only the overflowed (hidden) items are listed in the menu. + const isVisible = useIsOverflowItemVisible(id); + return isVisible ? null : Item {id}; +}; + +/** + * `+N` button that opens a headless `Menu` listing the overflowed items. `MenuPopover` renders + * through a portal but preserves React context, so the overflow hooks inside still read the + * `Overflow` root's context. + */ +const OverflowMenu = ({ ids }: { ids: string[] }): React.ReactElement | null => { + const { ref, overflowCount, isOverflowing } = useOverflowMenu(); + + if (!isOverflowing) { + return null; + } + + return ( + + + + + + + {ids.map(id => ( + + ))} + + + + ); +}; + +/** + * Overflow can happen in reverse DOM order via `overflowDirection="start"` — here the menu is the + * first child and items overflow from the start of the container. + */ +export const ReverseDomOrder = (): React.ReactElement => ( +
+ +
+ + {itemIds.map(id => ( + + + + ))} +
+
+
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowVertical.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowVertical.stories.tsx new file mode 100644 index 00000000000000..57ee5f9c949a44 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Overflow/OverflowVertical.stories.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { + Overflow, + OverflowItem, + useOverflowMenu, + useIsOverflowItemVisible, +} from '@fluentui/react-headless-components-preview/overflow'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; + +import styles from './overflow.module.css'; + +const itemIds = Array.from({ length: 10 }, (_, i) => (i + 1).toString()); + +const OverflowMenuItem = ({ id }: { id: string }): React.ReactElement | null => { + // Only the overflowed (hidden) items are listed in the menu. + const isVisible = useIsOverflowItemVisible(id); + return isVisible ? null : Item {id}; +}; + +/** + * `+N` button that opens a headless `Menu` listing the overflowed items. `MenuPopover` renders + * through a portal but preserves React context, so the overflow hooks inside still read the + * `Overflow` root's context. + */ +const OverflowMenu = ({ ids }: { ids: string[] }): React.ReactElement | null => { + const { ref, overflowCount, isOverflowing } = useOverflowMenu(); + + if (!isOverflowing) { + return null; + } + + return ( + + + + + + + {ids.map(id => ( + + ))} + + + + ); +}; + +/** + * Use the `overflowAxis` prop to switch orientation. Drag the dashed box's bottom edge to resize. + */ +export const Vertical = (): React.ReactElement => ( +
+ +
+ {itemIds.map(id => ( + + + + ))} + +
+
+
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Overflow/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Overflow/index.stories.tsx new file mode 100644 index 00000000000000..259849e6d7d052 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Overflow/index.stories.tsx @@ -0,0 +1,24 @@ +import { Overflow, OverflowItem } from '@fluentui/react-headless-components-preview/overflow'; + +import descriptionMd from './OverflowDescription.md'; +export { Default } from './OverflowDefault.stories'; +export { Vertical } from './OverflowVertical.stories'; +export { ReverseDomOrder } from './OverflowReverseDomOrder.stories'; +export { LargerDividers } from './OverflowLargerDividers.stories'; +export { Pinned } from './OverflowPinned.stories'; +export { PriorityWithDividers } from './OverflowPriorityWithDividers.stories'; + +export default { + title: 'Components/Overflow', + component: Overflow, + subcomponents: { + OverflowItem, + }, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Overflow/overflow.module.css b/packages/react-components/react-headless-components-preview/stories/src/Overflow/overflow.module.css new file mode 100644 index 00000000000000..0d0e1a5e300350 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Overflow/overflow.module.css @@ -0,0 +1,132 @@ +.container [data-overflowing] { + display: none; +} + +.container [data-overflow-menu] { + flex-shrink: 0; +} + +.resizer { + resize: horizontal; + overflow: hidden; + width: 360px; + max-width: 100%; + padding: 8px; + border: 1px dashed var(--border); + border-radius: var(--radius-lg); + background: var(--bg-elev); +} + +.resizerVertical { + resize: vertical; + overflow: hidden; + height: 220px; + max-height: 100%; + width: max-content; + padding: 8px; + border: 1px dashed var(--border); + border-radius: var(--radius-lg); + background: var(--bg-elev); +} + +.container { + position: relative; + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + overflow: hidden; +} + +.vertical { + flex-direction: column; + align-items: stretch; + height: 100%; +} + +.item { + flex: none; + padding: 6px 12px; + border-radius: var(--radius-pill); + background: var(--surface-muted); + color: var(--text); + border: none; + white-space: nowrap; + cursor: pointer; +} + +.item:hover { + background: var(--accent); + color: var(--accent-contrast); +} + +.itemSelected { + background: var(--accent); + color: var(--accent-contrast); +} + +.menu { + flex: none; + padding: 6px 12px; + border-radius: var(--radius-pill); + background: var(--accent); + color: var(--accent-contrast); + border: none; + cursor: pointer; +} + +.divider { + flex: none; + align-self: stretch; + width: 1px; + margin: 0 4px; + background: var(--border); +} + +.vertical .divider { + width: auto; + height: 1px; + margin: 4px 0; +} + +.menuPopover { + min-width: 140px; + padding: 4px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-1); +} + +.menuList { + display: flex; + flex-direction: column; + gap: 2px; + outline: none; +} + +.menuItem { + display: flex; + align-items: center; + width: 100%; + text-align: left; + padding: 6px 10px; + border: none; + background: transparent; + color: var(--text); + border-radius: var(--radius-pill); + cursor: pointer; + white-space: nowrap; + outline: none; +} + +.menuItem:hover, +.menuItem:focus-visible { + background: var(--surface-muted); +} + +.menuDivider { + height: 1px; + margin: 4px 0; + background: var(--border); +}