UNPKG

@linzjs/step-ag-grid

Version:

[![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release) > Reusable [ag-grid](https://www.ag-grid.com/) component for LINZ / Toitū te whenua.

1,416 lines (1,339 loc) 265 kB
'use strict'; var jsxRuntime = require('react/jsx-runtime'); var lui = require('@linzjs/lui'); var agGridCommunity = require('ag-grid-community'); var agGridReact = require('ag-grid-react'); var lodashEs = require('lodash-es'); var React = require('react'); var reactDom = require('react-dom'); /** * If loading is true this returns a loading spinner, otherwise it returns its children. */ const ComponentLoadingWrapper = (props) => { return props.loading ? (jsxRuntime.jsx(lui.LuiMiniSpinner, { size: 22, divProps: { role: 'status', ['aria-label']: 'Loading', style: { padding: 16 } } })) : (jsxRuntime.jsxs("div", { style: { pointerEvents: props.saving ? 'none' : 'inherit' }, className: props.className, children: [props.saving && jsxRuntime.jsx("div", { className: 'ComponentLoadingWrapper-saveOverlay' }), props.children] })); }; function r(e){var t,f,n="";if("string"==typeof e||"number"==typeof e)n+=e;else if("object"==typeof e)if(Array.isArray(e)){var o=e.length;for(t=0;t<o;t++)e[t]&&(f=r(e[t]))&&(n&&(n+=" "),n+=f);}else for(f in e)e[f]&&(n&&(n+=" "),n+=f);return n}function clsx(){for(var e,t,f=0,n="",o=arguments.length;f<o;f++)(e=arguments[f])&&(t=r(e))&&(n&&(n+=" "),n+=t);return n} var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; function getDefaultExportFromCjs (x) { return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; } /** * lodash (Custom Build) <https://lodash.com/> * Build: `lodash modularize exports="npm" -o ./` * Copyright jQuery Foundation and other contributors <https://jquery.org/> * Released under MIT license <https://lodash.com/license> * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE> * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors */ var lodash_debounce; var hasRequiredLodash_debounce; function requireLodash_debounce () { if (hasRequiredLodash_debounce) return lodash_debounce; hasRequiredLodash_debounce = 1; /** Used as the `TypeError` message for "Functions" methods. */ var FUNC_ERROR_TEXT = 'Expected a function'; /** Used as references for various `Number` constants. */ var NAN = 0 / 0; /** `Object#toString` result references. */ var symbolTag = '[object Symbol]'; /** Used to match leading and trailing whitespace. */ var reTrim = /^\s+|\s+$/g; /** Used to detect bad signed hexadecimal string values. */ var reIsBadHex = /^[-+]0x[0-9a-f]+$/i; /** Used to detect binary string values. */ var reIsBinary = /^0b[01]+$/i; /** Used to detect octal string values. */ var reIsOctal = /^0o[0-7]+$/i; /** Built-in method references without a dependency on `root`. */ var freeParseInt = parseInt; /** Detect free variable `global` from Node.js. */ var freeGlobal = typeof commonjsGlobal == 'object' && commonjsGlobal && commonjsGlobal.Object === Object && commonjsGlobal; /** Detect free variable `self`. */ var freeSelf = typeof self == 'object' && self && self.Object === Object && self; /** Used as a reference to the global object. */ var root = freeGlobal || freeSelf || Function('return this')(); /** Used for built-in method references. */ var objectProto = Object.prototype; /** * Used to resolve the * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) * of values. */ var objectToString = objectProto.toString; /* Built-in method references for those with the same name as other `lodash` methods. */ var nativeMax = Math.max, nativeMin = Math.min; /** * Gets the timestamp of the number of milliseconds that have elapsed since * the Unix epoch (1 January 1970 00:00:00 UTC). * * @static * @memberOf _ * @since 2.4.0 * @category Date * @returns {number} Returns the timestamp. * @example * * _.defer(function(stamp) { * console.log(_.now() - stamp); * }, _.now()); * // => Logs the number of milliseconds it took for the deferred invocation. */ var now = function() { return root.Date.now(); }; /** * Creates a debounced function that delays invoking `func` until after `wait` * milliseconds have elapsed since the last time the debounced function was * invoked. The debounced function comes with a `cancel` method to cancel * delayed `func` invocations and a `flush` method to immediately invoke them. * Provide `options` to indicate whether `func` should be invoked on the * leading and/or trailing edge of the `wait` timeout. The `func` is invoked * with the last arguments provided to the debounced function. Subsequent * calls to the debounced function return the result of the last `func` * invocation. * * **Note:** If `leading` and `trailing` options are `true`, `func` is * invoked on the trailing edge of the timeout only if the debounced function * is invoked more than once during the `wait` timeout. * * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred * until to the next tick, similar to `setTimeout` with a timeout of `0`. * * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) * for details over the differences between `_.debounce` and `_.throttle`. * * @static * @memberOf _ * @since 0.1.0 * @category Function * @param {Function} func The function to debounce. * @param {number} [wait=0] The number of milliseconds to delay. * @param {Object} [options={}] The options object. * @param {boolean} [options.leading=false] * Specify invoking on the leading edge of the timeout. * @param {number} [options.maxWait] * The maximum time `func` is allowed to be delayed before it's invoked. * @param {boolean} [options.trailing=true] * Specify invoking on the trailing edge of the timeout. * @returns {Function} Returns the new debounced function. * @example * * // Avoid costly calculations while the window size is in flux. * jQuery(window).on('resize', _.debounce(calculateLayout, 150)); * * // Invoke `sendMail` when clicked, debouncing subsequent calls. * jQuery(element).on('click', _.debounce(sendMail, 300, { * 'leading': true, * 'trailing': false * })); * * // Ensure `batchLog` is invoked once after 1 second of debounced calls. * var debounced = _.debounce(batchLog, 250, { 'maxWait': 1000 }); * var source = new EventSource('/stream'); * jQuery(source).on('message', debounced); * * // Cancel the trailing debounced invocation. * jQuery(window).on('popstate', debounced.cancel); */ function debounce(func, wait, options) { var lastArgs, lastThis, maxWait, result, timerId, lastCallTime, lastInvokeTime = 0, leading = false, maxing = false, trailing = true; if (typeof func != 'function') { throw new TypeError(FUNC_ERROR_TEXT); } wait = toNumber(wait) || 0; if (isObject(options)) { leading = !!options.leading; maxing = 'maxWait' in options; maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait; trailing = 'trailing' in options ? !!options.trailing : trailing; } function invokeFunc(time) { var args = lastArgs, thisArg = lastThis; lastArgs = lastThis = undefined; lastInvokeTime = time; result = func.apply(thisArg, args); return result; } function leadingEdge(time) { // Reset any `maxWait` timer. lastInvokeTime = time; // Start the timer for the trailing edge. timerId = setTimeout(timerExpired, wait); // Invoke the leading edge. return leading ? invokeFunc(time) : result; } function remainingWait(time) { var timeSinceLastCall = time - lastCallTime, timeSinceLastInvoke = time - lastInvokeTime, result = wait - timeSinceLastCall; return maxing ? nativeMin(result, maxWait - timeSinceLastInvoke) : result; } function shouldInvoke(time) { var timeSinceLastCall = time - lastCallTime, timeSinceLastInvoke = time - lastInvokeTime; // Either this is the first call, activity has stopped and we're at the // trailing edge, the system time has gone backwards and we're treating // it as the trailing edge, or we've hit the `maxWait` limit. return (lastCallTime === undefined || (timeSinceLastCall >= wait) || (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait)); } function timerExpired() { var time = now(); if (shouldInvoke(time)) { return trailingEdge(time); } // Restart the timer. timerId = setTimeout(timerExpired, remainingWait(time)); } function trailingEdge(time) { timerId = undefined; // Only invoke if we have `lastArgs` which means `func` has been // debounced at least once. if (trailing && lastArgs) { return invokeFunc(time); } lastArgs = lastThis = undefined; return result; } function cancel() { if (timerId !== undefined) { clearTimeout(timerId); } lastInvokeTime = 0; lastArgs = lastCallTime = lastThis = timerId = undefined; } function flush() { return timerId === undefined ? result : trailingEdge(now()); } function debounced() { var time = now(), isInvoking = shouldInvoke(time); lastArgs = arguments; lastThis = this; lastCallTime = time; if (isInvoking) { if (timerId === undefined) { return leadingEdge(lastCallTime); } if (maxing) { // Handle invocations in a tight loop. timerId = setTimeout(timerExpired, wait); return invokeFunc(lastCallTime); } } if (timerId === undefined) { timerId = setTimeout(timerExpired, wait); } return result; } debounced.cancel = cancel; debounced.flush = flush; return debounced; } /** * Checks if `value` is the * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types) * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) * * @static * @memberOf _ * @since 0.1.0 * @category Lang * @param {*} value The value to check. * @returns {boolean} Returns `true` if `value` is an object, else `false`. * @example * * _.isObject({}); * // => true * * _.isObject([1, 2, 3]); * // => true * * _.isObject(_.noop); * // => true * * _.isObject(null); * // => false */ function isObject(value) { var type = typeof value; return !!value && (type == 'object' || type == 'function'); } /** * Checks if `value` is object-like. A value is object-like if it's not `null` * and has a `typeof` result of "object". * * @static * @memberOf _ * @since 4.0.0 * @category Lang * @param {*} value The value to check. * @returns {boolean} Returns `true` if `value` is object-like, else `false`. * @example * * _.isObjectLike({}); * // => true * * _.isObjectLike([1, 2, 3]); * // => true * * _.isObjectLike(_.noop); * // => false * * _.isObjectLike(null); * // => false */ function isObjectLike(value) { return !!value && typeof value == 'object'; } /** * Checks if `value` is classified as a `Symbol` primitive or object. * * @static * @memberOf _ * @since 4.0.0 * @category Lang * @param {*} value The value to check. * @returns {boolean} Returns `true` if `value` is a symbol, else `false`. * @example * * _.isSymbol(Symbol.iterator); * // => true * * _.isSymbol('abc'); * // => false */ function isSymbol(value) { return typeof value == 'symbol' || (isObjectLike(value) && objectToString.call(value) == symbolTag); } /** * Converts `value` to a number. * * @static * @memberOf _ * @since 4.0.0 * @category Lang * @param {*} value The value to process. * @returns {number} Returns the number. * @example * * _.toNumber(3.2); * // => 3.2 * * _.toNumber(Number.MIN_VALUE); * // => 5e-324 * * _.toNumber(Infinity); * // => Infinity * * _.toNumber('3.2'); * // => 3.2 */ function toNumber(value) { if (typeof value == 'number') { return value; } if (isSymbol(value)) { return NAN; } if (isObject(value)) { var other = typeof value.valueOf == 'function' ? value.valueOf() : value; value = isObject(other) ? (other + '') : other; } if (typeof value != 'string') { return value === 0 ? value : +value; } value = value.replace(reTrim, ''); var isBinary = reIsBinary.test(value); return (isBinary || reIsOctal.test(value)) ? freeParseInt(value.slice(2), isBinary ? 2 : 8) : (reIsBadHex.test(value) ? NAN : +value); } lodash_debounce = debounce; return lodash_debounce; } requireLodash_debounce(); var useIsomorphicLayoutEffect$1 = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect; function useInterval(callback, delay) { const savedCallback = React.useRef(callback); useIsomorphicLayoutEffect$1(() => { savedCallback.current = callback; }, [callback]); React.useEffect(() => { const id = setInterval(() => { savedCallback.current(); }, delay); return () => { clearInterval(id); }; }, [delay]); } const GridContext = React.createContext({ gridReady: false, gridRenderState: () => null, getColDef: () => { console.error('no context provider for getColDef'); return undefined; }, getColumns: () => { console.error('no context provider for getColumns'); return []; }, getColumnIds: () => { console.error('no context provider for getColumnIds'); return []; }, invisibleColumnIds: undefined, setInvisibleColumnIds: () => { console.error('no context provider for setInvisibleColumnIds'); }, prePopupOps: () => { console.error('no context provider for prePopupOps'); }, postPopupOps: () => { console.error('no context provider for prePopupOps'); }, externallySelectedItemsAreInSync: false, setApis: () => { console.error('no context provider for setApis'); }, setQuickFilter: () => { console.error('no context provider for setQuickFilter'); }, selectRowsById: () => { console.error('no context provider for selectRows'); }, getSelectedRows: () => { console.error('no context provider for getSelectedRows'); return []; }, getFilteredSelectedRows: () => { console.error('no context provider for getFilteredSelectedRows'); return []; }, getSelectedRowIds: () => { console.error('no context provider for getSelectedRowIds'); return []; }, getFilteredSelectedRowIds: () => { console.error('no context provider for getFilteredSelectedRowIds'); return []; }, // eslint-disable-next-line @typescript-eslint/require-await selectRowsDiff: async () => { console.error('no context provider for selectRowsDiff'); }, selectRowsByIdWithFlash: () => { console.error('no context provider for selectRowsWithFlash'); }, // eslint-disable-next-line @typescript-eslint/require-await selectRowsWithFlashDiff: async () => { console.error('no context provider for selectRowsWithFlashDiff'); }, flashRows: () => { console.error('no context provider for flashRows'); }, // eslint-disable-next-line @typescript-eslint/require-await flashRowsDiff: async () => { console.error('no context provider for flashRows'); }, // eslint-disable-next-line @typescript-eslint/require-await,@typescript-eslint/no-misused-promises focusByRowById: async () => { console.error('no context provider for focusByRowById'); }, ensureRowVisible: () => { console.error('no context provider for ensureRowVisible'); return true; }, ensureSelectedRowIsVisible: () => { console.error('no context provider for ensureSelectedRowIsVisible'); }, getFirstRowId: () => { console.error('no context provider for getFirstRowId'); return -1; }, autoSizeColumns: () => { console.error('no context provider for autoSizeColumns'); return null; }, sizeColumnsToFit: () => { console.error('no context provider for autoSizeAllColumns'); return null; }, editingCells: () => { console.error('no context provider for editingCells'); return false; }, cancelEdit: () => { console.error('no context provider for cancelEdit'); }, // eslint-disable-next-line @typescript-eslint/require-await startCellEditing: async () => { console.error('no context provider for startCellEditing'); }, stopEditing: () => { console.error('no context provider for stopEditing'); }, // eslint-disable-next-line @typescript-eslint/require-await updatingCells: async () => { console.error('no context provider for modifyUpdating'); return false; }, redrawRows: () => { console.error('no context provider for redrawRows'); }, setExternallySelectedItemsAreInSync: () => { console.error('no context provider for setExternallySelectedItemsAreInSync'); }, // eslint-disable-next-line @typescript-eslint/require-await waitForExternallySelectedItemsToBeInSync: async () => { console.error('no context provider for waitForExternallySelectedItemsToBeInSync'); }, addExternalFilter: () => { console.error('no context provider for addExternalFilter'); }, removeExternalFilter: () => { console.error('no context provider for removeExternalFilter'); }, isExternalFilterPresent: () => { console.error('no context provider for isExternalFilterPresent'); return false; }, doesExternalFilterPass: () => { console.error('no context provider for doesExternalFilterPass'); return true; }, downloadCsv: () => { console.error('no context provider for downloadCsv'); }, setOnCellEditingComplete: () => { console.error('no context provider for setOnCellEditingComplete'); }, showNoRowsOverlay: () => { console.error('no context provider for showLoadingOverlay'); }, }); const useGridContext = () => React.useContext(GridContext); const GridUpdatingContext = React.createContext({ checkUpdating: () => { console.error('Missing GridUpdatingContext'); return false; }, updatingCols: () => { console.error('Missing GridUpdatingContext'); return []; }, // eslint-disable-next-line @typescript-eslint/require-await modifyUpdating: async () => { console.error('Missing GridUpdatingContext'); }, updatedDep: 0, }); const isNotEmpty = lodashEs.negate(lodashEs.isEmpty); const wait = (timeoutMs) => new Promise((resolve) => { setTimeout(resolve, timeoutMs); }); // This regexp only works if you parseFloat first, it won't validate a float on its own // It prevents scientific 1e10, or trailing decimal 1.2.3, or trailing garbage 1.2xyz const isFloatRegExp = /^-?\d*\.?\d*$/; const isFloat = (value) => { try { // Just checking it's not scientific notation or "NaN" here. // Also parse float will parse up to the first invalid character, // so we need to check there's no remaining invalids e.g. "1.2xyz" would parse as 1.2 return !Number.isNaN(parseFloat(value)) && isFloatRegExp.test(value); } catch { return false; } }; const findParentWithClass = function (className, child) { for (let node = child; node; node = node.parentNode) { // When nodes are in portals they aren't type node anymore hence treating it as any here if (node.classList && node.classList.contains(className)) { return node; } } return null; }; const hasParentClass = function (className, child) { for (let node = child; node; node = node.parentNode) { // When nodes are in portals they aren't type node anymore hence treating it as any here if (node.classList && node.classList.contains(className)) { return true; } } return false; }; const stringByteLengthIsInvalid = (str, maxBytes) => new TextEncoder().encode(str).length > maxBytes; const fnOrVar = (fn, param) => (typeof fn === 'function' ? fn(param) : fn); const sanitiseFileName = (filename) => { const valid = filename .trim() .replaceAll(/(\/|\\)+/g, '-') .replaceAll(/\s+/g, '_') .replaceAll(/[^\w\-āēīōūĀĒĪŌŪ.]/g, ''); const parts = valid.split('.'); const fileExt = parts.length > 1 ? parts.pop() : undefined; // Arbitrary max filename length of 64 chars + extension if (!fileExt) return valid.slice().slice(0, 64); return valid.slice(0, -fileExt.length - 1).slice(0, 64) + '.' + fileExt; }; /** * AgGrid checkbox select does not pass clicks within cell but not on the checkbox to checkbox. * This passes the event to the checkbox when you click anywhere in the cell. */ const clickInputWhenContainingCellClicked = (params) => { const { data, event, colDef } = params; if (!data || !event) return; const element = event.target; // Already handled if (element.closest('.GridCell-readonly') || (['BUTTON', 'INPUT'].includes(element?.tagName) && element.closest('.ag-cell-inline-editing'))) { return; } const row = element.closest('[row-id]'); if (!row) return; const colId = colDef.colId; if (!colId) return; const clickInput = () => { const cell = row.querySelector(`[col-id='${colId}']`); if (!cell) return; const input = cell.querySelector('input, button'); if (!input) { return; } input.dispatchEvent(event); }; setTimeout(clickInput, 20); }; /** * AgGrid's existing select header doesn't work the way we want. * If you have partial select then clicking the header checkbox will select all, * but we want to deselect all on partial select. */ const GridHeaderSelect = ({ api }) => { // This is used to force an update on selection change const [updateCounter, setUpdateCounter] = React.useState(0); const selectedNodeCount = api.getSelectedRows().length; React.useEffect(() => { const clickHandler = () => { setUpdateCounter(updateCounter + 1); }; api.addEventListener('selectionChanged', clickHandler); return () => { !api.isDestroyed() && api.removeEventListener('selectionChanged', clickHandler); }; }, [api, updateCounter]); const handleMultiSelect = () => { if (selectedNodeCount == 0) { api.selectAll('filtered'); } else { api.deselectAll(); } }; const totalNodeCount = api.getDisplayedRowCount(); const partialSelect = selectedNodeCount != 0 && selectedNodeCount != totalNodeCount; const allSelected = selectedNodeCount != 0 && selectedNodeCount == totalNodeCount; return (jsxRuntime.jsx("div", { className: clsx('ag-wrapper ag-input-wrapper ag-checkbox-input-wrapper', partialSelect && 'ag-indeterminate', allSelected && 'ag-checked'), onClick: handleMultiSelect, children: jsxRuntime.jsx("input", { type: "checkbox", className: 'ag-checkbox-input-wrapper', onChange: () => { /* do nothing */ } }) })); }; const EventHandlersContext = React.createContext({ handleClick: () => { }, }); const ItemSettingsContext = React.createContext({ submenuOpenDelay: 0, submenuCloseDelay: 0, }); // FIXME hacking a default context in here is probably bad, but the context is mess const SettingsContext = React.createContext({}); // Generate className following BEM methodology: http://getbem.com/naming/ // Modifier value can be one of the following types: boolean, string, undefined const useBEM = ({ block, element, modifiers, className }) => React.useMemo(() => { const blockElement = element ? `${block}__${element}` : block; let classString = blockElement; modifiers && Object.keys(modifiers).forEach((name) => { const value = modifiers[name]; if (value) classString += ` ${blockElement}--${value === true ? name : `${name}-${value}`}`; }); let expandedClassName = typeof className === 'function' ? className(modifiers) : className; if (typeof expandedClassName === 'string') { expandedClassName = expandedClassName.trim(); if (expandedClassName) classString += ` ${expandedClassName}`; } return classString; }, [block, element, modifiers, className]); // Adapted from material-ui // https://github.com/mui-org/material-ui/blob/f996027d00e7e4bff3fc040786c1706f9c6c3f82/packages/material-ui-utils/src/useForkRef.ts const setRef = (ref, instance) => { if (typeof ref === 'function') { ref(instance); } else if (ref) { ref.current = instance; } }; const useCombinedRef = (refA, refB) => { return React.useMemo(() => { return (instance) => { setRef(refA, instance); setRef(refB, instance); }; }, [refA, refB]); }; // Get around a warning when using useLayoutEffect on the server. // https://github.com/reduxjs/react-redux/blob/b48d087d76f666e1c6c5a9713bbec112a1631841/src/utils/useIsomorphicLayoutEffect.js#L12 // https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85 // https://github.com/facebook/react/issues/14927#issuecomment-549457471 const useIsomorphicLayoutEffect = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined' ? React.useLayoutEffect : React.useEffect; const useItemEffect = (isDisabled, menuItemRef, updateItems) => { useIsomorphicLayoutEffect(() => { if (!menuItemRef) return; if (process.env.NODE_ENV !== 'production' && !updateItems) { throw new Error(`[React-Menu] This menu item or submenu should be rendered under a menu: ${menuItemRef.current.outerHTML}`); } if (isDisabled) return; const item = menuItemRef.current; updateItems(item, true); return () => { updateItems(item); }; }, [isDisabled, menuItemRef, updateItems]); }; const menuContainerClass = 'szh-menu-container'; const menuClass = 'szh-menu'; const menuButtonClass = 'szh-menu-button'; const menuArrowClass = 'arrow'; const menuItemClass = 'item'; const menuDividerClass = 'divider'; const menuHeaderClass = 'header'; const menuGroupClass = 'group'; const subMenuClass = 'submenu'; const radioGroupClass = 'radio-group'; const Keys = Object.freeze({ ENTER: 'Enter', TAB: 'Tab', ESC: 'Escape', SPACE: ' ', HOME: 'Home', END: 'End', LEFT: 'ArrowLeft', RIGHT: 'ArrowRight', UP: 'ArrowUp', DOWN: 'ArrowDown', }); const HoverActionTypes = Object.freeze({ RESET: 0, SET: 1, UNSET: 2, INCREASE: 3, DECREASE: 4, FIRST: 5, LAST: 6, SET_INDEX: 7, }); const CloseReason = Object.freeze({ CLICK: 'click', CANCEL: 'cancel', BLUR: 'blur', SCROLL: 'scroll', TAB_FORWARD: 'tab_forward', TAB_BACKWARD: 'tab_backward', }); const FocusPositions = Object.freeze({ FIRST: 'first', LAST: 'last', }); const MenuStateMap = Object.freeze({ entering: 'opening', entered: 'open', exiting: 'closing', exited: 'closed', }); const isMenuOpen = (state) => !!state && state[0] === 'o'; const batchedUpdates = reactDom.unstable_batchedUpdates || ((callback) => callback()); const floatEqual = (a, b, diff = 0.0001) => Math.abs(a - b) < diff; const getTransition = (transition, name) => transition === true || !!(transition && transition[name]); function safeCall(fn, arg) { return typeof fn === 'function' ? fn(arg) : fn; } const internalKey = '_szhsinMenu'; const getName = (component) => component[internalKey]; const mergeProps = (target, source) => { source && Object.keys(source).forEach((key) => { const targetProp = target[key]; const sourceProp = source[key]; if (typeof sourceProp === 'function' && targetProp) { target[key] = (...arg) => { sourceProp(...arg); targetProp(...arg); }; } else { target[key] = sourceProp; } }); return target; }; const parsePadding = (paddingStr) => { if (paddingStr == null) return { top: 0, right: 0, bottom: 0, left: 0 }; const padding = paddingStr.trim().split(/\s+/, 4).map(parseFloat); const top = !isNaN(padding[0]) ? padding[0] : 0; const right = !isNaN(padding[1]) ? padding[1] : top; return { top, right, bottom: !isNaN(padding[2]) ? padding[2] : top, left: !isNaN(padding[3]) ? padding[3] : right, }; }; // Adapted from https://github.com/popperjs/popper-core/tree/v2.9.1/src/dom-utils const getScrollAncestor = (node) => { const thisWindow = (node?.ownerDocument ?? document).defaultView ?? window; while (node) { node = node.parentNode; if (!node || node === thisWindow?.document?.body) return null; if (node instanceof Element) { const { overflow, overflowX, overflowY } = thisWindow.getComputedStyle(node); if (/auto|scroll|overlay|hidden/.test(overflow + overflowY + overflowX)) return node; } } return null; }; function commonProps(isDisabled, isHovering) { return { 'aria-disabled': isDisabled || undefined, tabIndex: isHovering ? 0 : -1, }; } const indexOfNode = (nodeList, node) => lodashEs.findIndex(nodeList, node); const focusFirstInput = (container) => { // We can't use instanceof Element in portals, so I use querySelectorAll as a proxy here if (!container || !('querySelectorAll' in container)) return false; const inputs = container.querySelectorAll("input[type='text'],input:not([type]),textarea"); const input = inputs[0]; // Using focus as proxy for HTMLElement if (!input || !('focus' in input)) return false; input.focus(); // Text areas should start at end // this is a proxy for instanceof HTMLTextAreaElement if (['textarea', 'text'].includes(input.type)) { input.setSelectionRange(0, input.value.length); } return true; }; const HoverItemContext = React.createContext(undefined); const withHovering = (name, WrappedComponent) => { const Component = React.memo(WrappedComponent); const WithHovering = React.forwardRef((props, ref) => { const menuItemRef = React.useRef(null); return (jsxRuntime.jsx(jsxRuntime.Fragment, { children: jsxRuntime.jsx(Component, { ...props, menuItemRef: menuItemRef, externalRef: ref, isHovering: React.useContext(HoverItemContext) === menuItemRef.current }) })); }); WithHovering.displayName = `WithHovering(${name})`; return WithHovering; }; const useItems = (menuRef, focusRef) => { const [hoverItem, setHoverItem] = React.useState(); const stateRef = React.useRef({ items: [], hoverIndex: -1, sorted: false, }); const mutableState = stateRef.current; const updateItems = React.useCallback((item, isMounted) => { const { items } = mutableState; if (!item) { mutableState.items = []; } else if (isMounted) { items.push(item); } else { const index = items.indexOf(item); if (index > -1) { items.splice(index, 1); if (item.contains(document.activeElement)) { focusRef?.current?.focus(); setHoverItem(undefined); } } } mutableState.hoverIndex = -1; mutableState.sorted = false; }, [mutableState, focusRef]); const dispatch = React.useCallback((actionType, item, nextIndex) => { const { items, hoverIndex } = mutableState; const sortItems = () => { if (mutableState.sorted) return; const orderedNodes = menuRef.current.querySelectorAll('.szh-menu__item'); items.sort((a, b) => indexOfNode(orderedNodes, a) - indexOfNode(orderedNodes, b)); mutableState.sorted = true; }; let index = -1; let newItem = undefined; let newItemFn = undefined; switch (actionType) { case HoverActionTypes.RESET: break; case HoverActionTypes.SET: newItem = item; break; case HoverActionTypes.UNSET: newItemFn = (prevItem) => (prevItem === item ? undefined : prevItem); break; case HoverActionTypes.FIRST: sortItems(); index = 0; newItem = items[index]; break; case HoverActionTypes.LAST: sortItems(); index = items.length - 1; newItem = items[index]; break; case HoverActionTypes.SET_INDEX: if (typeof nextIndex !== 'number') break; sortItems(); index = nextIndex; newItem = items[index]; lodashEs.defer(() => newItem.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })); break; case HoverActionTypes.INCREASE: sortItems(); index = hoverIndex; if (index < 0) index = items.indexOf(item); index++; if (index >= items.length) index = 0; newItem = items[index]; focusFirstInput(newItem); break; case HoverActionTypes.DECREASE: { sortItems(); index = hoverIndex; if (index < 0) index = items.indexOf(item); index--; if (index < 0) index = items.length - 1; newItem = items[index]; focusFirstInput(newItem); break; } default: if (process.env.NODE_ENV !== 'production') throw new Error(`[React-Menu] Unknown hover action type: ${actionType}`); } if (!newItem && !newItemFn) index = -1; setHoverItem(newItem ?? newItemFn); mutableState.hoverIndex = index; }, [menuRef, mutableState]); return { hoverItem, dispatch, updateItems }; }; const MenuListItemContext = React.createContext({ dispatch: () => { }, updateItems: () => { }, setOpenSubmenuCount: () => 0, }); // This hook includes some common stateful logic in MenuItem and FocusableItem const useItemState = (menuItemRef, focusRef, isHovering, isDisabled) => { const { submenuCloseDelay } = React.useContext(ItemSettingsContext); const { isParentOpen, isSubmenuOpen, dispatch, updateItems } = React.useContext(MenuListItemContext); const timeoutId = React.useRef(); const setHover = () => { !isHovering && !isDisabled && dispatch(HoverActionTypes.SET, menuItemRef?.current, 0); }; const unsetHover = () => { !isDisabled && dispatch(HoverActionTypes.UNSET, menuItemRef?.current, 0); }; const onBlur = (e) => { // Focus has moved out of the entire item // It handles situation such as clicking on a sibling disabled menu item if (isHovering && !e.currentTarget.contains(e.relatedTarget)) unsetHover(); }; const onPointerMove = () => { if (isSubmenuOpen) { if (!timeoutId.current) timeoutId.current = setTimeout(() => { timeoutId.current = undefined; setHover(); }, submenuCloseDelay); } else { setHover(); } }; const onPointerLeave = (_, keepHover) => { if (timeoutId.current) { clearTimeout(timeoutId.current); timeoutId.current = undefined; } !keepHover && unsetHover(); }; useItemEffect(isDisabled, menuItemRef, updateItems); React.useEffect(() => () => clearTimeout(timeoutId.current), []); React.useEffect(() => { // Don't set focus when parent menu is closed, otherwise focus will be lost // and onBlur event will be fired with relatedTarget setting as null. if (isHovering && isParentOpen) { focusRef?.current && focusRef.current.focus(); } }, [focusRef, isHovering, isParentOpen]); return { setHover, onBlur, onPointerMove, onPointerLeave, }; }; const useMenuChange = (onMenuChange, isOpen) => { const prevOpen = React.useRef(isOpen); React.useEffect(() => { if (onMenuChange && prevOpen.current !== isOpen) safeCall(onMenuChange, { open: !!isOpen }); prevOpen.current = isOpen; }, [onMenuChange, isOpen]); }; const PRE_ENTER = 0; const ENTERING = 1; const ENTERED = 2; const PRE_EXIT = 3; const EXITING = 4; const EXITED = 5; const UNMOUNTED = 6; const STATUS = ['preEnter', 'entering', 'entered', 'preExit', 'exiting', 'exited', 'unmounted']; const getState = status => ({ _s: status, status: STATUS[status], isEnter: status < PRE_EXIT, isMounted: status !== UNMOUNTED, isResolved: status === ENTERED || status > EXITING }); const startOrEnd = unmounted => unmounted ? UNMOUNTED : EXITED; const getEndStatus = (status, unmountOnExit) => { switch (status) { case ENTERING: case PRE_ENTER: return ENTERED; case EXITING: case PRE_EXIT: return startOrEnd(unmountOnExit); } }; const getTimeout = timeout => typeof timeout === 'object' ? [timeout.enter, timeout.exit] : [timeout, timeout]; const nextTick = (transitState, status) => setTimeout(() => { // Reading document.body.offsetTop can force browser to repaint before transition to the next state isNaN(document.body.offsetTop) || transitState(status + 1); }, 0); const updateState = (status, setState, latestState, timeoutId, onChange) => { clearTimeout(timeoutId.current); const state = getState(status); setState(state); latestState.current = state; onChange && onChange({ current: state }); }; const useTransitionState = ({ enter = true, exit = true, preEnter, preExit, timeout, initialEntered, mountOnEnter, unmountOnExit, onStateChange: onChange } = {}) => { const [state, setState] = React.useState(() => getState(initialEntered ? ENTERED : startOrEnd(mountOnEnter))); const latestState = React.useRef(state); const timeoutId = React.useRef(); const [enterTimeout, exitTimeout] = getTimeout(timeout); const endTransition = React.useCallback(() => { const status = getEndStatus(latestState.current._s, unmountOnExit); status && updateState(status, setState, latestState, timeoutId, onChange); }, [onChange, unmountOnExit]); const toggle = React.useCallback(toEnter => { const transitState = status => { updateState(status, setState, latestState, timeoutId, onChange); switch (status) { case ENTERING: if (enterTimeout >= 0) timeoutId.current = setTimeout(endTransition, enterTimeout); break; case EXITING: if (exitTimeout >= 0) timeoutId.current = setTimeout(endTransition, exitTimeout); break; case PRE_ENTER: case PRE_EXIT: timeoutId.current = nextTick(transitState, status); break; } }; const enterStage = latestState.current.isEnter; if (typeof toEnter !== 'boolean') toEnter = !enterStage; if (toEnter) { !enterStage && transitState(enter ? preEnter ? PRE_ENTER : ENTERING : ENTERED); } else { enterStage && transitState(exit ? preExit ? PRE_EXIT : EXITING : startOrEnd(unmountOnExit)); } }, [endTransition, onChange, enter, exit, preEnter, preExit, enterTimeout, exitTimeout, unmountOnExit]); return [state, toggle, endTransition]; }; /** * A custom Hook which helps manage the states of `ControlledMenu`. */ const useMenuState = (props) => { const { initialMounted, unmountOnClose, transition, transitionTimeout } = props ?? { transition: false, }; const [state, toggleMenu, endTransition] = useTransitionState({ mountOnEnter: !initialMounted, unmountOnExit: unmountOnClose, timeout: transitionTimeout ?? 500, enter: getTransition(transition, 'open'), exit: getTransition(transition, 'close'), }); return [{ state: MenuStateMap[state.status], endTransition }, toggleMenu]; }; const useMenuStateAndFocus = (options) => { const [menuProps, toggleMenu] = useMenuState(options); const [menuItemFocus, setMenuItemFocus] = React.useState(); const openMenu = (position, alwaysUpdate) => { setMenuItemFocus({ position, alwaysUpdate }); toggleMenu(true); }; return [{ menuItemFocus, ...menuProps }, toggleMenu, openMenu]; }; const MenuListContext = React.createContext({}); const getPositionHelpers = (containerRef, menuRef, menuScroll, boundingBoxPadding) => { const menuRect = menuRef.current.getBoundingClientRect(); const thisWindow = containerRef.current?.ownerDocument.defaultView ?? window; const containerRect = (containerRef.current ?? thisWindow.document.body).getBoundingClientRect(); const boundingRect = menuScroll === window || menuScroll === thisWindow ? { left: 0, top: 0, right: thisWindow.document.documentElement.clientWidth, bottom: thisWindow.innerHeight, } : menuScroll.getBoundingClientRect(); const padding = parsePadding(boundingBoxPadding); // For left and top, overflows are negative value. // For right and bottom, overflows are positive value. const getLeftOverflow = (x) => x + containerRect.left - boundingRect.left - padding.left; const getRightOverflow = (x) => x + containerRect.left + menuRect.width - boundingRect.right + padding.right; const getTopOverflow = (y) => y + containerRect.top - boundingRect.top - padding.top; const getBottomOverflow = (y) => y + containerRect.top + menuRect.height - boundingRect.bottom + padding.bottom; const confineHorizontally = (x) => { // If menu overflows to the left side, adjust x to have the menu contained within the viewport // and there is no need to check the right side; // if it doesn't overflow to the left, then check the right side let leftOverflow = getLeftOverflow(x); if (leftOverflow < 0) { x -= leftOverflow; } else { const rightOverflow = getRightOverflow(x); if (rightOverflow > 0) { x -= rightOverflow; // Check again to make sure menu doesn't overflow to the left // because it may go off-screen and cannot be scrolled into view. leftOverflow = getLeftOverflow(x); if (leftOverflow < 0) x -= leftOverflow; } } return x; }; const confineVertically = (y) => { // Similar logic to confineHorizontally above let topOverflow = getTopOverflow(y); if (topOverflow < 0) { y -= topOverflow; } else { const bottomOverflow = getBottomOverflow(y); if (bottomOverflow > 0) { y -= bottomOverflow; // Check again to make sure menu doesn't overflow to the bottom // because it may go off screen and cannot be scroll into view. topOverflow = getTopOverflow(y); if (topOverflow < 0) y -= topOverflow; } } return y; }; return { menuRect, containerRect, getLeftOverflow, getRightOverflow, getTopOverflow, getBottomOverflow, confineHorizontally, confineVertically, }; }; const positionContextMenu = ({ positionHelpers, anchorPoint, }) => { const { menuRect, containerRect, getLeftOverflow, getRightOverflow, getTopOverflow, getBottomOverflow, confineHorizontally, confineVertically, } = positionHelpers; let x, y; // position the menu with cursor pointing to its top-left corner x = anchorPoint.x - containerRect.left; y = anchorPoint.y - containerRect.top; // If menu overflows to the right of viewport, // try to reposition it on the left side of cursor. // If menu overflows to the left of viewport after repositioning, // choose a side which has less overflow // and adjust x to have it contained within the viewport. const rightOverflow = getRightOverflow(x); if (rightOverflow > 0) { const adjustedX = x - menuRect.width; const leftOverflow = getLeftOverflow(adjustedX); if (leftOverflow >= 0 || -leftOverflow < rightOverflow) { x = adjustedX; } x = confineHorizontally(x); } // Similar logic to the left and right side above. let computedDirection = 'bottom'; const bottomOverflow = getBottomOverflow(y); if (bottomOverflow > 0) { const adjustedY = y - menuRect.height; const topOverflow = getTopOverflow(adjustedY); if (topOverflow >= 0 || -topOverflow < bottomOverflow) { y = adjustedY; computedDirection = 'top'; } y = confineVertically(y); } return { x, y, computedDirection }; }; const placeArrowVertical = (p) => { let y = p.anchorRect.top - p.containerRect.top - p.menuY + p.anchorRect.height / 2; const offset = p.arrowRef.current ? p.arrowRef.current.offsetHeight * 1.25 : 0; y = Math.max(offset, y); y = Math.min(y, p.menuRect.height - offset); return y; }; const placeLeftorRight = (props) => { const { anchorRect, containerRect, menuRect, placeLeftorRightY, placeLeftX, placeRightX, getLeftOverflow, getRightOverflow, confineHorizontally, confineVertically, arrowRef, arrow, direction, position, } = props; let computedDirection = direction; let y = placeLeftorRightY; if (position !== 'initial') { y = confineVertically(y); if (position === 'anchor') { // restrict menu to the edge of anchor element y = Math.min(y, anchorRect.bottom - containerRect.top); y = Math.max(y, anchorRect.top - containerRect.top - menuRect.height); } } let x, leftOverflow, rightOverflow; if (computedDirection === 'left') { x = placeLeftX; if (position !== 'initial') { // if menu overflows to the left, // try to reposition it to the right of the anchor. leftOverflow = getLeftOverflow(x); if (leftOverflow < 0) { // if menu overflows to the right after repositioning, // choose a side which has less overflow rightOverflow = getRightOverflow(placeRightX); if (rightOverflow <= 0 || -leftOverflow > rightOverflow) { x = placeRightX; computedDirection = 'right'; } } } } else { // Opposite logic to the 'left' direction above x = placeRightX; if (position !== 'initial') { rightOverflow = getRightOverflow(x); if (rightOverflow > 0) { leftOverflow = getLeftOverflow(placeLeftX); if (leftOverflow >= 0 || -leftOverflow < rightOverflow) { x = placeLeftX; computedDirection = 'left'; } } } } if (position === 'auto') x = confineHorizontally(x); const arrowY = arrow ? placeArrowVertical({ menuY: y, arrowRef, anchorRect, containerRect, menuRect, }) : undefined; return { arrowY, x, y, computedDirection }; }; const placeArrowHorizontal = (p) => { let x = p.anchorRect.left - p.containerRect.left - p.menuX + p.anchorRect.width / 2; const offset = p.arrowRef.current ? p.arrowRef.current.offsetWidth * 1.25 : 0; x = Math.max(offset, x); x = Math.min(x, p.menuRect.width - offset); return x; }; const placeToporBottom = (props) => { const { anchorRect, containerRect, menuRect, placeToporBottomX, placeTopY, placeBottomY, getTopOverflow, getBottomOverflow, confineHorizontally, confineVertically, arrowRef, arrow, direction, position, } = props; // make sure invalid direction is treated as 'bottom' let computedDirection = direction === 'top' ? 'top' : 'bottom'; let x = placeToporBottomX; if (position !== 'initial') { x = confineHorizontally(x); if (position === 'anchor') { // restrict menu to the edge of anchor element x = Math.min(x, anchorRect.right - containerRect.left); x = Math.max(x, anchorRect.left - containerRect.left - menuRect.width); } } let y, topOverflow, bottomOverflow; if (computedDirection === 'top') { y = placeTopY; if (position !== 'initial') { // if