UNPKG

@sanity/default-layout

Version:

The default layout components for Sanity

372 lines (349 loc) 19.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CommandListProvider = CommandListProvider; exports.useCommandList = useCommandList; var _throttle2 = _interopRequireDefault(require("lodash/throttle")); var _react = _interopRequireWildcard(require("react")); var _supportsTouch = require("../../utils/supportsTouch"); function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } /** * This provider adds the following: * - Keyboard navigation + events (↑ / ↓ / ENTER) to children with a specified container (`childContainerRef`) * - Focus redirection when clicking child elements * - Pointer blocking when navigating with arrow keys (to ensure that only one active state is visible at any given time) * - ARIA attributes to define a `combobox` header input that controls a separate `listbox` * * Requirements: * - All child items must have `data-index` attributes defined with their index in the list. This is to help with * interoperability with virtual lists (whilst preventing costly re-renders) * - You have to supply `childCount` which is the total number of list items. Again, this is specifically for virtual * list support. * - All child items have to use the supplied context functions (`onChildClick` etc) to ensure consistent behaviour * when clicking and hovering over items, as well as preventing unwanted focus. * - All elements (including the pointer overlay) must be defined and passed to this Provider. */ /** * @todo We should look to either create an dynamic pointer overlay in future, or create a custom list item component * with more control over dynamically applying (and removing) hover states. */var CommandListContext = /*#__PURE__*/(0, _react.createContext)(undefined); // Allowable tag names to pass through click events when selecting with the ENTER key var CLICKABLE_CHILD_TAGS = ['a', 'button']; /** * @internal */ function CommandListProvider(_ref) { var ariaChildrenLabel = _ref.ariaChildrenLabel, ariaHeaderLabel = _ref.ariaHeaderLabel, _ref$ariaMultiselecta = _ref.ariaMultiselectable, ariaMultiselectable = _ref$ariaMultiselecta === void 0 ? false : _ref$ariaMultiselecta, autoFocus = _ref.autoFocus, children = _ref.children, childContainerElement = _ref.childContainerElement, childCount = _ref.childCount, containerElement = _ref.containerElement, id = _ref.id, _ref$initialSelectedI = _ref.initialSelectedIndex, initialSelectedIndex = _ref$initialSelectedI === void 0 ? 0 : _ref$initialSelectedI, _ref$level = _ref.level, level = _ref$level === void 0 ? 0 : _ref$level, headerInputElement = _ref.headerInputElement, pointerOverlayElement = _ref.pointerOverlayElement, virtualList = _ref.virtualList; var selectedIndexRef = (0, _react.useRef)(-1); var virtualListScrollToIndexRef = (0, _react.useRef)(null); /** * Toggle pointer overlay element which will kill existing hover states */ var enableChildContainerPointerEvents = (0, _react.useCallback)(enabled => pointerOverlayElement === null || pointerOverlayElement === void 0 ? void 0 : pointerOverlayElement.setAttribute('data-enabled', (!enabled).toString()), [pointerOverlayElement]); var getChildDescendantId = (0, _react.useCallback)(index => { return "".concat(id, "-item-").concat(index); }, [id]); /** * Assign selected state on all child elements. */ var handleAssignSelectedState = (0, _react.useCallback)(scrollSelectedIntoView => { var selectedIndex = selectedIndexRef === null || selectedIndexRef === void 0 ? void 0 : selectedIndexRef.current; headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.setAttribute('aria-activedescendant', getChildDescendantId(selectedIndex)); var childElements = Array.from((childContainerElement === null || childContainerElement === void 0 ? void 0 : childContainerElement.children) || []); childElements === null || childElements === void 0 ? void 0 : childElements.forEach(child => { var _child$dataset; // Derive id from data-index attribute - especially relevant when dealing with virtual lists var childIndex = Number((_child$dataset = child.dataset) === null || _child$dataset === void 0 ? void 0 : _child$dataset.index); child.setAttribute('aria-posinset', (childIndex + 1).toString()); child.setAttribute('aria-setsize', childCount.toString()); child.setAttribute('data-active', (childIndex === selectedIndex).toString()); child.setAttribute('id', getChildDescendantId(childIndex)); child.setAttribute('role', 'option'); child.setAttribute('tabIndex', '-1'); }); /** * Scroll into view: delegate to `react-virtual` if a virtual list, otherwise use `scrollIntoView` */ if (scrollSelectedIntoView) { if (virtualList) { var _virtualListScrollToI; virtualListScrollToIndexRef === null || virtualListScrollToIndexRef === void 0 || (_virtualListScrollToI = virtualListScrollToIndexRef.current) === null || _virtualListScrollToI === void 0 ? void 0 : _virtualListScrollToI.call(virtualListScrollToIndexRef, selectedIndex, { align: 'start' }); } else { var selectedElement = childElements.find(element => { var _element$dataset; return Number((_element$dataset = element.dataset) === null || _element$dataset === void 0 ? void 0 : _element$dataset.index) === selectedIndex; }); selectedElement === null || selectedElement === void 0 ? void 0 : selectedElement.scrollIntoView({ block: 'nearest' }); } } }, [childContainerElement, childCount, getChildDescendantId, headerInputElement, virtualList]); /** * Throttled version of the above, used when DOM mutations are detected in virtual lists */ var handleReassignSelectedStateThrottled = (0, _react.useMemo)(() => (0, _throttle2.default)(handleAssignSelectedState.bind(undefined, false), 200), [handleAssignSelectedState]); /** * Mark an index as active, assign aria-selected state on all children and optionally scroll into view */ var setActiveIndex = (0, _react.useCallback)(_ref2 => { var index = _ref2.index, _ref2$scrollIntoView = _ref2.scrollIntoView, scrollIntoView = _ref2$scrollIntoView === void 0 ? true : _ref2$scrollIntoView; selectedIndexRef.current = index; handleAssignSelectedState(scrollIntoView); }, [handleAssignSelectedState]); /** * Prevent child items from receiving focus */ var handleChildMouseDown = (0, _react.useCallback)(event => { event.preventDefault(); }, []); /** * Always focus header input on child item click (non-touch only) */ var handleChildClick = (0, _react.useCallback)(() => { if (!_supportsTouch.supportsTouch) { headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.focus(); } }, [headerInputElement]); /** * Mark hovered child item as active */ var handleChildMouseEnter = (0, _react.useCallback)(index => { return function () { setActiveIndex({ index, scrollIntoView: false }); }; }, [setActiveIndex]); /** * Store virtual list's scrollToIndex function */ var handleSetVirtualListScrollToIndex = (0, _react.useCallback)(scrollToIndex => { virtualListScrollToIndexRef.current = scrollToIndex; }, []); var scrollToAdjacentItem = (0, _react.useCallback)(direction => { var nextIndex; if (direction === 'next') { nextIndex = selectedIndexRef.current < childCount - 1 ? selectedIndexRef.current + 1 : 0; } if (direction === 'previous') { nextIndex = selectedIndexRef.current > 0 ? selectedIndexRef.current - 1 : childCount - 1; } // Delegate scrolling to virtual list if necessary if (virtualList) { var _virtualListScrollToI2; virtualListScrollToIndexRef === null || virtualListScrollToIndexRef === void 0 || (_virtualListScrollToI2 = virtualListScrollToIndexRef.current) === null || _virtualListScrollToI2 === void 0 ? void 0 : _virtualListScrollToI2.call(virtualListScrollToIndexRef, nextIndex); setActiveIndex({ index: nextIndex, scrollIntoView: false }); } else { setActiveIndex({ index: nextIndex }); } enableChildContainerPointerEvents(false); }, [childCount, enableChildContainerPointerEvents, setActiveIndex, virtualList]); /** * Set active index whenever initial index changes */ (0, _react.useEffect)(() => { setActiveIndex({ index: initialSelectedIndex, scrollIntoView: true }); }, [initialSelectedIndex, setActiveIndex]); /** * Re-enable child pointer events on any mouse move event */ (0, _react.useEffect)(() => { function handleMouseMove() { enableChildContainerPointerEvents(true); } document.addEventListener('mousemove', handleMouseMove); return () => { document.removeEventListener('mousemove', handleMouseMove); }; }, [enableChildContainerPointerEvents]); /** * Listen to keyboard events on header input element */ (0, _react.useEffect)(() => { function handleKeyDown(event) { var childElements = Array.from((childContainerElement === null || childContainerElement === void 0 ? void 0 : childContainerElement.children) || []); if (!childElements.length) { return; } if (event.key === 'ArrowDown') { event.preventDefault(); scrollToAdjacentItem('next'); } if (event.key === 'ArrowUp') { event.preventDefault(); scrollToAdjacentItem('previous'); } if (event.key === 'Enter') { event.preventDefault(); var currentElement = childElements.find(el => Number(el.dataset.index) === selectedIndexRef.current); if (currentElement) { // Find the closest available clickable element - if not the current element, then query its children. var clickableElement = CLICKABLE_CHILD_TAGS.includes(currentElement.tagName.toLowerCase()) ? currentElement : currentElement.querySelector(CLICKABLE_CHILD_TAGS.join(',')); clickableElement === null || clickableElement === void 0 ? void 0 : clickableElement.click(); } } } headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.addEventListener('keydown', handleKeyDown); return () => { headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.removeEventListener('keydown', handleKeyDown); }; }, [childContainerElement, headerInputElement, scrollToAdjacentItem]); /** * Listen to keyboard arrow events on the 'closest' parent [data-overflow] element to the child container. * On arrow press: focus the header input element and then navigate accordingly. * * Done to account for when users focus a wrapping element with overflow (by dragging its scroll handle) * and then try navigate with the keyboard. */ (0, _react.useEffect)(() => { function handleKeydown(event) { if (event.key === 'ArrowDown') { event.preventDefault(); headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.focus(); scrollToAdjacentItem('next'); } if (event.key === 'ArrowUp') { event.preventDefault(); headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.focus(); scrollToAdjacentItem('previous'); } } var parentOverflowElement = childContainerElement === null || childContainerElement === void 0 ? void 0 : childContainerElement.closest('[data-overflow]'); parentOverflowElement === null || parentOverflowElement === void 0 ? void 0 : parentOverflowElement.addEventListener('keydown', handleKeydown); return () => { parentOverflowElement === null || parentOverflowElement === void 0 ? void 0 : parentOverflowElement.removeEventListener('keydown', handleKeydown); }; }, [childContainerElement, headerInputElement, scrollToAdjacentItem]); /** * Track focus / blur state on the list's input element and store state in `data-focused` attribute on * a separate container element. */ (0, _react.useEffect)(() => { function handleMarkContainerAsFocused(focused) { return () => containerElement === null || containerElement === void 0 ? void 0 : containerElement.setAttribute('data-focused', focused.toString()); } headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.addEventListener('blur', handleMarkContainerAsFocused(false)); headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.addEventListener('focus', handleMarkContainerAsFocused(true)); return () => { headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.removeEventListener('blur', handleMarkContainerAsFocused(false)); headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.removeEventListener('focus', handleMarkContainerAsFocused(true)); }; }, [containerElement, headerInputElement]); /** * Track mouse enter / leave state on child container and store state in `data-hovered` attribute on * a separate container element. */ (0, _react.useEffect)(() => { function handleMarkChildrenAsHovered(hovered) { return () => containerElement === null || containerElement === void 0 ? void 0 : containerElement.setAttribute('data-hovered', hovered.toString()); } childContainerElement === null || childContainerElement === void 0 ? void 0 : childContainerElement.addEventListener('mouseenter', handleMarkChildrenAsHovered(true)); childContainerElement === null || childContainerElement === void 0 ? void 0 : childContainerElement.addEventListener('mouseleave', handleMarkChildrenAsHovered(false)); return () => { childContainerElement === null || childContainerElement === void 0 ? void 0 : childContainerElement.removeEventListener('mouseenter', handleMarkChildrenAsHovered(true)); childContainerElement === null || childContainerElement === void 0 ? void 0 : childContainerElement.removeEventListener('mouseleave', handleMarkChildrenAsHovered(false)); }; }, [childContainerElement, containerElement]); /** * Temporarily disable pointer events (or 'flush' existing hover states) on child count changes. */ (0, _react.useEffect)(() => { enableChildContainerPointerEvents(false); }, [childCount, enableChildContainerPointerEvents]); /** * If this is a virtual list - re-assign aria-selected state on all child elements on any DOM mutations. * * Useful since virtual lists will constantly mutate the DOM on scroll, and we want to ensure that * new elements coming into view are rendered with the correct selected state. */ (0, _react.useEffect)(() => { if (!virtualList) { return undefined; } var mutationObserver = new MutationObserver(handleReassignSelectedStateThrottled); if (childContainerElement) { mutationObserver.observe(childContainerElement, { childList: true, subtree: true }); } return () => { mutationObserver.disconnect(); }; }, [childContainerElement, handleReassignSelectedStateThrottled, virtualList]); /** * Apply initial attributes */ (0, _react.useEffect)(() => { childContainerElement === null || childContainerElement === void 0 ? void 0 : childContainerElement.setAttribute('aria-multiselectable', ariaMultiselectable.toString()); childContainerElement === null || childContainerElement === void 0 ? void 0 : childContainerElement.setAttribute('aria-label', ariaChildrenLabel); childContainerElement === null || childContainerElement === void 0 ? void 0 : childContainerElement.setAttribute('id', "".concat(id, "-children")); childContainerElement === null || childContainerElement === void 0 ? void 0 : childContainerElement.setAttribute('role', 'listbox'); containerElement === null || containerElement === void 0 ? void 0 : containerElement.setAttribute('data-level', level.toString()); headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.setAttribute('aria-autocomplete', 'list'); headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.setAttribute('aria-expanded', 'true'); headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.setAttribute('aria-controls', "".concat(id, "-children")); headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.setAttribute('aria-label', ariaHeaderLabel); headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.setAttribute('role', 'combobox'); pointerOverlayElement === null || pointerOverlayElement === void 0 ? void 0 : pointerOverlayElement.setAttribute('data-enabled', 'true'); }, [ariaChildrenLabel, ariaHeaderLabel, ariaMultiselectable, childContainerElement, containerElement, headerInputElement, id, level, pointerOverlayElement]); /** * Focus header input on mount (non-touch only) */ (0, _react.useEffect)(() => { if (autoFocus) { if (!_supportsTouch.supportsTouch) { headerInputElement === null || headerInputElement === void 0 ? void 0 : headerInputElement.focus(); } } }, [autoFocus, headerInputElement]); return /*#__PURE__*/_react.default.createElement(CommandListContext.Provider, { value: { level, onChildClick: handleChildClick, onChildMouseDown: handleChildMouseDown, onChildMouseEnter: handleChildMouseEnter, setVirtualListScrollToIndex: handleSetVirtualListScrollToIndex } }, children); } function useCommandList() { var context = (0, _react.useContext)(CommandListContext); if (context === undefined) { throw new Error('useCommandList must be used within a CommandListProvider'); } return context; }