diff --git a/change/@fluentui-react-components-267e8104-5e4e-487c-b7aa-766926d9c6bc.json b/change/@fluentui-react-components-267e8104-5e4e-487c-b7aa-766926d9c6bc.json new file mode 100644 index 00000000000000..865ad8c40384c5 --- /dev/null +++ b/change/@fluentui-react-components-267e8104-5e4e-487c-b7aa-766926d9c6bc.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: Add static measurement hooks and embedded scroll option to react-virtualizer", + "packageName": "@fluentui/react-components", + "email": "mifraser@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-virtualizer-4e79b7e8-b7bf-493b-b8cb-fba5a70fecf7.json b/change/@fluentui-react-virtualizer-4e79b7e8-b7bf-493b-b8cb-fba5a70fecf7.json new file mode 100644 index 00000000000000..61e2e2f85db972 --- /dev/null +++ b/change/@fluentui-react-virtualizer-4e79b7e8-b7bf-493b-b8cb-fba5a70fecf7.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "[feat] Add static measurement hooks and embedded scroll option", + "packageName": "@fluentui/react-virtualizer", + "email": "mifraser@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-components/etc/react-components.unstable.api.md b/packages/react-components/react-components/etc/react-components.unstable.api.md index 536698590ea8a5..16f7745139fa90 100644 --- a/packages/react-components/react-components/etc/react-components.unstable.api.md +++ b/packages/react-components/react-components/etc/react-components.unstable.api.md @@ -56,6 +56,7 @@ import { renderTreeItem_unstable } from '@fluentui/react-tree'; import { renderTreeItemLayout_unstable } from '@fluentui/react-tree'; import { renderTreeItemPersonaLayout_unstable } from '@fluentui/react-tree'; import { renderVirtualizer_unstable } from '@fluentui/react-virtualizer'; +import { renderVirtualizerScrollView_unstable } from '@fluentui/react-virtualizer'; import { SelectField_unstable as SelectField } from '@fluentui/react-select'; import { selectFieldClassNames } from '@fluentui/react-select'; import { SelectFieldProps_unstable as SelectFieldProps } from '@fluentui/react-select'; @@ -127,6 +128,7 @@ import { useSkeletonContext } from '@fluentui/react-skeleton'; import { useSkeletonItem_unstable } from '@fluentui/react-skeleton'; import { useSkeletonItemStyles_unstable } from '@fluentui/react-skeleton'; import { useSkeletonStyles_unstable } from '@fluentui/react-skeleton'; +import { useStaticVirtualizerMeasure } from '@fluentui/react-virtualizer'; import { useTree_unstable } from '@fluentui/react-tree'; import { useTreeContext_unstable } from '@fluentui/react-tree'; import { useTreeItem_unstable } from '@fluentui/react-tree'; @@ -138,11 +140,18 @@ import { useTreeItemPersonaLayoutStyles_unstable } from '@fluentui/react-tree'; import { useTreeItemStyles_unstable } from '@fluentui/react-tree'; import { useTreeStyles_unstable } from '@fluentui/react-tree'; import { useVirtualizer_unstable } from '@fluentui/react-virtualizer'; +import { useVirtualizerScrollView_unstable } from '@fluentui/react-virtualizer'; +import { useVirtualizerScrollViewStyles_unstable } from '@fluentui/react-virtualizer'; import { useVirtualizerStyles_unstable } from '@fluentui/react-virtualizer'; import { Virtualizer } from '@fluentui/react-virtualizer'; import { VirtualizerChildRenderFunction } from '@fluentui/react-virtualizer'; import { virtualizerClassNames } from '@fluentui/react-virtualizer'; import { VirtualizerProps } from '@fluentui/react-virtualizer'; +import { VirtualizerScrollView } from '@fluentui/react-virtualizer'; +import { virtualizerScrollViewClassNames } from '@fluentui/react-virtualizer'; +import { VirtualizerScrollViewProps } from '@fluentui/react-virtualizer'; +import { VirtualizerScrollViewSlots } from '@fluentui/react-virtualizer'; +import { VirtualizerScrollViewState } from '@fluentui/react-virtualizer'; import { VirtualizerSlots } from '@fluentui/react-virtualizer'; import { VirtualizerState } from '@fluentui/react-virtualizer'; @@ -250,6 +259,8 @@ export { renderTreeItemPersonaLayout_unstable } export { renderVirtualizer_unstable } +export { renderVirtualizerScrollView_unstable } + export { SelectField } export { selectFieldClassNames } @@ -392,6 +403,8 @@ export { useSkeletonItemStyles_unstable } export { useSkeletonStyles_unstable } +export { useStaticVirtualizerMeasure } + export { useTree_unstable } export { useTreeContext_unstable } @@ -414,6 +427,10 @@ export { useTreeStyles_unstable } export { useVirtualizer_unstable } +export { useVirtualizerScrollView_unstable } + +export { useVirtualizerScrollViewStyles_unstable } + export { useVirtualizerStyles_unstable } export { Virtualizer } @@ -424,6 +441,16 @@ export { virtualizerClassNames } export { VirtualizerProps } +export { VirtualizerScrollView } + +export { virtualizerScrollViewClassNames } + +export { VirtualizerScrollViewProps } + +export { VirtualizerScrollViewSlots } + +export { VirtualizerScrollViewState } + export { VirtualizerSlots } export { VirtualizerState } diff --git a/packages/react-components/react-components/src/unstable/index.ts b/packages/react-components/react-components/src/unstable/index.ts index 60eb1ef64ef74f..f6728de03d1b2e 100644 --- a/packages/react-components/react-components/src/unstable/index.ts +++ b/packages/react-components/react-components/src/unstable/index.ts @@ -120,12 +120,21 @@ export { renderVirtualizer_unstable, useVirtualizerStyles_unstable, useIntersectionObserver, + useStaticVirtualizerMeasure, + VirtualizerScrollView, + virtualizerScrollViewClassNames, + useVirtualizerScrollView_unstable, + renderVirtualizerScrollView_unstable, + useVirtualizerScrollViewStyles_unstable, } from '@fluentui/react-virtualizer'; export type { VirtualizerProps, VirtualizerState, VirtualizerSlots, VirtualizerChildRenderFunction, + VirtualizerScrollViewProps, + VirtualizerScrollViewState, + VirtualizerScrollViewSlots, } from '@fluentui/react-virtualizer'; export { diff --git a/packages/react-components/react-virtualizer/etc/react-virtualizer.api.md b/packages/react-components/react-virtualizer/etc/react-virtualizer.api.md index 9c9a26dbbc2f84..a00c9dec69b41e 100644 --- a/packages/react-components/react-virtualizer/etc/react-virtualizer.api.md +++ b/packages/react-components/react-virtualizer/etc/react-virtualizer.api.md @@ -4,19 +4,22 @@ ```ts -import type { ComponentProps } from '@fluentui/react-utilities'; -import type { ComponentState } from '@fluentui/react-utilities'; +import { ComponentProps } from '@fluentui/react-utilities'; +import { ComponentState } from '@fluentui/react-utilities'; import type { Dispatch } from 'react'; import type { FC } from 'react'; import type { MutableRefObject } from 'react'; import * as React_2 from 'react'; import type { SetStateAction } from 'react'; -import type { Slot } from '@fluentui/react-utilities'; +import { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; // @public (undocumented) export const renderVirtualizer_unstable: (state: VirtualizerState) => JSX.Element; +// @public (undocumented) +export const renderVirtualizerScrollView_unstable: (state: VirtualizerScrollViewState) => JSX.Element; + // @public export const useIntersectionObserver: (callback: IntersectionObserverCallback, options?: IntersectionObserverInit | undefined) => { setObserverList: Dispatch>; @@ -24,9 +27,23 @@ export const useIntersectionObserver: (callback: IntersectionObserverCallback, o observer: MutableRefObject; }; +// @public +export const useStaticVirtualizerMeasure: (virtualizerProps: VirtualizerMeasureProps) => { + virtualizerLength: number; + bufferItems: number; + bufferSize: number; + scrollRef: (instance: HTMLElement | HTMLDivElement | null) => void; +}; + // @public (undocumented) export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerState; +// @public (undocumented) +export function useVirtualizerScrollView_unstable(props: VirtualizerScrollViewProps): VirtualizerScrollViewState; + +// @public +export const useVirtualizerScrollViewStyles_unstable: (state: VirtualizerScrollViewState) => VirtualizerScrollViewState; + // @public export const useVirtualizerStyles_unstable: (state: VirtualizerState) => VirtualizerState; @@ -40,21 +57,29 @@ export type VirtualizerChildRenderFunction = (index: number) => React_2.ReactNod export const virtualizerClassNames: SlotClassNames; // @public (undocumented) -export type VirtualizerProps = ComponentProps> & { - children: VirtualizerChildRenderFunction; +export type VirtualizerProps = ComponentProps> & VirtualizerConfigProps; + +// @public +export const VirtualizerScrollView: React_2.FC; + +// @public (undocumented) +export const virtualizerScrollViewClassNames: SlotClassNames; + +// @public (undocumented) +export type VirtualizerScrollViewProps = ComponentProps> & Partial> & { itemSize: number; numItems: number; - virtualizerLength: number; - bufferItems?: number; - bufferSize?: number; - intersectionObserverRoot?: React_2.MutableRefObject; - axis?: 'vertical' | 'horizontal'; - reversed?: boolean; - getItemSize?: (index: number) => number; - onUpdateIndex?: (index: number, prevIndex: number) => void; - onCalculateIndex?: (newIndex: number) => number; + children: VirtualizerChildRenderFunction; }; +// @public (undocumented) +export type VirtualizerScrollViewSlots = VirtualizerSlots & { + container: NonNullable>; +}; + +// @public (undocumented) +export type VirtualizerScrollViewState = ComponentState & VirtualizerConfigState; + // @public (undocumented) export type VirtualizerSlots = { before: NonNullable>; @@ -64,16 +89,7 @@ export type VirtualizerSlots = { }; // @public (undocumented) -export type VirtualizerState = ComponentState & { - virtualizedChildren: React_2.ReactNode[]; - virtualizerStartIndex: number; - afterBufferHeight: number; - beforeBufferHeight: number; - totalVirtualizerHeight: number; - axis?: 'vertical' | 'horizontal'; - reversed?: boolean; - bufferSize: number; -}; +export type VirtualizerState = ComponentState & VirtualizerConfigState; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-virtualizer/package.json b/packages/react-components/react-virtualizer/package.json index e37d7010c1fdee..692ed37c0a6b20 100644 --- a/packages/react-components/react-virtualizer/package.json +++ b/packages/react-components/react-virtualizer/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@fluentui/react-utilities": "^9.7.2", + "@fluentui/react": "^8.106.8", "@griffel/react": "^1.5.2", "@swc/helpers": "^0.4.14" }, diff --git a/packages/react-components/react-virtualizer/src/VirtualizerScrollView.ts b/packages/react-components/react-virtualizer/src/VirtualizerScrollView.ts new file mode 100644 index 00000000000000..20bfbaf6a9fcba --- /dev/null +++ b/packages/react-components/react-virtualizer/src/VirtualizerScrollView.ts @@ -0,0 +1 @@ +export * from './components/VirtualizerScrollView/index'; diff --git a/packages/react-components/react-virtualizer/src/components/Virtualizer/Virtualizer.types.ts b/packages/react-components/react-virtualizer/src/components/Virtualizer/Virtualizer.types.ts index 16bce33460eb9e..dbdfb52d21c222 100644 --- a/packages/react-components/react-virtualizer/src/components/Virtualizer/Virtualizer.types.ts +++ b/packages/react-components/react-virtualizer/src/components/Virtualizer/Virtualizer.types.ts @@ -20,7 +20,7 @@ export type VirtualizerSlots = { afterContainer: NonNullable>; }; -export type VirtualizerState = ComponentState & { +export type VirtualizerConfigState = { /** * The current virtualized array of children to show in the DOM. */ @@ -51,16 +51,19 @@ export type VirtualizerState = ComponentState & { */ reversed?: boolean; /** - * Tells the virtualizer how much + * Pixel size of intersection observers and how much they 'cross over' into the bufferItems index. + * Minimum 1px. */ bufferSize: number; }; +export type VirtualizerState = ComponentState & VirtualizerConfigState; + // Virtualizer render function to procedurally generate children elements as rows or columns via index. // Q: Use generic typing and passing through object data or a simple index system? export type VirtualizerChildRenderFunction = (index: number) => React.ReactNode; -export type VirtualizerProps = ComponentProps> & { +export type VirtualizerConfigProps = { /** * Child render function. * Iteratively called to return current virtualizer DOM children. @@ -110,9 +113,8 @@ export type VirtualizerProps = ComponentProps> & { /** * Enables users to override the intersectionObserverRoot. - * @default null */ - intersectionObserverRoot?: React.MutableRefObject; + scrollViewRef?: React.MutableRefObject; /** * The scroll direction @@ -142,3 +144,5 @@ export type VirtualizerProps = ComponentProps> & { */ onCalculateIndex?: (newIndex: number) => number; }; + +export type VirtualizerProps = ComponentProps> & VirtualizerConfigProps; diff --git a/packages/react-components/react-virtualizer/src/components/Virtualizer/useVirtualizer.ts b/packages/react-components/react-virtualizer/src/components/Virtualizer/useVirtualizer.ts index 208ec69c3f20ff..90577e0efc412e 100644 --- a/packages/react-components/react-virtualizer/src/components/Virtualizer/useVirtualizer.ts +++ b/packages/react-components/react-virtualizer/src/components/Virtualizer/useVirtualizer.ts @@ -15,7 +15,7 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta getItemSize, bufferItems = Math.round(virtualizerLength / 4.0), bufferSize = Math.floor(bufferItems / 2.0) * itemSize, - intersectionObserverRoot, + scrollViewRef, axis = 'vertical', reversed = false, onUpdateIndex, @@ -180,7 +180,7 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta } }, { - root: intersectionObserverRoot ? intersectionObserverRoot?.current : null, + root: scrollViewRef ? scrollViewRef?.current : null, rootMargin: '0px', threshold: 0, }, @@ -276,23 +276,26 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta return childProgressiveSizes.current[numItems - 1] - childProgressiveSizes.current[lastItemIndex]; }; - const updateChildRows = (newIndex: number) => { - if (numItems === 0) { - /* Nothing to virtualize */ + const updateChildRows = useCallback( + (newIndex: number) => { + if (numItems === 0) { + /* Nothing to virtualize */ - return []; - } + return []; + } - if (childArray.current.length !== numItems) { - childArray.current = new Array(virtualizerLength); - } - const actualIndex = Math.max(newIndex, 0); - const end = Math.min(actualIndex + virtualizerLength, numItems); + if (childArray.current.length !== numItems) { + childArray.current = new Array(virtualizerLength); + } + const actualIndex = Math.max(newIndex, 0); + const end = Math.min(actualIndex + virtualizerLength, numItems); - for (let i = actualIndex; i < end; i++) { - childArray.current[i - actualIndex] = renderChild(i); - } - }; + for (let i = actualIndex; i < end; i++) { + childArray.current[i - actualIndex] = renderChild(i); + } + }, + [numItems, renderChild, virtualizerLength], + ); const setBeforeRef = useCallback( (element: HTMLDivElement) => { @@ -387,7 +390,7 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta forceUpdate(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [renderChild]); + }, [renderChild, updateChildRows]); // Ensure we have run through and updated the whole size list array at least once. initializeSizeArray(); @@ -397,6 +400,12 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta populateSizeArrays(); } + // Ensure we recalc if virtualizer length changes + const maxCompare = Math.min(virtualizerLength, numItems); + if (childArray.current.length !== maxCompare && virtualizerStartIndex + childArray.current.length < numItems) { + updateChildRows(virtualizerStartIndex); + } + const isFullyInitialized = hasInitialized.current && virtualizerStartIndex >= 0; return { components: { diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/VirtualizerScrollView.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/VirtualizerScrollView.ts new file mode 100644 index 00000000000000..0a141260dc61ee --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/VirtualizerScrollView.ts @@ -0,0 +1,19 @@ +import { VirtualizerScrollViewProps } from './VirtualizerScrollView.types'; +import { useVirtualizerScrollView_unstable } from './useVirtualizerScrollView'; +import { renderVirtualizerScrollView_unstable } from './renderVirtualizerScrollView'; +import { useVirtualizerScrollViewStyles_unstable } from './useVirtualizerScrollViewStyles'; +import * as React from 'react'; + +/** + * Virtualizer ScrollView + */ + +export const VirtualizerScrollView: React.FC = (props: VirtualizerScrollViewProps) => { + const state = useVirtualizerScrollView_unstable(props); + + useVirtualizerScrollViewStyles_unstable(state); + + return renderVirtualizerScrollView_unstable(state); +}; + +VirtualizerScrollView.displayName = 'VirtualizerScrollView'; diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/VirtualizerScrollView.types.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/VirtualizerScrollView.types.ts new file mode 100644 index 00000000000000..198789fef9c81d --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/VirtualizerScrollView.types.ts @@ -0,0 +1,36 @@ +import { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import { + VirtualizerSlots, + VirtualizerConfigProps, + VirtualizerConfigState, + VirtualizerChildRenderFunction, +} from '../Virtualizer/Virtualizer.types'; + +export type VirtualizerScrollViewSlots = VirtualizerSlots & { + /** + * The root container that provides embedded scrolling. + */ + container: NonNullable>; +}; + +export type VirtualizerScrollViewProps = ComponentProps> & + Partial> & { + /** + * Virtualizer item size in pixels - static. + * Axis: 'vertical' = Height + * Axis: 'horizontal' = Width + */ + itemSize: number; + /** + * The total number of items to be virtualized. + */ + numItems: number; + /** + * Child render function. + * Iteratively called to return current virtualizer DOM children. + * Will act as a row or column indexer depending on Virtualizer settings. + */ + children: VirtualizerChildRenderFunction; + }; + +export type VirtualizerScrollViewState = ComponentState & VirtualizerConfigState; diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/index.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/index.ts new file mode 100644 index 00000000000000..bd57151d9a7d42 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/index.ts @@ -0,0 +1,5 @@ +export * from './VirtualizerScrollView'; +export * from './VirtualizerScrollView.types'; +export * from './useVirtualizerScrollView'; +export * from './renderVirtualizerScrollView'; +export * from './useVirtualizerScrollViewStyles'; diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/renderVirtualizerScrollView.tsx b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/renderVirtualizerScrollView.tsx new file mode 100644 index 00000000000000..1283838d46fc60 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/renderVirtualizerScrollView.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { getSlots } from '@fluentui/react-utilities'; +import { VirtualizerScrollViewSlots, VirtualizerScrollViewState } from './VirtualizerScrollView.types'; +import { renderVirtualizer_unstable } from '../Virtualizer/renderVirtualizer'; + +export const renderVirtualizerScrollView_unstable = (state: VirtualizerScrollViewState) => { + const { slots, slotProps } = getSlots(state); + + return {renderVirtualizer_unstable(state)}; +}; diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollView.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollView.ts new file mode 100644 index 00000000000000..331752a1376f31 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollView.ts @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { resolveShorthand, useMergedRefs } from '@fluentui/react-utilities'; +import { useVirtualizer_unstable } from '../Virtualizer/useVirtualizer'; +import { VirtualizerScrollViewProps, VirtualizerScrollViewState } from './VirtualizerScrollView.types'; +import { useStaticVirtualizerMeasure } from '../../Hooks'; + +export function useVirtualizerScrollView_unstable(props: VirtualizerScrollViewProps): VirtualizerScrollViewState { + const { virtualizerLength, bufferItems, bufferSize, scrollRef } = useStaticVirtualizerMeasure({ + defaultItemSize: props.itemSize, + direction: props.axis ?? 'vertical', + }); + + const iScrollRef = useMergedRefs(React.useRef(null), scrollRef); + + const virtualizerState = useVirtualizer_unstable({ + ...props, + virtualizerLength, + bufferItems, + bufferSize, + scrollViewRef: iScrollRef, + }); + + return { + ...virtualizerState, + components: { + ...virtualizerState.components, + container: 'div', + }, + container: resolveShorthand(props.container, { + required: true, + defaultProps: { + ref: iScrollRef as React.RefObject, + }, + }), + }; +} diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollViewStyles.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollViewStyles.ts new file mode 100644 index 00000000000000..b457f20bcef4c1 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollViewStyles.ts @@ -0,0 +1,68 @@ +import { VirtualizerScrollViewState } from './VirtualizerScrollView.types'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import { VirtualizerScrollViewSlots } from './VirtualizerScrollView.types'; +import { useVirtualizerStyles_unstable, virtualizerClassNames } from '../Virtualizer/useVirtualizerStyles'; +import { makeStyles, mergeClasses } from '@griffel/react'; + +const virtualizerScrollViewClassName = 'fui-Virtualizer-Scroll-View'; + +export const virtualizerScrollViewClassNames: SlotClassNames = { + ...virtualizerClassNames, + container: `${virtualizerScrollViewClassName}__container`, +}; + +const useStyles = makeStyles({ + base: { + display: 'flex', + width: '100%', + height: '100%', + overflowAnchor: 'none', + }, + vertical: { + flexDirection: 'column', + overflowAnchor: 'none', + overflowY: 'auto', + }, + horizontal: { + flexDirection: 'row', + overflowX: 'auto', + }, + verticalReversed: { + flexDirection: 'column-reverse', + overflowY: 'auto', + }, + horizontalReversed: { + flexDirection: 'row-reverse', + overflowX: 'auto', + }, +}); + +/** + * Apply styling to the Virtualizer states + */ +export const useVirtualizerScrollViewStyles_unstable = ( + state: VirtualizerScrollViewState, +): VirtualizerScrollViewState => { + const styles = useStyles(); + + // For now - just return default style mods + useVirtualizerStyles_unstable(state); + + const containerStyle = + state.axis === 'horizontal' + ? state.reversed + ? styles.horizontalReversed + : styles.horizontal + : state.reversed + ? styles.verticalReversed + : styles.vertical; + + state.container.className = mergeClasses( + virtualizerScrollViewClassNames.container, + styles.base, + containerStyle, + state.container.className, + ); + + return state; +}; diff --git a/packages/react-components/react-virtualizer/src/hooks/index.ts b/packages/react-components/react-virtualizer/src/hooks/index.ts index 97e1994797fa28..6e09744b3c9783 100644 --- a/packages/react-components/react-virtualizer/src/hooks/index.ts +++ b/packages/react-components/react-virtualizer/src/hooks/index.ts @@ -1 +1,2 @@ export * from './useIntersectionObserver'; +export * from './useVirtualizerMeasure'; diff --git a/packages/react-components/react-virtualizer/src/hooks/useIntersectionObserver.ts b/packages/react-components/react-virtualizer/src/hooks/useIntersectionObserver.ts index cfceb58f66a022..f0838f3b459b2d 100644 --- a/packages/react-components/react-virtualizer/src/hooks/useIntersectionObserver.ts +++ b/packages/react-components/react-virtualizer/src/hooks/useIntersectionObserver.ts @@ -22,14 +22,6 @@ export const useIntersectionObserver = ( setObserverInit: Dispatch>; observer: MutableRefObject; } => { - // export const useIntersectionObserver = ( - // callback: IntersectionObserverCallback, - // options?: IntersectionObserverInit, - // ): [ - // Dispatch>, - // Dispatch>, - // MutableRefObject, - // ] => { const observer = useRef(); const [observerList, setObserverList] = useState(); const [observerInit, setObserverInit] = useState(options); diff --git a/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.ts b/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.ts new file mode 100644 index 00000000000000..d5f92320ecb233 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.ts @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { canUseDOM } from '@fluentui/react-utilities'; +import { VirtualizerMeasureProps } from './useVirtualizerMeasure.types'; +import { debounce } from '../utilities/debounce'; + +/** + * React hook that measures virtualized space based on a static size to ensure optimized virtualization length. + */ +export const useStaticVirtualizerMeasure = ( + virtualizerProps: VirtualizerMeasureProps, +): { + virtualizerLength: number; + bufferItems: number; + bufferSize: number; + scrollRef: (instance: HTMLElement | HTMLDivElement | null) => void; +} => { + const { defaultItemSize, direction = 'vertical' } = virtualizerProps; + + const [state, setState] = React.useState({ + virtualizerLength: 0, + bufferSize: 0, + bufferItems: 0, + }); + + const { virtualizerLength, bufferItems, bufferSize } = state; + + // The ref the user sets on their scrollView - Defaults to document.body to ensure no null on init + const container = React.useRef(null); + + const resizeCallback = () => { + if (!container.current) { + return; + } + + const containerSize = + direction === 'vertical' + ? container.current.getBoundingClientRect().height + : container.current.getBoundingClientRect().width; + + /* + * Number of items required to cover viewport. + */ + const length = Math.ceil(containerSize / defaultItemSize + 1); + + /* + * Number of items to append at each end, i.e. 'preload' each side before entering view. + */ + const newBufferItems = Math.max(Math.floor(length / 4), 2); + + /* + * This is how far we deviate into the bufferItems to detect a redraw. + */ + const newBufferSize = Math.max(Math.floor((length / 8) * defaultItemSize), 1); + + const totalLength = length + newBufferItems * 2 + 1; + + setState({ + virtualizerLength: totalLength, + bufferItems: newBufferItems, + bufferSize: newBufferSize, + }); + }; + + // the handler for resize observer + const handleResize = debounce(resizeCallback); + + // Keep the reference of ResizeObserver in the state, as it should live through renders + const [resizeObserver] = React.useState(canUseDOM() ? new ResizeObserver(handleResize) : undefined); + + React.useEffect(() => { + return () => { + resizeObserver?.disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const scrollRef = React.useCallback( + (el: HTMLElement | null) => { + if (container.current !== el) { + if (container.current) { + resizeObserver?.unobserve(container.current); + } + + container.current = el; + if (container.current) { + resizeObserver?.observe(container.current); + } + } + }, + [resizeObserver], + ); + + return { + virtualizerLength, + bufferItems, + bufferSize, + scrollRef, + }; +}; diff --git a/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.types.ts b/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.types.ts new file mode 100644 index 00000000000000..dffd7c801c7b89 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.types.ts @@ -0,0 +1,4 @@ +export type VirtualizerMeasureProps = { + defaultItemSize: number; + direction?: 'vertical' | 'horizontal'; +}; diff --git a/packages/react-components/react-virtualizer/src/index.ts b/packages/react-components/react-virtualizer/src/index.ts index 3d8f72a15d351c..eff1e6574b1946 100644 --- a/packages/react-components/react-virtualizer/src/index.ts +++ b/packages/react-components/react-virtualizer/src/index.ts @@ -11,4 +11,18 @@ export type { VirtualizerSlots, VirtualizerChildRenderFunction, } from './Virtualizer'; -export { useIntersectionObserver } from './Hooks'; +export { useIntersectionObserver, useStaticVirtualizerMeasure } from './Hooks'; + +export { + VirtualizerScrollView, + virtualizerScrollViewClassNames, + useVirtualizerScrollView_unstable, + renderVirtualizerScrollView_unstable, + useVirtualizerScrollViewStyles_unstable, +} from './VirtualizerScrollView'; + +export type { + VirtualizerScrollViewProps, + VirtualizerScrollViewState, + VirtualizerScrollViewSlots, +} from './VirtualizerScrollView'; diff --git a/packages/react-components/react-virtualizer/src/utilities/debounce.ts b/packages/react-components/react-virtualizer/src/utilities/debounce.ts new file mode 100644 index 00000000000000..966da2e0d50315 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/utilities/debounce.ts @@ -0,0 +1,20 @@ +/** + * Microtask debouncer + * https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide + * @param fn - Function to debounce + * @returns debounced function + */ +export function debounce(fn: Function) { + let pending: boolean; + return () => { + if (!pending) { + pending = true; + queueMicrotask(() => { + // Need to set pending to `false` before the debounced function is run. + // React can actually interrupt the function while it's running! + pending = false; + fn(); + }); + } + }; +} diff --git a/packages/react-components/react-virtualizer/stories/Virtualizer/Default.stories.tsx b/packages/react-components/react-virtualizer/stories/Virtualizer/Default.stories.tsx index cbeabffb5cbc05..00ab141a061181 100644 --- a/packages/react-components/react-virtualizer/stories/Virtualizer/Default.stories.tsx +++ b/packages/react-components/react-virtualizer/stories/Virtualizer/Default.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Virtualizer } from '@fluentui/react-components/unstable'; +import { Virtualizer, useStaticVirtualizerMeasure } from '@fluentui/react-components/unstable'; import { makeStyles } from '@fluentui/react-components'; const useStyles = makeStyles({ @@ -10,7 +10,7 @@ const useStyles = makeStyles({ overflowY: 'auto', width: '100%', height: '100%', - maxHeight: '750px', + maxHeight: '60vh', }, child: { height: '100px', @@ -23,9 +23,19 @@ export const Default = () => { const styles = useStyles(); const childLength = 1000; + const { virtualizerLength, bufferItems, bufferSize, scrollRef } = useStaticVirtualizerMeasure({ + defaultItemSize: 100, + }); + return ( -
- +
+ {index => { return ( { + const styles = useStyles(); + const childLength = 1000; + + const { virtualizerLength, bufferItems, bufferSize, scrollRef } = useStaticVirtualizerMeasure({ + defaultItemSize: 100, + }); + + const { targetDocument } = useFluent(); + if (targetDocument) { + scrollRef(targetDocument.body); + } + + return ( + +
+
{`Virtualizer`}
+ + {index => { + return ( + {`Node-${index}`} + ); + }} + +
+ Footer +
+
+
+ ); +}; diff --git a/packages/react-components/react-virtualizer/stories/Virtualizer/Horizontal.stories.tsx b/packages/react-components/react-virtualizer/stories/Virtualizer/Horizontal.stories.tsx index 4832d1c21491aa..9b0539346f3384 100644 --- a/packages/react-components/react-virtualizer/stories/Virtualizer/Horizontal.stories.tsx +++ b/packages/react-components/react-virtualizer/stories/Virtualizer/Horizontal.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Virtualizer } from '@fluentui/react-components/unstable'; +import { useStaticVirtualizerMeasure, Virtualizer } from '@fluentui/react-components/unstable'; import { makeStyles } from '@fluentui/react-components'; const useStyles = makeStyles({ @@ -22,10 +22,23 @@ const useStyles = makeStyles({ export const Horizontal = () => { const styles = useStyles(); const childLength = 1000; + const itemWidth = 100; + + const { virtualizerLength, bufferItems, bufferSize, scrollRef } = useStaticVirtualizerMeasure({ + defaultItemSize: itemWidth, + direction: 'horizontal', + }); return ( -
- +
+ {index => { return ( { + const styles = useStyles(); + const childLength = 100; + const repeatingVirtualizers = 5; + + const { virtualizerLength, bufferItems, bufferSize, scrollRef } = useStaticVirtualizerMeasure({ + defaultItemSize: 100, + }); + + const { targetDocument } = useFluent(); + if (targetDocument) { + scrollRef(targetDocument.body); + } + + const renderHeader = (index: number) => { + return
{`Virtualizer Instance - ${index}`}
; + }; + + const renderVirtualization = (index: number) => { + return ( + + {rowIndex => { + return ( + {`Node-${index}-${rowIndex}`} + ); + }} + + ); + }; + + const renderVirtualizerLoop = () => { + // Virtualizer instances can all run independently, even inline a single scroll view. + const array = []; + for (let i = 0; i < repeatingVirtualizers; i++) { + array.push(renderHeader(i)); + array.push(renderVirtualization(i)); + } + return array; + }; + + return ( + +
+ {renderVirtualizerLoop()} +
+ Footer +
+
+
+ ); +}; diff --git a/packages/react-components/react-virtualizer/stories/Virtualizer/RTL.stories.tsx b/packages/react-components/react-virtualizer/stories/Virtualizer/RTL.stories.tsx index 20b75d0dbc3877..9f6d296753b312 100644 --- a/packages/react-components/react-virtualizer/stories/Virtualizer/RTL.stories.tsx +++ b/packages/react-components/react-virtualizer/stories/Virtualizer/RTL.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Virtualizer } from '@fluentui/react-components/unstable'; +import { useStaticVirtualizerMeasure, Virtualizer } from '@fluentui/react-components/unstable'; import { makeStyles } from '@fluentui/react-components'; const useStyles = makeStyles({ @@ -24,9 +24,23 @@ export const RTL = () => { const styles = useStyles(); const childLength = 1000; + const itemWidth = 100; + const { virtualizerLength, bufferItems, bufferSize, scrollRef } = useStaticVirtualizerMeasure({ + defaultItemSize: itemWidth, + direction: 'horizontal', + }); + return ( -
- +
+ {index => { return ( { const styles = useStyles(); const childLength = 1000; + const itemSize = 100; + + const { virtualizerLength, bufferItems, bufferSize, scrollRef } = useStaticVirtualizerMeasure({ + defaultItemSize: itemSize, + }); return ( -
- +
+ {index => { return ( { const styles = useStyles(); const childLength = 1000; + const itemWidth = 100; + + const { virtualizerLength, bufferItems, bufferSize, scrollRef } = useStaticVirtualizerMeasure({ + defaultItemSize: itemWidth, + direction: 'horizontal', + }); + return ( -
- +
+ {index => { return ( { + const styles = useStyles(); + const childLength = 1000; + + return ( + + + {(index: number) => { + return ( +
{`Node-${index}`}
+ ); + }} +
+
+ ); +}; diff --git a/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/VirtualizerScrollViewDescription.md b/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/VirtualizerScrollViewDescription.md new file mode 100644 index 00000000000000..6e866566c10755 --- /dev/null +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/VirtualizerScrollViewDescription.md @@ -0,0 +1,12 @@ + + +> **⚠️ Preview components are considered unstable:** +> +> ```jsx +> +> import { VirtualizerScrollView } from '@fluentui/react-components/unstable'; +> +> ``` +> +> - Features and APIs may change before final release +> - Please contact us if you intend to use this in your product diff --git a/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/index.stories.ts b/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/index.stories.ts new file mode 100644 index 00000000000000..db5040009bd414 --- /dev/null +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/index.stories.ts @@ -0,0 +1,16 @@ +import { VirtualizerScrollView } from '../../src/VirtualizerScrollView'; +import descriptionMd from './VirtualizerScrollViewDescription.md'; + +export { Default } from './Default.stories'; + +export default { + title: 'Preview Components/VirtualizerScrollView', + component: VirtualizerScrollView, + parameters: { + docs: { + description: { + component: [descriptionMd].join('\n'), + }, + }, + }, +};