UNPKG

@wordpress/interactivity

Version:

Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.

673 lines (647 loc) 20.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _preact = require("preact"); var _hooks = require("preact/hooks"); var _utils = require("./utils"); var _hooks2 = require("./hooks"); var _scopes = require("./scopes"); var _proxies = require("./proxies"); // eslint-disable-next-line eslint-comments/disable-enable-pair /* eslint-disable react-hooks/exhaustive-deps */ /** * External dependencies */ /** * Internal dependencies */ /** * Recursively clones the passed object. * * @param source Source object. * @return Cloned object. */ function deepClone(source) { if ((0, _utils.isPlainObject)(source)) { return Object.fromEntries(Object.entries(source).map(([key, value]) => [key, deepClone(value)])); } if (Array.isArray(source)) { return source.map(i => deepClone(i)); } return source; } /** * Wraps event object to warn about access of synchronous properties and methods. * * For all store actions attached to an event listener the event object is proxied via this function, unless the action * uses the `withSyncEvent()` utility to indicate that it requires synchronous access to the event object. * * At the moment, the proxied event only emits warnings when synchronous properties or methods are being accessed. In * the future this will be changed and result in an error. The current temporary behavior allows implementers to update * their relevant actions to use `withSyncEvent()`. * * For additional context, see https://github.com/WordPress/gutenberg/issues/64944. * * @param event Event object. * @return Proxied event object. */ function wrapEventAsync(event) { const handler = { get(target, prop, receiver) { const value = target[prop]; switch (prop) { case 'currentTarget': (0, _utils.warn)(`Accessing the synchronous event.${prop} property in a store action without wrapping it in withSyncEvent() is deprecated and will stop working in WordPress 6.9. Please wrap the store action in withSyncEvent().`); break; case 'preventDefault': case 'stopImmediatePropagation': case 'stopPropagation': (0, _utils.warn)(`Using the synchronous event.${prop}() function in a store action without wrapping it in withSyncEvent() is deprecated and will stop working in WordPress 6.9. Please wrap the store action in withSyncEvent().`); break; } if (value instanceof Function) { return function (...args) { return value.apply(this === receiver ? target : this, args); }; } return value; } }; return new Proxy(event, handler); } const newRule = /(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g; const ruleClean = /\/\*[^]*?\*\/| +/g; const ruleNewline = /\n+/g; const empty = ' '; /** * Converts a css style string into a object. * * Made by Cristian Bote (@cristianbote) for Goober. * https://unpkg.com/browse/goober@2.1.13/src/core/astish.js * * @param val CSS string. * @return CSS object. */ const cssStringToObject = val => { const tree = [{}]; let block, left; while (block = newRule.exec(val.replace(ruleClean, ''))) { if (block[4]) { tree.shift(); } else if (block[3]) { left = block[3].replace(ruleNewline, empty).trim(); tree.unshift(tree[0][left] = tree[0][left] || {}); } else { tree[0][block[1]] = block[2].replace(ruleNewline, empty).trim(); } } return tree[0]; }; /** * Creates a directive that adds an event listener to the global window or * document object. * * @param type 'window' or 'document' */ const getGlobalEventDirective = type => { return ({ directives, evaluate }) => { directives[`on-${type}`].filter(_hooks2.isNonDefaultDirectiveSuffix).forEach(entry => { const eventName = entry.suffix.split('--', 1)[0]; (0, _utils.useInit)(() => { const cb = event => { const result = evaluate(entry); if (typeof result === 'function') { if (!result?.sync) { event = wrapEventAsync(event); } result(event); } }; const globalVar = type === 'window' ? window : document; globalVar.addEventListener(eventName, cb); return () => globalVar.removeEventListener(eventName, cb); }); }); }; }; /** * Creates a directive that adds an async event listener to the global window or * document object. * * @param type 'window' or 'document' */ const getGlobalAsyncEventDirective = type => { return ({ directives, evaluate }) => { directives[`on-async-${type}`].filter(_hooks2.isNonDefaultDirectiveSuffix).forEach(entry => { const eventName = entry.suffix.split('--', 1)[0]; (0, _utils.useInit)(() => { const cb = async event => { await (0, _utils.splitTask)(); const result = evaluate(entry); if (typeof result === 'function') { result(event); } }; const globalVar = type === 'window' ? window : document; globalVar.addEventListener(eventName, cb, { passive: true }); return () => globalVar.removeEventListener(eventName, cb); }); }); }; }; var _default = () => { // data-wp-context (0, _hooks2.directive)('context', ({ directives: { context }, props: { children }, context: inheritedContext }) => { const { Provider } = inheritedContext; const defaultEntry = context.find(_hooks2.isDefaultDirectiveSuffix); const { client: inheritedClient, server: inheritedServer } = (0, _hooks.useContext)(inheritedContext); const ns = defaultEntry.namespace; const client = (0, _hooks.useRef)((0, _proxies.proxifyState)(ns, {})); const server = (0, _hooks.useRef)((0, _proxies.proxifyState)(ns, {}, { readOnly: true })); // No change should be made if `defaultEntry` does not exist. const contextStack = (0, _hooks.useMemo)(() => { const result = { client: { ...inheritedClient }, server: { ...inheritedServer } }; if (defaultEntry) { const { namespace, value } = defaultEntry; // Check that the value is a JSON object. Send a console warning if not. if (!(0, _utils.isPlainObject)(value)) { (0, _utils.warn)(`The value of data-wp-context in "${namespace}" store must be a valid stringified JSON object.`); } (0, _proxies.deepMerge)(client.current, deepClone(value), false); (0, _proxies.deepMerge)(server.current, deepClone(value)); result.client[namespace] = (0, _proxies.proxifyContext)(client.current, inheritedClient[namespace]); result.server[namespace] = (0, _proxies.proxifyContext)(server.current, inheritedServer[namespace]); } return result; }, [defaultEntry, inheritedClient, inheritedServer]); return (0, _preact.h)(Provider, { value: contextStack }, children); }, { priority: 5 }); // data-wp-watch--[name] (0, _hooks2.directive)('watch', ({ directives: { watch }, evaluate }) => { watch.forEach(entry => { (0, _utils.useWatch)(() => { let start; if (globalThis.IS_GUTENBERG_PLUGIN) { if (globalThis.SCRIPT_DEBUG) { // eslint-disable-next-line no-unused-vars start = performance.now(); } } let result = evaluate(entry); if (typeof result === 'function') { result = result(); } if (globalThis.IS_GUTENBERG_PLUGIN) { if (globalThis.SCRIPT_DEBUG) { performance.measure(`interactivity api watch ${entry.namespace}`, { start, end: performance.now(), detail: { devtools: { track: `IA: watch ${entry.namespace}` } } }); } } return result; }); }); }); // data-wp-init--[name] (0, _hooks2.directive)('init', ({ directives: { init }, evaluate }) => { init.forEach(entry => { // TODO: Replace with useEffect to prevent unneeded scopes. (0, _utils.useInit)(() => { let start; if (globalThis.IS_GUTENBERG_PLUGIN) { if (globalThis.SCRIPT_DEBUG) { start = performance.now(); } } let result = evaluate(entry); if (typeof result === 'function') { result = result(); } if (globalThis.IS_GUTENBERG_PLUGIN) { if (globalThis.SCRIPT_DEBUG) { performance.measure(`interactivity api init ${entry.namespace}`, { // eslint-disable-next-line no-undef start, end: performance.now(), detail: { devtools: { track: `IA: init ${entry.namespace}` } } }); } } return result; }); }); }); // data-wp-on--[event] (0, _hooks2.directive)('on', ({ directives: { on }, element, evaluate }) => { const events = new Map(); on.filter(_hooks2.isNonDefaultDirectiveSuffix).forEach(entry => { const event = entry.suffix.split('--')[0]; if (!events.has(event)) { events.set(event, new Set()); } events.get(event).add(entry); }); events.forEach((entries, eventType) => { const existingHandler = element.props[`on${eventType}`]; element.props[`on${eventType}`] = event => { entries.forEach(entry => { if (existingHandler) { existingHandler(event); } let start; if (globalThis.IS_GUTENBERG_PLUGIN) { if (globalThis.SCRIPT_DEBUG) { start = performance.now(); } } const result = evaluate(entry); if (typeof result === 'function') { if (!result?.sync) { event = wrapEventAsync(event); } result(event); } if (globalThis.IS_GUTENBERG_PLUGIN) { if (globalThis.SCRIPT_DEBUG) { performance.measure(`interactivity api on ${entry.namespace}`, { // eslint-disable-next-line no-undef start, end: performance.now(), detail: { devtools: { track: `IA: on ${entry.namespace}` } } }); } } }); }; }); }); // data-wp-on-async--[event] (0, _hooks2.directive)('on-async', ({ directives: { 'on-async': onAsync }, element, evaluate }) => { const events = new Map(); onAsync.filter(_hooks2.isNonDefaultDirectiveSuffix).forEach(entry => { const event = entry.suffix.split('--')[0]; if (!events.has(event)) { events.set(event, new Set()); } events.get(event).add(entry); }); events.forEach((entries, eventType) => { const existingHandler = element.props[`on${eventType}`]; element.props[`on${eventType}`] = event => { if (existingHandler) { existingHandler(event); } entries.forEach(async entry => { await (0, _utils.splitTask)(); const result = evaluate(entry); if (typeof result === 'function') { result(event); } }); }; }); }); // data-wp-on-window--[event] (0, _hooks2.directive)('on-window', getGlobalEventDirective('window')); // data-wp-on-document--[event] (0, _hooks2.directive)('on-document', getGlobalEventDirective('document')); // data-wp-on-async-window--[event] (0, _hooks2.directive)('on-async-window', getGlobalAsyncEventDirective('window')); // data-wp-on-async-document--[event] (0, _hooks2.directive)('on-async-document', getGlobalAsyncEventDirective('document')); // data-wp-class--[classname] (0, _hooks2.directive)('class', ({ directives: { class: classNames }, element, evaluate }) => { classNames.filter(_hooks2.isNonDefaultDirectiveSuffix).forEach(entry => { const className = entry.suffix; let result = evaluate(entry); if (typeof result === 'function') { result = result(); } const currentClass = element.props.class || ''; const classFinder = new RegExp(`(^|\\s)${className}(\\s|$)`, 'g'); if (!result) { element.props.class = currentClass.replace(classFinder, ' ').trim(); } else if (!classFinder.test(currentClass)) { element.props.class = currentClass ? `${currentClass} ${className}` : className; } (0, _utils.useInit)(() => { /* * This seems necessary because Preact doesn't change the class * names on the hydration, so we have to do it manually. It doesn't * need deps because it only needs to do it the first time. */ if (!result) { element.ref.current.classList.remove(className); } else { element.ref.current.classList.add(className); } }); }); }); // data-wp-style--[style-prop] (0, _hooks2.directive)('style', ({ directives: { style }, element, evaluate }) => { style.filter(_hooks2.isNonDefaultDirectiveSuffix).forEach(entry => { const styleProp = entry.suffix; let result = evaluate(entry); if (typeof result === 'function') { result = result(); } element.props.style = element.props.style || {}; if (typeof element.props.style === 'string') { element.props.style = cssStringToObject(element.props.style); } if (!result) { delete element.props.style[styleProp]; } else { element.props.style[styleProp] = result; } (0, _utils.useInit)(() => { /* * This seems necessary because Preact doesn't change the styles on * the hydration, so we have to do it manually. It doesn't need deps * because it only needs to do it the first time. */ if (!result) { element.ref.current.style.removeProperty(styleProp); } else { element.ref.current.style[styleProp] = result; } }); }); }); // data-wp-bind--[attribute] (0, _hooks2.directive)('bind', ({ directives: { bind }, element, evaluate }) => { bind.filter(_hooks2.isNonDefaultDirectiveSuffix).forEach(entry => { const attribute = entry.suffix; let result = evaluate(entry); if (typeof result === 'function') { result = result(); } element.props[attribute] = result; /* * This is necessary because Preact doesn't change the attributes on the * hydration, so we have to do it manually. It only needs to do it the * first time. After that, Preact will handle the changes. */ (0, _utils.useInit)(() => { const el = element.ref.current; /* * We set the value directly to the corresponding HTMLElement instance * property excluding the following special cases. We follow Preact's * logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 */ if (attribute === 'style') { if (typeof result === 'string') { el.style.cssText = result; } return; } else if (attribute !== 'width' && attribute !== 'height' && attribute !== 'href' && attribute !== 'list' && attribute !== 'form' && /* * The value for `tabindex` follows the parsing rules for an * integer. If that fails, or if the attribute isn't present, then * the browsers should "follow platform conventions to determine if * the element should be considered as a focusable area", * practically meaning that most elements get a default of `-1` (not * focusable), but several also get a default of `0` (focusable in * order after all elements with a positive `tabindex` value). * * @see https://html.spec.whatwg.org/#tabindex-value */ attribute !== 'tabIndex' && attribute !== 'download' && attribute !== 'rowSpan' && attribute !== 'colSpan' && attribute !== 'role' && attribute in el) { try { el[attribute] = result === null || result === undefined ? '' : result; return; } catch (err) {} } /* * aria- and data- attributes have no boolean representation. * A `false` value is different from the attribute not being * present, so we can't remove it. * We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 */ if (result !== null && result !== undefined && (result !== false || attribute[4] === '-')) { el.setAttribute(attribute, result); } else { el.removeAttribute(attribute); } }); }); }); // data-wp-ignore (0, _hooks2.directive)('ignore', ({ element: { type: Type, props: { innerHTML, ...rest } } }) => { // Shown deprecation warning (0, _utils.warn)('The "data-wp-ignore" directive of the Interactivity API is deprecated since version 6.9 and will be removed in version 7.0.'); // Preserve the initial inner HTML const cached = (0, _hooks.useMemo)(() => innerHTML, []); return (0, _preact.h)(Type, { dangerouslySetInnerHTML: { __html: cached }, ...rest }); }); // data-wp-text (0, _hooks2.directive)('text', ({ directives: { text }, element, evaluate }) => { const entry = text.find(_hooks2.isDefaultDirectiveSuffix); if (!entry) { element.props.children = null; return; } try { let result = evaluate(entry); if (typeof result === 'function') { result = result(); } element.props.children = typeof result === 'object' ? null : result.toString(); } catch (e) { element.props.children = null; } }); // data-wp-run (0, _hooks2.directive)('run', ({ directives: { run }, evaluate }) => { run.forEach(entry => { let result = evaluate(entry); if (typeof result === 'function') { result = result(); } return result; }); }); // data-wp-each--[item] (0, _hooks2.directive)('each', ({ directives: { each, 'each-key': eachKey }, context: inheritedContext, element, evaluate }) => { if (element.type !== 'template') { return; } const { Provider } = inheritedContext; const inheritedValue = (0, _hooks.useContext)(inheritedContext); const [entry] = each; const { namespace } = entry; let iterable = evaluate(entry); if (typeof iterable === 'function') { iterable = iterable(); } if (typeof iterable?.[Symbol.iterator] !== 'function') { return; } const itemProp = (0, _hooks2.isNonDefaultDirectiveSuffix)(entry) ? (0, _utils.kebabToCamelCase)(entry.suffix) : 'item'; const result = []; for (const item of iterable) { const itemContext = (0, _proxies.proxifyContext)((0, _proxies.proxifyState)(namespace, {}), inheritedValue.client[namespace]); const mergedContext = { client: { ...inheritedValue.client, [namespace]: itemContext }, server: { ...inheritedValue.server } }; // Set the item after proxifying the context. mergedContext.client[namespace][itemProp] = item; const scope = { ...(0, _scopes.getScope)(), context: mergedContext.client, serverContext: mergedContext.server }; const key = eachKey ? (0, _hooks2.getEvaluate)({ scope })(eachKey[0]) : item; result.push((0, _preact.h)(Provider, { value: mergedContext, key }, element.props.content)); } return result; }, { priority: 20 }); (0, _hooks2.directive)('each-child', () => null, { priority: 1 }); }; exports.default = _default; //# sourceMappingURL=directives.js.map