diff --git a/packages/react-accordion/etc/react-accordion.api.md b/packages/react-accordion/etc/react-accordion.api.md index dff2a12b35cee..4a270d0ad9c41 100644 --- a/packages/react-accordion/etc/react-accordion.api.md +++ b/packages/react-accordion/etc/react-accordion.api.md @@ -5,6 +5,7 @@ ```ts import { ComponentProps } from '@fluentui/react-utilities'; +import { Descendant } from '@fluentui/react-utilities'; import { ObjectShorthandProps } from '@fluentui/react-utilities'; import * as React from 'react'; import { ShorthandProps } from '@fluentui/react-utilities'; @@ -20,8 +21,6 @@ export interface AccordionContext extends AccordionHeaderCommonProps { requestToggle: NonNullable; } -// Warning: (ae-forgotten-export) The symbol "Descendant" needs to be exported by the entry point index.d.ts -// // @public (undocumented) export interface AccordionDescendant extends Descendant { disabled: boolean; diff --git a/packages/react-accordion/src/components/Accordion/Accordion.test.tsx b/packages/react-accordion/src/components/Accordion/Accordion.test.tsx index c98860aa94484..b04f02b25ab5b 100644 --- a/packages/react-accordion/src/components/Accordion/Accordion.test.tsx +++ b/packages/react-accordion/src/components/Accordion/Accordion.test.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import { Accordion } from './Accordion'; import * as renderer from 'react-test-renderer'; +import { DescendantProvider } from '@fluentui/react-utilities'; import { ReactWrapper } from 'enzyme'; import { isConformant } from '../../common/isConformant'; import { accordionContext, accordionDescendantContext } from './useAccordionContext'; -import { DescendantProvider } from '../../utils/descendants'; describe('Accordion', () => { isConformant({ diff --git a/packages/react-accordion/src/components/Accordion/Accordion.types.ts b/packages/react-accordion/src/components/Accordion/Accordion.types.ts index 8301b39e70269..e1b78a8280c2e 100644 --- a/packages/react-accordion/src/components/Accordion/Accordion.types.ts +++ b/packages/react-accordion/src/components/Accordion/Accordion.types.ts @@ -1,6 +1,5 @@ import * as React from 'react'; -import { ComponentProps } from '@fluentui/react-utilities'; -import { Descendant } from '../../utils/descendants'; +import { ComponentProps, Descendant } from '@fluentui/react-utilities'; import { AccordionHeaderProps } from '../AccordionHeader/AccordionHeader.types'; export type AccordionIndex = number | number[]; diff --git a/packages/react-accordion/src/components/Accordion/renderAccordion.tsx b/packages/react-accordion/src/components/Accordion/renderAccordion.tsx index 9196ac6bfd3b3..cb722ee429403 100644 --- a/packages/react-accordion/src/components/Accordion/renderAccordion.tsx +++ b/packages/react-accordion/src/components/Accordion/renderAccordion.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; -import { getSlots } from '@fluentui/react-utilities'; +import { getSlots, DescendantProvider } from '@fluentui/react-utilities'; import { AccordionState } from './Accordion.types'; import { accordionShorthandProps } from './useAccordion'; -import { DescendantProvider } from '../../utils/descendants'; import { accordionContext, accordionDescendantContext } from './useAccordionContext'; /** diff --git a/packages/react-accordion/src/components/Accordion/useAccordionContext.ts b/packages/react-accordion/src/components/Accordion/useAccordionContext.ts index 5a35d3dda7973..262c134e7e202 100644 --- a/packages/react-accordion/src/components/Accordion/useAccordionContext.ts +++ b/packages/react-accordion/src/components/Accordion/useAccordionContext.ts @@ -1,7 +1,12 @@ import * as React from 'react'; -import { createDescendantContext, useDescendant, useDescendantsInit } from '../../utils/descendants'; +import { + createDescendantContext, + useDescendant, + useDescendantsInit, + useControllableValue, + useEventCallback, +} from '@fluentui/react-utilities'; import { AccordionContext, AccordionDescendant, AccordionIndex, AccordionState } from './Accordion.types'; -import { useControllableValue, useEventCallback } from '@fluentui/react-utilities'; import { createContext } from '@fluentui/react-context-selector'; export const accordionDescendantContext = createDescendantContext('AccordionDescendantContext'); diff --git a/packages/react-utilities/etc/react-utilities.api.md b/packages/react-utilities/etc/react-utilities.api.md index b3c822bd11fbf..da81fdb37d2b5 100644 --- a/packages/react-utilities/etc/react-utilities.api.md +++ b/packages/react-utilities/etc/react-utilities.api.md @@ -49,6 +49,36 @@ export interface ComponentProps { className?: string; } +// @public (undocumented) +export function createDescendantContext(name: string, initialValue?: {}): React.Context>; + +// @public (undocumented) +export function createNamedContext(name: string, defaultValue: ContextValueType): React.Context; + +// @public (undocumented) +export type Descendant = { + element: SomeElement | null; + index: number; +}; + +// @public (undocumented) +export interface DescendantContextValue { + // (undocumented) + descendants: DescendantType[]; + // (undocumented) + registerDescendant(descendant: DescendantType): void; + // (undocumented) + unregisterDescendant(element: DescendantType['element']): void; +} + +// @public (undocumented) +export const DescendantProvider: >({ context: Ctx, children, items, set, }: { + context: React.Context>; + children: React.ReactNode; + items: DescendantType[]; + set: React.Dispatch>; +}) => JSX.Element; + // @public export const divProperties: Record; @@ -176,12 +206,35 @@ export function useControllableValue(contr // @public (undocumented) export function useControllableValue | undefined>(controlledValue: TValue, defaultUncontrolledValue: DefaultValue, onChange: ChangeCallback): Readonly<[TValue, (update: React.SetStateAction, ev?: React.FormEvent) => void]>; +// @public +export function useDescendant(descendant: Omit, context: React.Context>, indexProp?: number): number; + +// @public +export function useDescendantKeyDown(context: React.Context>, options: { + currentIndex: number | null | undefined; + key?: K | 'option'; + filter?: (descendant: DescendantType) => boolean; + orientation?: 'vertical' | 'horizontal' | 'both'; + rotate?: boolean; + rtl?: boolean; + callback(nextOption: DescendantType | DescendantType[K]): void; +}): (event: React.KeyboardEvent) => void; + +// @public (undocumented) +export function useDescendants(ctx: React.Context>): DescendantType[]; + +// @public (undocumented) +export function useDescendantsInit(): [DescendantType[], React.Dispatch>]; + // @public export const useEventCallback: (fn: (...args: Args) => Return) => (...args: Args) => Return; // @public export function useFirstMount(): boolean; +// @public +export function useForceUpdate(): () => void; + // @public export function useId(prefix?: string, providedId?: string): string; @@ -201,10 +254,17 @@ export type UseOnClickOutsideOptions = { callback: (ev: MouseEvent | TouchEvent) => void; }; +// @public (undocumented) +export function usePrevious(value: ValueType): ValueType | null; + // @public export const videoProperties: Record; +// Warnings were encountered during analysis: +// +// lib/descendants/descendants.d.ts:64:5 - (ae-forgotten-export) The symbol "SomeElement" needs to be exported by the entry point index.d.ts + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/react-utilities/src/descendants/descendants.tsx b/packages/react-utilities/src/descendants/descendants.tsx new file mode 100644 index 0000000000000..49ae083f386b2 --- /dev/null +++ b/packages/react-utilities/src/descendants/descendants.tsx @@ -0,0 +1,413 @@ +/** + * This file is from @reach/descendants + * https://github.com/reach/reach-ui/tree/develop/packages/descendants + * + * Copying it was required due to TS version issues. The original one required TS 3.8. + * Next step would be to implement an internal version of it under react-utilities + */ + +import * as React from 'react'; +import { arrayFind, arrayFindIndex, arrayIncludes, objectValues } from './polyfills'; + +export function createDescendantContext(name: string, initialValue = {}) { + const descendants: DescendantType[] = []; + return createNamedContext>(name, { + descendants, + registerDescendant: () => { + /* */ + }, + unregisterDescendant: () => { + /* */ + }, + ...initialValue, + }); +} + +/** + * This hook registers our descendant by passing it into an array. We can then + * search that array by to find its index when registering it in the component. + * We use this for focus management, keyboard navigation, and typeahead + * functionality for some components. + * + * The hook accepts the element node and (optionally) a key. The key is useful + * if multiple descendants have identical text values and we need to + * differentiate siblings for some reason. + * + * Our main goals with this are: + * 1) maximum composability, + * 2) minimal API friction + * 3) SSR compatibility* + * 4) concurrent safe + * 5) index always up-to-date with the tree despite changes + * 6) works with memoization of any component in the tree (hopefully) + * + * * As for SSR, the good news is that we don't actually need the index on the + * server for most use-cases, as we are only using it to determine the order of + * composed descendants for keyboard navigation. However, in the few cases where + * this is not the case, we can require an explicit index from the app. + */ +export function useDescendant( + descendant: Omit, + context: React.Context>, + indexProp?: number, +) { + const forceUpdate = useForceUpdate(); + const { registerDescendant, unregisterDescendant, descendants } = React.useContext(context); + + // This will initially return -1 because we haven't registered the descendant + // on the first render. After we register, this will then return the correct + // index on the following render and we will re-register descendants + // so that everything is up-to-date before the user interacts with a + // collection. + const index = indexProp ?? arrayFindIndex(descendants, item => item.element === descendant.element); + + const previousDescendants = usePrevious(descendants); + + // We also need to re-register descendants any time ANY of the other + // descendants have changed. My brain was melting when I wrote this and it + // feels a little off, but checking in render and using the result in the + // effect's dependency array works well enough. + const someDescendantsHaveChanged = descendants.some((nextDescendant, i) => { + return nextDescendant.element !== previousDescendants?.[i]?.element; + }); + + // Prevent any flashing + React.useLayoutEffect(() => { + if (!descendant.element) { + forceUpdate(); + } + registerDescendant({ + ...descendant, + index, + } as DescendantType); + return () => unregisterDescendant(descendant.element); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + forceUpdate, + index, + registerDescendant, + someDescendantsHaveChanged, + unregisterDescendant, + // eslint-disable-next-line react-hooks/exhaustive-deps + ...objectValues(descendant), + ]); + + return index; +} + +export function useDescendantsInit() { + return React.useState([]); +} + +export function useDescendants( + ctx: React.Context>, +) { + return React.useContext(ctx).descendants; +} + +const UnmemoizedDescendantProvider = ({ + context: Ctx, + children, + items, + set, +}: { + context: React.Context>; + children: React.ReactNode; + items: DescendantType[]; + set: React.Dispatch>; +}) => { + const registerDescendant = React.useCallback( + ({ element, index: explicitIndex, ...rest }: Omit & { index?: number | undefined }) => { + if (!element) { + return; + } + + set(previousItems => { + let newItems: DescendantType[]; + if (explicitIndex !== null || explicitIndex !== undefined) { + newItems = [ + ...previousItems, + { + ...rest, + element, + index: explicitIndex, + } as DescendantType, + ]; + } else if (previousItems.length === 0) { + // If there are no items, register at index 0 and bail. + newItems = [ + ...previousItems, + { + ...rest, + element, + index: 0, + } as DescendantType, + ]; + } else if (arrayFind(previousItems, item => item.element === element)) { + // If the element is already registered, just use the same array + newItems = previousItems; + } else { + // When registering a descendant, we need to make sure we insert in + // into the array in the same order that it appears in the DOM. So as + // new descendants are added or maybe some are removed, we always know + // that the array is up-to-date and correct. + // + // So here we look at our registered descendants and see if the new + // element we are adding appears earlier than an existing descendant's + // DOM node via `node.compareDocumentPosition`. If it does, we insert + // the new element at this index. Because `registerDescendant` will be + // called in an effect every time the descendants state value changes, + // we should be sure that this index is accurate when descendent + // elements come or go from our component. + const index = arrayFindIndex(previousItems, item => { + if (!item.element || !element) { + return false; + } + // Does this element's DOM node appear before another item in the + // array in our DOM tree? If so, return true to grab the index at + // this point in the array so we know where to insert the new + // element. + return Boolean( + // eslint-disable-next-line no-bitwise + item.element.compareDocumentPosition(element as Node) & Node.DOCUMENT_POSITION_PRECEDING, + ); + }); + + const newItem = { + ...rest, + element, + index, + } as DescendantType; + + // If an index is not found we will push the element to the end. + if (index === -1) { + newItems = [...previousItems, newItem]; + } else { + newItems = [...previousItems.slice(0, index), newItem, ...previousItems.slice(index)]; + } + } + return newItems.map((item, index) => ({ ...item, index })); + }); + }, + // set is a state setter initialized by the useDescendantsInit hook. + // We can safely ignore the lint warning here because it will not change + // between renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const unregisterDescendant = React.useCallback( + (element: DescendantType['element']) => { + if (!element) { + return; + } + + set(previousItems => previousItems.filter(item => element !== item.element)); + }, + // set is a state setter initialized by the useDescendantsInit hook. + // We can safely ignore the lint warning here because it will not change + // between renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + return ( + { + return { + descendants: items, + registerDescendant, + unregisterDescendant, + }; + }, [items, registerDescendant, unregisterDescendant])} + > + {children} + + ); +}; +export const DescendantProvider = React.memo(UnmemoizedDescendantProvider) as typeof UnmemoizedDescendantProvider; + +/** + * Testing this as an abstraction for compound components that use keyboard + * navigation. Hoping this will help us prevent bugs and mismatched behavior + * across various components, but it may also prove to be too messy of an + * abstraction in the end. + * + * Currently used in: + * - Tabs + * - Accordion + * + */ +export function useDescendantKeyDown< + DescendantType extends Descendant, + K extends keyof DescendantType = keyof DescendantType +>( + context: React.Context>, + options: { + currentIndex: number | null | undefined; + key?: K | 'option'; + filter?: (descendant: DescendantType) => boolean; + orientation?: 'vertical' | 'horizontal' | 'both'; + rotate?: boolean; + rtl?: boolean; + callback(nextOption: DescendantType | DescendantType[K]): void; + }, +) { + const { descendants } = React.useContext(context); + const { + callback, + currentIndex, + filter, + key = 'index' as K, + orientation = 'vertical', + rotate = true, + rtl = false, + } = options; + let index = currentIndex ?? -1; + + return function handleKeyDown(event: React.KeyboardEvent) { + if ( + !arrayIncludes( + ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'PageUp', 'PageDown', 'Home', 'End'], + event.key, + ) + ) { + return; + } + + // If we use a filter function, we need to re-index our descendants array + // so that filtered descendent elements aren't selected. + const selectableDescendants = filter ? descendants.filter(filter) : descendants; + + // Current index should map to the updated array vs. the original + // descendants array. + if (filter) { + index = arrayFindIndex(selectableDescendants, descendant => descendant.index === currentIndex); + } + + // We need some options for any of this to work! + if (!selectableDescendants.length) { + return; + } + + function getNextOption() { + const atBottom = index === selectableDescendants.length - 1; + return atBottom + ? rotate + ? getFirstOption() + : selectableDescendants[index] + : selectableDescendants[(index + 1) % selectableDescendants.length]; + } + + function getPreviousOption() { + const atTop = index === 0; + return atTop + ? rotate + ? getLastOption() + : selectableDescendants[index] + : selectableDescendants[(index - 1 + selectableDescendants.length) % selectableDescendants.length]; + } + + function getFirstOption() { + return selectableDescendants[0]; + } + + function getLastOption() { + return selectableDescendants[selectableDescendants.length - 1]; + } + + switch (event.key) { + case 'ArrowDown': + if (orientation === 'vertical' || orientation === 'both') { + event.preventDefault(); + const next = getNextOption(); + callback(key === 'option' ? next : next[key]); + } + break; + case 'ArrowUp': + if (orientation === 'vertical' || orientation === 'both') { + event.preventDefault(); + const prev = getPreviousOption(); + callback(key === 'option' ? prev : prev[key]); + } + break; + case 'ArrowLeft': + if (orientation === 'horizontal' || orientation === 'both') { + event.preventDefault(); + const nextOrPrev = (rtl ? getNextOption : getPreviousOption)(); + callback(key === 'option' ? nextOrPrev : nextOrPrev[key]); + } + break; + case 'ArrowRight': + if (orientation === 'horizontal' || orientation === 'both') { + event.preventDefault(); + const prevOrNext = (rtl ? getPreviousOption : getNextOption)(); + callback(key === 'option' ? prevOrNext : prevOrNext[key]); + } + break; + case 'PageUp': + event.preventDefault(); + const prevOrFirst = (event.ctrlKey ? getPreviousOption : getFirstOption)(); + callback(key === 'option' ? prevOrFirst : prevOrFirst[key]); + break; + case 'Home': + event.preventDefault(); + const first = getFirstOption(); + callback(key === 'option' ? first : first[key]); + break; + case 'PageDown': + event.preventDefault(); + const nextOrLast = (event.ctrlKey ? getNextOption : getLastOption)(); + callback(key === 'option' ? nextOrLast : nextOrLast[key]); + break; + case 'End': + event.preventDefault(); + const last = getLastOption(); + callback(key === 'option' ? last : last[key]); + break; + } + }; +} + +//////////////////////////////////////////////////////////////////////////////// +// Types + +type SomeElement = T extends Element ? T : HTMLElement; + +export type Descendant = { + element: SomeElement | null; + index: number; +}; + +export interface DescendantContextValue { + descendants: DescendantType[]; + registerDescendant(descendant: DescendantType): void; + unregisterDescendant(element: DescendantType['element']): void; +} + +export function usePrevious(value: ValueType) { + const ref = React.useRef(null); + React.useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; +} + +/** + * Forces a re-render, similar to `forceUpdate` in class components. + */ +export function useForceUpdate() { + const [, dispatch] = React.useState(Object.create(null)); + return React.useCallback(() => { + dispatch(Object.create(null)); + }, []); +} + +export function createNamedContext( + name: string, + defaultValue: ContextValueType, +): React.Context { + const Ctx = React.createContext(defaultValue); + Ctx.displayName = name; + return Ctx; +} diff --git a/packages/react-utilities/src/descendants/index.ts b/packages/react-utilities/src/descendants/index.ts new file mode 100644 index 0000000000000..60e30dd70521c --- /dev/null +++ b/packages/react-utilities/src/descendants/index.ts @@ -0,0 +1 @@ +export * from './descendants'; diff --git a/packages/react-utilities/src/descendants/polyfills.ts b/packages/react-utilities/src/descendants/polyfills.ts new file mode 100644 index 0000000000000..ad66df3dba619 --- /dev/null +++ b/packages/react-utilities/src/descendants/polyfills.ts @@ -0,0 +1,63 @@ +/** + * The original source of the descendants uses ES2017 functionality + * https://github.com/reach/reach-ui/tree/develop/packages/descendants + * + * Since >ES2015 support is still unsure, putting some temporary polyfills inline for now + */ + +export function arrayFind(array: T[], predicate: (item: T) => boolean): T | undefined { + // eslint-disable-next-line eqeqeq + if (array == null) { + throw TypeError('array cannot be null or undefined'); + } + + // eslint-disable-next-line eqeqeq + if (predicate == null || typeof predicate !== 'function') { + throw TypeError('predicate must be a function'); + } + + let i = 0; + const len = array.length; + while (i < len) { + if (predicate(array[i])) { + return array[i]; + } + + i++; + } + + return undefined; +} + +export function arrayFindIndex(array: T[], predicate: (item: T) => boolean): number { + // eslint-disable-next-line eqeqeq + if (array == null) { + throw TypeError('array cannot be null or undefined'); + } + + // eslint-disable-next-line eqeqeq + if (predicate == null || typeof predicate !== 'function') { + throw TypeError('predicate must be a function'); + } + + let i = 0; + const len = array.length; + while (i < len) { + if (predicate(array[i])) { + return i; + } + + i++; + } + + return -1; +} + +export function arrayIncludes(array: T[], item: T): boolean { + return array.indexOf(item) !== -1; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function objectValues(obj: any): any[] { + return Object.keys(obj).map(key => obj[key]); +} diff --git a/packages/react-utilities/src/index.ts b/packages/react-utilities/src/index.ts index b0a797e705156..2e89de8a9cee3 100644 --- a/packages/react-utilities/src/index.ts +++ b/packages/react-utilities/src/index.ts @@ -1,3 +1,4 @@ export * from './compose/index'; +export * from './descendants/index'; export * from './hooks/index'; export * from './utils/index'; diff --git a/packages/react-utilities/tsconfig.json b/packages/react-utilities/tsconfig.json index beb1660276af8..667df769ed829 100644 --- a/packages/react-utilities/tsconfig.json +++ b/packages/react-utilities/tsconfig.json @@ -15,6 +15,7 @@ "moduleResolution": "node", "preserveConstEnums": true, "lib": ["es5", "dom"], + "skipLibCheck": true, "typeRoots": ["../../node_modules/@types", "../../typings"], "types": ["jest", "custom-global"] },