@ramstack/alpinegear-bound
Version: 
@ramstack/alpinegear-bound provides the 'x-bound' Alpine.js directive, which allows for two-way binding of input elements and their associated data properties.
431 lines (329 loc) • 12.4 kB
JavaScript
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 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;
    const handle = effect(() => {
        new_value = get_value();
        if (!initialized) {
            options?.deep && JSON.stringify(new_value);
            old_value = new_value;
        }
        if (initialized || (options?.immediate ?? true)) {
            
            setTimeout(() => {
                callback(new_value, old_value);
                old_value = new_value;
            }, 0);
        }
        initialized = true;
    });
    return () => 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({ directive, entangle, evaluateLater, mapAttributes, mutateDom, prefixed }) {
    
    
    
    
    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();
        
        
        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_details();
                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":
                    
                    
                    is_nullish(get_value()) && update_variable();
                    effect(update_property);
                    cleanup(listen(el, "input", update_variable));
                    processed = true;
                    break;
                case "SELECT":
                    
                    
                    
                    
                    
                    
                    
                    setTimeout(() => {
                        
                        
                        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))));
                    }, 0);
                    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.contentEditable === "true") {
                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_details() {
            if (tag_name === "DETAILS") {
                
                
                is_nullish(get_value()) && update_variable();
                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;
}
export { plugin as bound };