File "index.js"

Full Path: /home/siazco/grocery.siazco.se/wp-content/plugins/better-wp-security/core/packages/components/src/tree/index.js
File size: 5.73 KB
MIME-type: text/x-java
Charset: utf-8

/**
 * External dependencies
 */
import { isArray } from 'lodash';
import scrollIntoView from 'scroll-into-view-if-needed';
import classnames from 'classnames';

/**
 * WordPress dependencies
 */
import { useMemo, useState, useRef } from '@wordpress/element';
import { DOWN, ENTER, LEFT, RIGHT, SPACE, UP } from '@wordpress/keycodes';
import { BaseControl } from '@wordpress/components';

/**
 * Internal dependencies
 */
import './style.scss';

export function walkTree( tree, apply, parent = undefined ) {
	for ( let i = 0; i < tree.length; i++ ) {
		const sigil = apply( tree[ i ], parent, i );

		if ( sigil === walkTree.skip ) {
			continue;
		}

		if ( sigil === walkTree.halt ) {
			return sigil;
		}

		if ( isArray( tree[ i ].children ) ) {
			if (
				walkTree.halt ===
				walkTree( tree[ i ].children, apply, tree[ i ] )
			) {
				return walkTree.halt;
			}
		}
	}
}

walkTree.halt = Symbol( 'halt' );
walkTree.skip = Symbol( 'skip' );

function findPrevious( tree, before, expandedIds ) {
	let previous;

	walkTree( tree, ( item ) => {
		if ( item.id === before ) {
			return walkTree.halt;
		}

		previous = item;

		if ( item.children !== false && ! expandedIds.includes( item.id ) ) {
			return walkTree.skip;
		}
	} );

	return previous;
}

function findNext( tree, after, expandedIds ) {
	let next,
		found = false;

	walkTree( tree, ( item ) => {
		next = item;

		if ( found ) {
			return walkTree.halt;
		}

		if ( item.id === after ) {
			found = true;
		}

		if ( item.children !== false && ! expandedIds.includes( item.id ) ) {
			return walkTree.skip;
		}
	} );

	return next;
}

export default function Tree( {
	id,
	tree,
	active,
	setActive,
	onActivate,
	onLoad,
	label,
	help,
	...props
} ) {
	const treeRef = useRef();
	const lookup = useMemo( () => {
		const map = {};

		walkTree( tree, ( item, parent, index ) => {
			map[ item.id ] = {
				item,
				index,
				parent: parent?.id,
			};
		} );

		return map;
	}, [ tree ] );

	const [ expandedIds, setExpandedIds ] = useState( [] );
	const [ loadingIds, setLoadingIds ] = useState( [] );
	const idBase = id + '__item__';

	const onToggle = async ( item ) => {
		if ( item.children === true && onLoad ) {
			setLoadingIds( ( ids ) => [ ...ids, item.id ] );
			await onLoad( item.id );
			setLoadingIds( ( ids ) =>
				ids.filter( ( maybeId ) => maybeId !== item.id )
			);
		}

		setExpandedIds( ( state ) => {
			const isExpanded = state.includes( item.id );

			return isExpanded
				? state.filter( ( maybe ) => maybe !== item.id )
				: [ ...state, item.id ];
		} );
	};
	const onKeyDown = async ( e ) => {
		if ( props.onKeyDown ) {
			props.onKeyDown( e );
		}

		const { keyCode } = e;

		if ( onActivate && [ ENTER, SPACE ].includes( keyCode ) ) {
			onActivate( active );
		}

		if ( ! [ UP, DOWN, RIGHT, LEFT ].includes( keyCode ) ) {
			return;
		}

		e.stopPropagation();
		e.preventDefault();

		const found = lookup[ active ];

		if ( ! found ) {
			setActive( tree[ 0 ].id );
			return;
		}

		const { item, parent } = found;

		let next;

		switch ( keyCode ) {
			case UP: {
				next = findPrevious( tree, item.id, expandedIds )?.id;
				break;
			}
			case DOWN: {
				next = findNext( tree, item.id, expandedIds )?.id;
				break;
			}
			case RIGHT:
				if ( item.children ) {
					if ( expandedIds.includes( item.id ) ) {
						next = item.children?.[ 0 ].id;
					} else {
						await onToggle( item );
					}
				}
				break;
			case LEFT:
				if ( item.children && expandedIds.includes( item.id ) ) {
					await onToggle( item );
				} else {
					next = parent;
				}
				break;
		}

		if ( next ) {
			setActive( next );

			if ( treeRef.current ) {
				const nextEl = treeRef.current.ownerDocument.getElementById(
					idBase + next
				);

				if ( nextEl.scrollIntoViewIfNeeded ) {
					nextEl.scrollIntoViewIfNeeded();
				} else {
					scrollIntoView( nextEl, {
						scrollMode: 'if-needed',
					} );
				}
			}
		}
	};

	return (
		<BaseControl help={ help } className="itsec-tree">
			<span
				className="components-base-control__label"
				id={ id + '__tree_label' }
			>
				{ label }
			</span>
			<ul
				ref={ treeRef }
				id={ id }
				role="tree"
				tabIndex={ 0 }
				onKeyDown={ onKeyDown }
				onFocus={ active ? undefined : () => setActive( tree[ 0 ].id ) }
				aria-activedescendant={ active ? idBase + active : undefined }
				aria-labelledby={ id + '__tree_label' }
				{ ...props }
			>
				{ tree.map( ( item ) => (
					<TreeItem
						key={ item.id }
						idBase={ idBase }
						active={ active }
						setActive={ setActive }
						expandedIds={ expandedIds }
						onToggle={ onToggle }
						loadingIds={ loadingIds }
						item={ item }
					/>
				) ) }
			</ul>
		</BaseControl>
	);
}

function TreeItem( props ) {
	const {
		idBase,
		item,
		expandedIds,
		loadingIds,
		onToggle,
		active,
		setActive,
	} = props;

	const hasChildren = !! item.children;
	const isExpanded = expandedIds.includes( item.id );
	const onClick = async () => {
		await onToggle( item );
		setActive( item.id );
	};

	// Disable reason: Keyboard interaction is handled by the Tree.
	return (
		<li
			id={ idBase + item.id }
			role="treeitem"
			aria-selected={ active === item.id ? 'true' : undefined }
			aria-expanded={ hasChildren ? isExpanded : undefined }
			className={ classnames( 'itsec-tree__item', {
				'itsec-tree__item--loading': loadingIds.includes( item.id ),
			} ) }
		>
			{ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */ }
			<span onClick={ onClick } aria-label={ item.label }>
				{ item.label }
			</span>

			{ hasChildren && item.children.length > 0 && (
				<ul role="group">
					{ item.children.map( ( child ) => (
						<TreeItem
							key={ child.id }
							{ ...props }
							item={ child }
						/>
					) ) }
				</ul>
			) }
		</li>
	);
}