UNPKG

@wordpress/interactivity

Version:

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

666 lines (641 loc) 20 kB
// eslint-disable-next-line eslint-comments/disable-enable-pair /* eslint-disable react-hooks/exhaustive-deps */ /** * External dependencies */ import { h as createElement } from 'preact'; import { useContext, useMemo, useRef } from 'preact/hooks'; /** * Internal dependencies */ import { useWatch, useInit, kebabToCamelCase, warn, splitTask, isPlainObject } from './utils'; import { directive, getEvaluate, isDefaultDirectiveSuffix, isNonDefaultDirectiveSuffix } from './hooks'; import { getScope } from './scopes'; import { proxifyState, proxifyContext, deepMerge } from './proxies'; /** * Recursively clones the passed object. * * @param source Source object. * @return Cloned object. */ function deepClone(source) { if (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': 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': 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(isNonDefaultDirectiveSuffix).forEach(entry => { const eventName = entry.suffix.split('--', 1)[0]; 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(isNonDefaultDirectiveSuffix).forEach(entry => { const eventName = entry.suffix.split('--', 1)[0]; useInit(() => { const cb = async event => { await 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); }); }); }; }; export default () => { // data-wp-context directive('context', ({ directives: { context }, props: { children }, context: inheritedContext }) => { const { Provider } = inheritedContext; const defaultEntry = context.find(isDefaultDirectiveSuffix); const { client: inheritedClient, server: inheritedServer } = useContext(inheritedContext); const ns = defaultEntry.namespace; const client = useRef(proxifyState(ns, {})); const server = useRef(proxifyState(ns, {}, { readOnly: true })); // No change should be made if `defaultEntry` does not exist. const contextStack = 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 (!isPlainObject(value)) { warn(`The value of data-wp-context in "${namespace}" store must be a valid stringified JSON object.`); } deepMerge(client.current, deepClone(value), false); deepMerge(server.current, deepClone(value)); result.client[namespace] = proxifyContext(client.current, inheritedClient[namespace]); result.server[namespace] = proxifyContext(server.current, inheritedServer[namespace]); } return result; }, [defaultEntry, inheritedClient, inheritedServer]); return createElement(Provider, { value: contextStack }, children); }, { priority: 5 }); // data-wp-watch--[name] directive('watch', ({ directives: { watch }, evaluate }) => { watch.forEach(entry => { 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] directive('init', ({ directives: { init }, evaluate }) => { init.forEach(entry => { // TODO: Replace with useEffect to prevent unneeded scopes. 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] directive('on', ({ directives: { on }, element, evaluate }) => { const events = new Map(); on.filter(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] directive('on-async', ({ directives: { 'on-async': onAsync }, element, evaluate }) => { const events = new Map(); onAsync.filter(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 splitTask(); const result = evaluate(entry); if (typeof result === 'function') { result(event); } }); }; }); }); // data-wp-on-window--[event] directive('on-window', getGlobalEventDirective('window')); // data-wp-on-document--[event] directive('on-document', getGlobalEventDirective('document')); // data-wp-on-async-window--[event] directive('on-async-window', getGlobalAsyncEventDirective('window')); // data-wp-on-async-document--[event] directive('on-async-document', getGlobalAsyncEventDirective('document')); // data-wp-class--[classname] directive('class', ({ directives: { class: classNames }, element, evaluate }) => { classNames.filter(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; } 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] directive('style', ({ directives: { style }, element, evaluate }) => { style.filter(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; } 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] directive('bind', ({ directives: { bind }, element, evaluate }) => { bind.filter(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. */ 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 directive('ignore', ({ element: { type: Type, props: { innerHTML, ...rest } } }) => { // Shown deprecation warning 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 = useMemo(() => innerHTML, []); return createElement(Type, { dangerouslySetInnerHTML: { __html: cached }, ...rest }); }); // data-wp-text directive('text', ({ directives: { text }, element, evaluate }) => { const entry = text.find(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 directive('run', ({ directives: { run }, evaluate }) => { run.forEach(entry => { let result = evaluate(entry); if (typeof result === 'function') { result = result(); } return result; }); }); // data-wp-each--[item] directive('each', ({ directives: { each, 'each-key': eachKey }, context: inheritedContext, element, evaluate }) => { if (element.type !== 'template') { return; } const { Provider } = inheritedContext; const inheritedValue = 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 = isNonDefaultDirectiveSuffix(entry) ? kebabToCamelCase(entry.suffix) : 'item'; const result = []; for (const item of iterable) { const itemContext = proxifyContext(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 = { ...getScope(), context: mergedContext.client, serverContext: mergedContext.server }; const key = eachKey ? getEvaluate({ scope })(eachKey[0]) : item; result.push(createElement(Provider, { value: mergedContext, key }, element.props.content)); } return result; }, { priority: 20 }); directive('each-child', () => null, { priority: 1 }); }; //# sourceMappingURL=directives.js.map