UNPKG

@ramstack/alpinegear-main

Version:

@ramstack/alpinegear-main is a combined plugin that includes several Alpine.js directives, providing a convenient all-in-one package.

726 lines (600 loc) 23.4 kB
function create_getter(evaluate_later, ...args) { const evaluate = evaluate_later(...args); return () => { let result; evaluate(v => result = v); return has_getter(result) ? result.get() : result; }; } function create_setter(evaluate_later, ...args) { const evaluate = evaluate_later(...args); args[args.length - 1] = `${ args.at(-1) } = __val`; const set = evaluate_later(...args); return value => { let result; evaluate(v => result = v); if (has_setter(result)) { result.set(value); } else { set(() => { }, { scope: { __val: value } }); } }; } function has_getter(value) { return typeof value?.get === "function"; } function has_setter(value) { return typeof value?.set === "function"; } const key = Symbol(); let observer; function observe_resize(el, listener) { observer ??= new ResizeObserver(entries => { for (const e of entries) { for (const callback of e.target[key]?.values() ?? []) { callback(e); } } }); el[key] ??= new Set(); el[key].add(listener); observer.observe(el); return () => { el[key].delete(listener); if (!el[key].size) { observer.unobserve(el); el[key] = null; } }; } const warn = (...args) => console.warn("alpinegear.js:", ...args); const is_array = Array.isArray; const is_nullish = value => value === null || value === undefined; const is_checkable_input = el => el.type === "checkbox" || el.type === "radio"; const is_numeric_input = el => el.type === "number" || el.type === "range"; const is_template = el => el.matches("template"); const is_element = el => el.nodeType === Node.ELEMENT_NODE; const as_array = value => is_array(value) ? value : [value]; const loose_equal = (a, b) => a == b; const loose_index_of = (array, value) => array.findIndex(v => v == value); const has_modifier = (modifiers, modifier) => modifiers.includes(modifier); function assert(value, message) { if (!value) { throw new Error(message); } } const listen = (target, type, listener, options) => { target.addEventListener(type, listener, options); return () => target.removeEventListener(type, listener, options); }; const clone = value => typeof value === "object" ? JSON.parse(JSON.stringify(value)) : value; const closest = (el, callback) => { while (el && !callback(el)) { el = (el._x_teleportBack ?? el).parentElement; } return el; }; const create_map = keys => new Map( keys.split(",").map(v => [v.trim().toLowerCase(), v.trim()])); function watch(get_value, callback, options = null) { assert(Alpine, "Alpine is not defined"); const { effect, release } = Alpine; let new_value; let old_value; let initialized = false; let timer_id; const handle = effect(() => { new_value = get_value(); if (!initialized) { options?.deep && JSON.stringify(new_value); old_value = new_value; } if (initialized || (options?.immediate ?? true)) { // Prevent the watcher from detecting its own dependencies. timer_id = setTimeout(() => { callback(new_value, old_value); old_value = new_value; }); } initialized = true; }); return () => { clearTimeout(timer_id); release(handle); } } const canonical_names = create_map( "value,checked,files," + "innerHTML,innerText,textContent," + "videoHeight,videoWidth," + "naturalHeight,naturalWidth," + "clientHeight,clientWidth,offsetHeight,offsetWidth," + "indeterminate," + "open," + "group"); function plugin$6({ directive, entangle, evaluateLater, mapAttributes, mutateDom, prefixed }) { // creating a shortcut for the directive, // when an attribute name starting with & will refer to our directive, // allowing us to write like this: &value="prop", // which is equivalent to x-bound:value="prop" mapAttributes(attr => ({ name: attr.name.replace(/^&/, prefixed("bound:")), value: attr.value })); directive("bound", (el, { expression, value, modifiers }, { effect, cleanup }) => { if (!value) { warn("x-bound directive expects the presence of a bound property name"); return; } const tag_name = el.tagName.toUpperCase(); expression = expression?.trim(); // since attributes come in a lowercase, // we need to convert the bound property name to its canonical form const property_name = canonical_names.get(value.trim().replace("-", "").toLowerCase()); // if the expression is omitted, then we assume it corresponds // to the bound property name, allowing us to write expressions more concisely, // and write &value instead of &value="value" expression ||= property_name; const get_value = create_getter(evaluateLater, el, expression); const set_value = create_setter(evaluateLater, el, expression); const update_property = () => loose_equal(el[property_name], get_value()) || mutateDom(() => el[property_name] = get_value()); const update_variable = () => set_value(is_numeric_input(el) ? to_number(el[property_name]) : el[property_name]); let processed; switch (property_name) { case "value": process_value(); break; case "checked": process_checked(); break; case "files": process_files(); break; case "innerHTML": case "innerText": case "textContent": process_contenteditable(); break; case "videoHeight": case "videoWidth": process_media_resize("VIDEO", "resize"); break; case "naturalHeight": case "naturalWidth": process_media_resize("IMG", "load"); break; case "clientHeight": case "clientWidth": case "offsetHeight": case "offsetWidth": process_dimensions(); break; case "indeterminate": process_indeterminate(); break; case "open": process_open_attribute(); break; case "group": process_group(); break; } if (!processed) { const modifier = has_modifier(modifiers, "in") ? "in" : has_modifier(modifiers, "out") ? "out" : "inout"; const source_el = expression === value ? closest(el.parentNode, node => node._x_dataStack) : el; if (!el._x_dataStack) { warn("x-bound directive requires the presence of the x-data directive to bind component properties"); return; } if (!source_el) { warn(`x-bound directive cannot find the parent scope where the '${ value }' property is defined`); return; } const source = { get: create_getter(evaluateLater, source_el, expression), set: create_setter(evaluateLater, source_el, expression) }; const target = { get: create_getter(evaluateLater, el, value), set: create_setter(evaluateLater, el, value) }; switch (modifier) { case "in": cleanup(watch(() => source.get(), v => target.set(clone(v)))); break; case "out": cleanup(watch(() => target.get(), v => source.set(clone(v)))); break; default: cleanup(entangle(source, target)); break; } } function process_value() { switch (tag_name) { case "INPUT": case "TEXTAREA": // if the value of the bound property is "null" or "undefined", // we initialize it with the value from the element. is_nullish(get_value()) && update_variable(); effect(update_property); cleanup(listen(el, "input", update_variable)); processed = true; break; case "SELECT": // WORKAROUND: // For the "select" element, there might be a situation // where options are generated dynamically using the "x-for" directive, // and in this case, attempting to set the "value" property // will have no effect since there are no options yet. // Therefore, we use a small trick to set the value a bit later // when the "x-for" directive has finished its work. setTimeout(() => { // if the value of the bound property is "null" or "undefined", // we initialize it with the value from the element. is_nullish(get_value()) && update_variable(); effect(() => apply_select_values(el, as_array(get_value() ?? []))); cleanup(listen(el, "change", () => set_value(collect_selected_values(el)))); }); processed = true; break; } } function process_checked() { if (is_checkable_input(el)) { effect(update_property); cleanup(listen(el, "change", update_variable)); processed = true; } } function process_indeterminate() { if (el.type === "checkbox") { is_nullish(get_value()) && update_variable(); effect(update_property); cleanup(listen(el, "change", update_variable)); processed = true; } } function process_files() { if (el.type === "file") { get_value() instanceof FileList || update_variable(); effect(update_property); cleanup(listen(el, "input", update_variable)); processed = true; } } function process_contenteditable() { if (el.isContentEditable) { is_nullish(get_value()) && update_variable(); effect(update_property); cleanup(listen(el, "input", update_variable)); processed = true; } } function process_media_resize(name, event_name) { if (tag_name === name) { update_variable(); cleanup(listen(el, event_name, update_variable)); processed = true; } } function process_dimensions() { cleanup(observe_resize(el, update_variable)); processed = true; } function process_open_attribute() { const [is_details, is_dialog] = [tag_name === "DETAILS", tag_name === "DIALOG"]; if (is_details || is_dialog) { // // <details>: // Supports safe two-way binding via the "open" attribute, // so we initialize from the element only if the bound value // is null or undefined. // // <dialog>: // Directly setting element.open is discouraged by the spec, // as it breaks native dialog behavior and the "close" event. // Therefore, we always initialize state from the element // and treat it as a one-way source of truth. // https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/open#value // (is_dialog || is_nullish(get_value())) && update_variable(); // // Enable two-way binding only for "<details>" // is_details && effect(update_property); cleanup(listen(el, "toggle", update_variable)); processed = true; } } function process_group() { if (is_checkable_input(el)) { el.name || mutateDom(() => el.name = expression); effect(() => mutateDom(() => apply_group_values(el, get_value() ?? []))); cleanup(listen(el, "input", () => set_value(collect_group_values(el, get_value())))); processed = true; } } }); } function to_number(value) { return value === "" ? null : +value; } function apply_select_values(el, values) { for (const option of el.options) { option.selected = loose_index_of(values, option.value) >= 0; } } function collect_selected_values(el) { if (el.multiple) { return [...el.selectedOptions].map(o => o.value); } return el.value; } function apply_group_values(el, values) { el.checked = is_array(values) ? loose_index_of(values, el.value) >= 0 : loose_equal(el.value, values); } function collect_group_values(el, values) { if (el.type === "radio") { return el.value; } values = as_array(values); const index = loose_index_of(values, el.value); if (el.checked) { index >= 0 || values.push(el.value); } else { index >= 0 && values.splice(index, 1); } return values; } function plugin$5({ directive, evaluateLater, mutateDom }) { directive("format", (el, { modifiers }, { effect }) => { const is_once = has_modifier(modifiers, "once"); const has_format_attr = el => el.hasAttribute("x-format"); process(el); function update(callback) { if (is_once) { mutateDom(() => callback()); } else { effect(() => mutateDom(() => callback())); } } function process(node) { switch (node.nodeType) { case Node.TEXT_NODE: process_text_node(node); break; case Node.ELEMENT_NODE: if (node !== el) { // // When we encounter an element with the "x-data" attribute, its properties // are not yet initialized, and the Alpine context is unavailable. // Attempting to use these properties will result in // an "Alpine Expression Error: [expression] is not defined". // // Workaround: // To avoid this, we manually add our "x-format" directive to the element. // Alpine evaluates "x-format" directive once the context is initialized. // In the current loop, we skip these elements to defer their processing. // // This also handles cases where the user manually adds the "x-format" attribute. // if (node.hasAttribute("x-data") && !has_format_attr(node)) { node.setAttribute("x-format", ""); } if (has_format_attr(node)) { break; } } process_nodes(node); process_attributes(node); break; } } function process_text_node(node) { const tokens = node.textContent.split(/{{(?<expr>.+?)}}/g); if (tokens.length > 1) { const fragment = new DocumentFragment(); for (let i = 0; i < tokens.length; i++) { if ((i % 2) === 0) { fragment.appendChild(document.createTextNode(tokens[i])); } else { const get_value = create_getter(evaluateLater, node.parentNode, tokens[i]); const text = document.createTextNode(""); fragment.append(text); update(() => text.textContent = get_value()); } } mutateDom(() => node.parentElement.replaceChild(fragment, node)); } } function process_attributes(node) { for (let attr of node.attributes) { const matches = [...attr.value.matchAll(/{{(?<expr>.+?)}}/g)]; if (matches.length) { const getters = new Map( matches.map(m => [ m.groups.expr, create_getter( evaluateLater, node, m.groups.expr)])); const template = attr.value; update(() => attr.value = template.replace(/{{(?<expr>.+?)}}/g, (_, expr) => getters.get(expr)())); } } } function process_nodes(node) { for (let child of node.childNodes) { process(child); } } }); } function anchor_block(el, template, { addScopeToNode, cleanup, initTree, mutateDom, scope = {} }) { if (el._r_block) { return; } initialize(); let nodes = is_template(template) ? [...template.content.cloneNode(true).childNodes] : [template.cloneNode(true)]; mutateDom(() => { for (let node of nodes) { is_element(node) && addScopeToNode(node, scope, el); el.parentElement.insertBefore(node, el); is_element(node) && initTree(node); } }); el._r_block = { template, update() { mutateDom(() => { for (let node of nodes ?? []) { el.parentElement.insertBefore(node, el); } }); }, delete() { el._r_block = null; for (let node of nodes ?? []) { node.remove(); } nodes = null; } }; cleanup(() => el._r_block?.delete()); } function initialize() { document.body._r_block ??= (() => { const observer = new MutationObserver(mutations => { for (let mutation of mutations) { for (let node of mutation.addedNodes) { node._r_block?.update(); } } }); observer.observe(document.body, { childList: true, subtree: true }); return observer; })(); } function plugin$4({ addScopeToNode, directive, initTree, mutateDom }) { directive("fragment", (el, {}, { cleanup }) => { if (!is_template(el)) { warn("x-fragment can only be used on a 'template' tag"); return; } anchor_block(el, el, { addScopeToNode, cleanup, initTree, mutateDom }); }); } function plugin$3({ addScopeToNode, directive, initTree, mutateDom }) { directive("match", (el, { }, { cleanup, effect, evaluateLater }) => { if (!is_template(el)) { warn("x-match can only be used on a 'template' tag"); return; } const branches = []; const has_default_case = () => branches.some(b => b.default); for (let node of el.content.children) { const expr = node.getAttribute("x-case"); if (expr !== null) { has_default_case() && warn("The x-case directive cannot be appear after x-default"); branches.push({ el: node, get_value: create_getter(evaluateLater, expr) }); } else if (node.hasAttribute("x-default")) { has_default_case() && warn("Only one x-default directive is allowed"); branches.push({ el: node, get_value: () => true, default: true }); } else { warn("Element has no x-case or x-default directive and will be ignored", node); } } const activate = branch => { if (el._r_block?.template !== branch.el) { clear(); anchor_block(el, branch.el, { addScopeToNode, cleanup, initTree, mutateDom }); } }; const clear = () => el._r_block?.delete(); effect(() => { let active; for (let branch of branches) { if (branch.get_value() && !active) { active = branch; } } active ? activate(active) : clear(); }); }); } function plugin$2(alpine) { alpine.directive("template", (el, { expression }) => { if (is_template(el)) { warn("x-template cannot be used on a 'template' tag"); return; } const tpl = document.getElementById(expression); if (!tpl || !is_template(tpl)) { warn("x-template directive can only reference the template tag"); return; } // Adding a queued task ensures asynchronous content update, allowing Alpine.js // to handle context propagation for cloned elements properly. // This is important because manipulation can occur within the mutateDom function // when mutation observing is disabled, preventing proper context propagation // for cloned elements. queueMicrotask(() => { el.innerHTML = ""; el.append(tpl.content.cloneNode(true)); }); }); } function plugin$1({ addScopeToNode, directive, initTree, mutateDom }) { directive("when", (el, { expression }, { cleanup, effect, evaluateLater }) => { if (!is_template(el)) { warn("x-when can only be used on a 'template' tag"); return; } const activate = () => anchor_block(el, el, { addScopeToNode, cleanup, initTree, mutateDom }); const clear = () => el._r_block?.delete(); const get = create_getter(evaluateLater, expression); effect(() => get() ? activate() : clear()); }); } function plugin(alpine) { plugin$6(alpine); plugin$5(alpine); plugin$4(alpine); plugin$3(alpine); plugin$2(alpine); plugin$1(alpine); } export { plugin$6 as bound, plugin as default, plugin$5 as format, plugin$4 as fragment, plugin$3 as match, plugin$2 as template, plugin$1 as when };