@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
JavaScript
;
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