@sanity/default-layout
Version:
The default layout components for Sanity
372 lines (349 loc) • 19.3 kB
JavaScript
;
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;
}