import { Accessibility, treeBehavior, TreeBehaviorProps } from '@fluentui/accessibility'; import { ComponentWithAs, getNextElement, useAutoControlled, useTelemetry, useUnhandledProps, getElementType, useAccessibility, useStyles, useFluentContext, } from '@fluentui/react-bindings'; import * as customPropTypes from '@fluentui/react-proptypes'; import * as _ from 'lodash'; import * as PropTypes from 'prop-types'; import * as React from 'react'; import { Ref } from '@fluentui/react-component-ref'; import { TreeItem, TreeItemProps } from './TreeItem'; import { TreeTitle, TreeTitleProps } from './TreeTitle'; import { childrenExist, commonPropTypes, createShorthandFactory, UIComponentProps, ChildrenComponentProps, rtlTextContainer, } from '../../utils'; import { ShorthandRenderFunction, ShorthandCollection, ComponentEventHandler, ObjectShorthandCollection, FluentComponentStaticProps, } from '../../types'; import { getAllSelectableChildrenId, isAllGroupChecked, hasSubtree, removeItemAtIndex, getSiblings, TreeContext, TreeRenderContextValue, processItemsForSelection, } from './utils'; export interface TreeProps extends UIComponentProps, ChildrenComponentProps { /** Accessibility behavior if overridden by the user. */ accessibility?: Accessibility; /** Ids of expanded items. */ activeItemIds?: string[]; /** Ids of selected items. */ selectedItemIds?: string[]; /** Initial activeItemIds value. */ defaultActiveItemIds?: string[]; /** Initial selectedItemIds value. */ defaultSelectedItemIds?: string[]; /** Only allow one subtree to be expanded at a time. */ exclusive?: boolean; /** Shorthand array of props for Tree. */ items?: ObjectShorthandCollection; /** * A custom render function for the title slot. * * @param Component - The computed component for this slot. * @param props - The computed props for this slot. * @param children - The computed children for this slot. */ renderItemTitle?: ShorthandRenderFunction; /** * Called when active item ids change. * @param event - React's original SyntheticEvent. * @param data - All props, with `activeItemIds` reflecting the new state. */ onActiveItemIdsChange?: ComponentEventHandler; /** * Called when tree item selection state is changed. * @param event - React's original SyntheticEvent. * @param data - All props, with `selectedItemIds` reflecting the new state. */ onSelectedItemIdsChange?: ComponentEventHandler; /** * Callback that provides rendered tree items to be used by react-virtualized for instance. * Acts as a render prop, with the rendered tree items being the re-used logic. * * @param renderedItem - The array of rendered items. * @returns The render prop result. */ renderedItems?: (renderedItems: React.ReactElement[]) => React.ReactNode; /** Whether or not tree items are selectable. */ selectable?: boolean; } export interface TreeItemForRenderProps { elementRef: React.RefObject; id: string; index: number; level: number; parent: string; siblings: ShorthandCollection; } export const treeClassName = 'ui-tree'; export type TreeStylesProps = never; function useStableProps

(props: P) { const stableProps = React.useRef

(props); React.useEffect(() => { stableProps.current = props; }); return stableProps; } const expandedItemsGenerator = (items: TreeProps['items'] | TreeItemProps['items'], acc = []) => _.reduce( items, (acc, item) => { if (item['expanded'] && acc.indexOf(item['id']) === -1) { acc.push(item['id']); } if (item['items']) { return expandedItemsGenerator(item['items'], acc); } return acc; }, acc, ); const iterateItems = (items: TreeProps['items'] | TreeItemProps['items'], acc = []): string[] => _.reduce( items, (acc, item) => { if (item['selected'] && acc.indexOf(item['id']) === -1) { acc.push(item['id']); } if (item['items']) { return iterateItems(item['items']); } return acc; }, acc, ); /** * A Tree displays data organised in tree hierarchy. * * @accessibility * Implements [ARIA TreeView](https://www.w3.org/TR/wai-aria-practices-1.1/#TreeView) design pattern. * @accessibilityIssues * [Treeview - JAWS doesn't narrate position for each tree item](https://github.com/FreedomScientific/VFO-standards-support/issues/338) * [Aria compliant trees are read as empty tables](https://bugs.chromium.org/p/chromium/issues/detail?id=1048770) */ export const Tree: ComponentWithAs<'div', TreeProps> & FluentComponentStaticProps & { Item: typeof TreeItem; Title: typeof TreeTitle; } = props => { const context = useFluentContext(); const { setStart, setEnd } = useTelemetry(Tree.displayName, context.telemetry); setStart(); const { exclusive, items, renderItemTitle, selectable, children, renderedItems, className, design, styles, variables, } = props; const stableProps = useStableProps(props); const ElementType = getElementType(props); const unhandledProps = useUnhandledProps(Tree.handledProps, props); const [activeItemIds, setActiveItemIdsState] = useAutoControlled({ defaultValue: props.defaultActiveItemIds, value: props.activeItemIds, initialValue: expandedItemsGenerator(items), }); const [selectedItemIds, setSelectedItemIdsState] = useAutoControlled({ defaultValue: props.defaultSelectedItemIds, value: props.selectedItemIds, initialValue: iterateItems(items), }); const getA11yProps = useAccessibility(props.accessibility, { debugName: Tree.displayName, rtl: context.rtl, }); const { classes } = useStyles(Tree.displayName, { className: treeClassName, mapPropsToInlineStyles: () => ({ className, design, styles, variables, }), rtl: context.rtl, }); const treeRef = React.useRef(); const itemsRef = React.useRef(new Map>()); const onFocusParent = React.useCallback( (parent: string) => { const parentRef = itemsRef.current.get(parent); if (!parentRef || !parentRef.current) { return; } parentRef.current.focus(); }, [itemsRef], ); const setSelectedItemIds = React.useCallback( (e: React.SyntheticEvent, updateSelectedItemIds: (currSelectedItemIds: string[]) => string[]) => { _.invoke(stableProps.current, 'onSelectedItemIdsChange', e, { ...stableProps.current, selectedItemIds: updateSelectedItemIds, }); setSelectedItemIdsState(updateSelectedItemIds); }, [stableProps, setSelectedItemIdsState], ); const setActiveItemIds = React.useCallback( (e: React.SyntheticEvent, updateActiveItemIds: (activeItemIds: string[]) => string[]) => { _.invoke(stableProps.current, 'onActiveItemIdsChange', e, { ...stableProps.current, activeItemIds: updateActiveItemIds, }); setActiveItemIdsState(updateActiveItemIds); }, [stableProps, setActiveItemIdsState], ); const expandItems = React.useCallback( (e: React.SyntheticEvent, treeItemProps: TreeItemProps) => { const { id } = treeItemProps; setActiveItemIds(e, currActiveItemIds => { const siblings = getSiblings(stableProps.current.items, id); const activeItemIdIndex = currActiveItemIds.indexOf(id); let nextActiveItemsIds = currActiveItemIds; if (activeItemIdIndex > -1) { nextActiveItemsIds = removeItemAtIndex(currActiveItemIds, activeItemIdIndex); } else { if (exclusive) { siblings.some(sibling => { const activeSiblingIdIndex = currActiveItemIds.indexOf(sibling['id']); if (activeSiblingIdIndex > -1) { nextActiveItemsIds = removeItemAtIndex(currActiveItemIds, activeSiblingIdIndex); return true; } return false; }); } nextActiveItemsIds = [...nextActiveItemsIds, id]; } return nextActiveItemsIds; }); }, [stableProps, setActiveItemIds, exclusive], ); const onTitleClick = React.useCallback( (e: React.SyntheticEvent, treeItemProps: TreeItemProps, executeSelection: boolean = false) => { const treeItemHasSubtree = hasSubtree(treeItemProps); if (!treeItemProps) { return; } if (treeItemHasSubtree && !executeSelection && e.target === e.currentTarget) { expandItems(e, treeItemProps); } if (treeItemProps.selectable) { // parent must be selectable and expanded in order to procced with selection, otherwise return if (treeItemHasSubtree && !(treeItemProps.selectableParent && treeItemProps.expanded)) { return; } // if the target is equal to currentTarget it means treeItem should be collapsed, not procced with selection if (treeItemHasSubtree && e.target === e.currentTarget && !executeSelection) { return; } setSelectedItemIds(e, currSelectedItemIds => processItemsForSelection(treeItemProps, currSelectedItemIds)); } }, [expandItems, setSelectedItemIds], ); const onFocusFirstChild = React.useCallback( (itemId: string) => { const currentElement = itemsRef.current.get(itemId); if (!currentElement || !currentElement.current) { return; } const elementToBeFocused = getNextElement(treeRef.current, currentElement.current); if (!elementToBeFocused) { return; } elementToBeFocused.focus(); }, [treeRef, itemsRef], ); const isActiveItem = React.useCallback( (id: string): boolean => { return activeItemIds.indexOf(id) > -1; }, [activeItemIds], ); const onSiblingsExpand = React.useCallback( (e: React.SyntheticEvent, treeItemProps: TreeItemProps) => { if (exclusive) { return; } const { id } = treeItemProps; const siblings = getSiblings(stableProps.current.items, id); setActiveItemIds(e, currActiveItemIds => { const nextActiveItemsIds = [...currActiveItemIds]; siblings.forEach(sibling => { if (hasSubtree(sibling) && !isActiveItem(sibling['id'])) { nextActiveItemsIds.push(sibling['id']); } }); if (hasSubtree(treeItemProps) && !isActiveItem(id)) { nextActiveItemsIds.push(id); } return nextActiveItemsIds; }); }, [exclusive, stableProps, isActiveItem, setActiveItemIds], ); const isIndeterminate = (item: TreeItemProps) => { if (!item.selectableParent || !item.items) { return false; } const selectableItemIds = getAllSelectableChildrenId(item.items as TreeItemProps[]); return ( !isAllGroupChecked(item.items as TreeItemProps[], selectedItemIds) && selectableItemIds.some(id => selectedItemIds.indexOf(id) > -1) ); }; const isSelectedItem = (item: TreeItemProps): boolean => { if (item.selectableParent && item.items) { return isAllGroupChecked(item.items as TreeItemProps[], selectedItemIds); } return selectedItemIds && selectedItemIds.indexOf(item.id) > -1; }; const contextValue: TreeRenderContextValue = React.useMemo( () => ({ onFocusParent, onSiblingsExpand, onFocusFirstChild, onTitleClick, }), [onFocusParent, onSiblingsExpand, onFocusFirstChild, onTitleClick], ); const renderContent = (): React.ReactElement[] => { if (!items) return null; const renderItems = (items: TreeItemProps[], level = 1, parent?: string): React.ReactElement[] => { return items.reduce((renderedItems: React.ReactElement[], item: TreeItemProps, index: number) => { const id = item.id; const isSubtree = hasSubtree(item); const isSubtreeExpanded = isSubtree && isActiveItem(id); const indeterminate = isIndeterminate(item); if (!itemsRef.current.has(id)) { itemsRef.current.set(id, React.createRef()); } const renderedItem = TreeItem.create(item, { defaultProps: () => getA11yProps('item', { expanded: isSubtreeExpanded, selected: isSelectedItem(item), selectable, renderItemTitle, id, key: id, parent, level, index: index + 1, // Used for aria-posinset and it's 1-based. contentRef: itemsRef.current.get(id), treeSize: items.length, indeterminate, onSiblingsExpand, }), }); return [ ...renderedItems, renderedItem, ...(isSubtreeExpanded ? renderItems(item.items as TreeItemProps[], level + 1, id) : ([] as any)), ]; }, []); }; const itemsRendered = renderItems(items as TreeItemProps[]); return itemsRendered; }; const element = ( {getA11yProps.unstable_wrapWithFocusZone( {childrenExist(children) ? children : renderedItems ? renderedItems(renderContent()) : renderContent()} , )} ); setEnd(); return element; }; Tree.displayName = 'Tree'; Tree.propTypes = { ...commonPropTypes.createCommon({ content: false, }), activeItemIds: customPropTypes.collectionShorthand, selectedItemIds: customPropTypes.collectionShorthand, defaultActiveItemIds: customPropTypes.collectionShorthand, defaultSelectedItemIds: customPropTypes.collectionShorthand, exclusive: PropTypes.bool, selectable: PropTypes.bool, items: customPropTypes.collectionObjectShorthand, onActiveItemIdsChange: PropTypes.func, onSelectedItemIdsChange: PropTypes.func, renderItemTitle: PropTypes.func, renderedItems: PropTypes.func, }; Tree.Item = TreeItem; Tree.Title = TreeTitle; Tree.defaultProps = { accessibility: treeBehavior, }; Tree.handledProps = Object.keys(Tree.propTypes) as any; Tree.create = createShorthandFactory({ Component: Tree, mappedArrayProp: 'items', });