diff --git a/change/@fluentui-react-tree-82531355-9d67-474e-b007-7fb1e6a64060.json b/change/@fluentui-react-tree-82531355-9d67-474e-b007-7fb1e6a64060.json new file mode 100644 index 00000000000000..b81398d61fbb90 --- /dev/null +++ b/change/@fluentui-react-tree-82531355-9d67-474e-b007-7fb1e6a64060.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feature: preventScroll on navigation", + "packageName": "@fluentui/react-tree", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-tree/etc/react-tree.api.md b/packages/react-components/react-tree/etc/react-tree.api.md index ee1d02b116a36b..ef1e88e19f8228 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -369,7 +369,7 @@ export type TreeProps = ComponentProps & { openItems?: Iterable; defaultOpenItems?: Iterable; onOpenChange?(event: TreeOpenChangeEvent, data: TreeOpenChangeData): void; - onNavigation?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void; + onNavigation?(event: TreeNavigationEvent_unstable, data: TreeNavigationDataParam): void; selectionMode?: SelectionMode_2; checkedItems?: Iterable; onCheckedChange?(event: TreeCheckedChangeEvent, data: TreeCheckedChangeData): void; diff --git a/packages/react-components/react-tree/src/components/Tree/Tree.cy.tsx b/packages/react-components/react-tree/src/components/Tree/Tree.cy.tsx index edb84dad148706..af5256110c9dc7 100644 --- a/packages/react-components/react-tree/src/components/Tree/Tree.cy.tsx +++ b/packages/react-components/react-tree/src/components/Tree/Tree.cy.tsx @@ -234,6 +234,24 @@ describe('Tree', () => { cy.get('[data-testid="item2__item1__item1"]').should('be.focused').realPress('{home}'); cy.get('[data-testid="item1"]').should('be.focused'); }); + it('should prevent scrolling when `preventScroll()` is called in navigation', () => { + mount( + { + data.preventScroll(); + }} + defaultOpenItems={['item1', 'item2', 'item2__item1']} + > + {Array.from({ length: 200 }, (_, index) => ( + + level 0, item {index + 1} + + ))} + , + ); + cy.get('[data-testid="item0"]').focus().realPress('{end}'); + cy.get('[data-testid="item199"]').should('be.focused').isOutsideViewport(); + }); }); }); @@ -362,6 +380,7 @@ describe('Tree', () => { cy.get('[data-testid="tree-item-2-1-1"]').should('exist'); }); }); + it('should ensure roving tab indexes when focusing programmatically', () => { mount( <> @@ -377,3 +396,23 @@ describe('Tree', () => { cy.get('[data-testid="item2__item1"]').should('be.focused'); }); }); + +declare global { + namespace Cypress { + interface Chainable { + isOutsideViewport(): Chainable; + } + } +} + +Cypress.Commands.add('isOutsideViewport', { prevSubject: true }, subject => { + const windowInnerHeight = Cypress.config(`viewportHeight`); + + const bounding = subject[0].getBoundingClientRect(); + + const bottomBoundOfWindow = windowInnerHeight; + + expect(bounding.top).to.be.greaterThan(bottomBoundOfWindow); + + return subject; +}); diff --git a/packages/react-components/react-tree/src/components/Tree/Tree.types.ts b/packages/react-components/react-tree/src/components/Tree/Tree.types.ts index ec1113b16399f1..5f039445d7350c 100644 --- a/packages/react-components/react-tree/src/components/Tree/Tree.types.ts +++ b/packages/react-components/react-tree/src/components/Tree/Tree.types.ts @@ -52,6 +52,18 @@ export type TreeOpenChangeData = { | { event: React.KeyboardEvent; type: typeof ArrowLeft } ); +/** + * @internal + * + * To avoid breaking changes on TreeNavigationData + * we are creating a new type that extends the old one + * and adds the new methods, and this type will not be exported + */ +type TreeNavigationDataParam = TreeNavigationData_unstable & { + preventScroll(): void; + isScrollPrevented(): boolean; +}; + export type TreeOpenChangeEvent = TreeOpenChangeData['event']; export type TreeCheckedChangeData = { @@ -121,7 +133,7 @@ export type TreeProps = ComponentProps & { * @param event - a React's Synthetic event * @param data - A data object with relevant information, */ - onNavigation?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void; + onNavigation?(event: TreeNavigationEvent_unstable, data: TreeNavigationDataParam): void; /** * This refers to the selection mode of the tree. diff --git a/packages/react-components/react-tree/src/components/Tree/useTree.ts b/packages/react-components/react-tree/src/components/Tree/useTree.ts index 8545c78eba62f3..05a0cf228ea3af 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTree.ts @@ -39,7 +39,9 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref): TreeS onNavigation: useEventCallback((event, data) => { props.onNavigation?.(event, data); if (!event.isDefaultPrevented()) { - navigation.navigate(data); + navigation.navigate(data, { + preventScroll: data.isScrollPrevented(), + }); } }), onCheckedChange: useEventCallback((event, data) => { @@ -50,7 +52,7 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref): TreeS }); }), }, - useMergedRefs(ref, navigation.rootRef), + useMergedRefs(ref, navigation.treeRef), ), { treeType: 'nested' } as const, ); diff --git a/packages/react-components/react-tree/src/hooks/useRootTree.ts b/packages/react-components/react-tree/src/hooks/useRootTree.ts index fd57070c21e2fc..959aa3ed2bddc9 100644 --- a/packages/react-components/react-tree/src/hooks/useRootTree.ts +++ b/packages/react-components/react-tree/src/hooks/useRootTree.ts @@ -44,7 +44,14 @@ export function useRootTree( }; const requestNavigation = (request: Extract) => { - props.onNavigation?.(request.event, request); + let isScrollPrevented = false; + props.onNavigation?.(request.event, { + ...request, + preventScroll: () => { + isScrollPrevented = true; + }, + isScrollPrevented: () => isScrollPrevented, + }); switch (request.type) { case treeDataTypes.ArrowDown: case treeDataTypes.ArrowUp: diff --git a/packages/react-components/react-tree/src/hooks/useRovingTabIndexes.ts b/packages/react-components/react-tree/src/hooks/useRovingTabIndexes.ts index df119b522fd387..663f4225184242 100644 --- a/packages/react-components/react-tree/src/hooks/useRovingTabIndexes.ts +++ b/packages/react-components/react-tree/src/hooks/useRovingTabIndexes.ts @@ -4,6 +4,7 @@ import { useFocusedElementChange } from '@fluentui/react-tabster'; import { elementContains } from '@fluentui/react-utilities'; /** + * @internal * https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex */ export function useRovingTabIndex() { @@ -37,13 +38,13 @@ export function useRovingTabIndex() { nextElement.tabIndex = -1; } }, []); - const rove = React.useCallback((nextElement: HTMLElement) => { + const rove = React.useCallback((nextElement: HTMLElement, focusOptions?: FocusOptions) => { if (!currentElementRef.current) { return; } currentElementRef.current.tabIndex = -1; nextElement.tabIndex = 0; - nextElement.focus(); + nextElement.focus(focusOptions); currentElementRef.current = nextElement; }, []); diff --git a/packages/react-components/react-tree/src/hooks/useTreeNavigation.ts b/packages/react-components/react-tree/src/hooks/useTreeNavigation.ts index b356aee50f0ba4..ca8dad6696c304 100644 --- a/packages/react-components/react-tree/src/hooks/useTreeNavigation.ts +++ b/packages/react-components/react-tree/src/hooks/useTreeNavigation.ts @@ -7,6 +7,9 @@ import * as React from 'react'; import { useHTMLElementWalkerRef } from './useHTMLElementWalkerRef'; import { useMergedRefs } from '@fluentui/react-utilities'; +/** + * @internal + */ export function useTreeNavigation() { const { rove, initialize: initializeRovingTabIndex } = useRovingTabIndex(); const { walkerRef, rootRef: walkerRootRef } = useHTMLElementWalkerRef(); @@ -50,13 +53,16 @@ export function useTreeNavigation() { return walkerRef.current.previousElement(); } }; - function navigate(data: TreeNavigationData_unstable) { + function navigate(data: TreeNavigationData_unstable, focusOptions?: FocusOptions) { const nextElement = getNextElement(data); if (nextElement) { - rove(nextElement); + rove(nextElement, focusOptions); } } - return { navigate, rootRef: useMergedRefs(walkerRootRef, rootRefCallback) } as const; + return { + navigate, + treeRef: useMergedRefs(walkerRootRef, rootRefCallback) as React.RefCallback, + } as const; } function lastChildRecursive(walker: HTMLElementWalker) {