// HELPER FUNCTIONS

// Get a specific CSS property for a CSS class, optionally with :focus
function get_css_property(tag_name, class_name, property_name, focus)
{
    // Create a temporary element
    const el = document.createElement(tag_name);
    el.className = class_name;
    el.style.position = "absolute";
    el.style.left = "-9999px";
    document.body.appendChild(el);

    if (focus) {
        // Force focus
        el.tabIndex = -1;
        el.focus();
    }

    // Read the computed property
    const prop = getComputedStyle(el).getPropertyValue(property_name);

    // Clean up
    document.body.removeChild(el);

    return prop;
}

// Remove data and attribute
function rm_data($target, key) {
    $target.removeData(key);
    $target.removeAttr("data-" + key);
}

// Plain text content of a string of html, for ARIA attributes
function plain_text(html) {
    var el = document.createElement("div");
    el.innerHTML = html;
    return el.textContent;
}

// if x is null or undefined, return d, else x
function deflt(x, d) {
    return x == null ? d : x;
}

// if x is undefined, return d, else x
function defln(x, d) {
    return x === undefined ? d : x;
}

// Does str represent a valid finite number, also not an empty string?
function is_number(str) {
    return str.trim() !== "" && isFinite(str);
}

// Format a Date as ISO yyyy-mm-dd.
function format_date_iso(date) {
    var str = date.toISOString();
    return str.slice(0, str.indexOf("T"));
}

// Format date according to datepicker format; date can be a Date or a string
function format_date_dp(date, $dp)
{
    if (typeof(date) === "string") {
        date = new Date(date);
    } else if (!(date instanceof Date)) {
        throw new Error("Unknown date object.")
    }
    return $dp.bsDatepicker.DPGlobal.formatDate(date,
        $dp.data("dateFormat"),
        $dp.data("dateLanguage"));
}

// Parse date_str according to datepicker format
function parse_date_dp(date_str, $dp)
{
    return $dp.bsDatepicker.DPGlobal.parseDate(date_str,
        $dp.data("dateFormat"),
        $dp.data("dateLanguage"),
        $dp.data("assumeNearbyYear"));
}

// The other parts of a widget, found by looking within the widget itself.
// While a widget is being moved from one place in the document to another,
// the copy on its way out still holds the same ids, so a lookup by id can
// find that one instead.
function slider_of(el) {
    return $(el).closest(".dropdown-center").find("input.js-range-slider");
}

function datepicker_of(el) {
    return $(el).closest(".dropdown-center").find(".inshiny-datepicker");
}

function menu_of(el) {
    return $(el).closest(".dropdown-center").find(".inshiny-menu");
}

function list_form_of(el) {
    return $(el).closest(".dropdown-center").find(".inshiny-list-form");
}

function dropdown_of(el) {
    return $(el).closest(".dropdown-center").find("[data-bs-toggle='dropdown']");
}

function number_of(el) {
    return $(el).closest(".inshiny-arrows").siblings(".inshiny-text-container")
        .find(".inshiny-number-form");
}

function switch_label_of(el) {
    return $(el).closest(".inshiny-switch-container").siblings(".inshiny-switch-label");
}

// Adjust number for numeric input by [by] steps (-1, +1, etc.)
// by = -99 or +99 interpreted as move to minimum or maximum.
function adjust_number(el, by) {
    var $target = $(el);
    var num;
    if (is_number($target.text())) {
        num = Number($target.text());
        if (by == -99) {
            const min = $target.data("min");
            if (min) num = Number(min);
        } else if (by == 99) {
            const max = $target.data("max");
            if (max) num = Number(max);
        } else {
            const min = Number($target.data("min") || 0);
            const step = Number($target.data("step") || 1);
            var n1 = ((num - min) / step); // Number of steps above min
            var n2 = n1 % 1;  // Fractional part of n1
            if (n2 < 0) n2++; // Account for negative n2 (in case of no min)

            if (n2 < 1e-12) {
                num = Math.floor(n1 + by) * step + min;
            } else if (n2 > 1 - 1e-12) {
                num = Math.ceil(n1 + by) * step + min;
            } else {
                if (by > 0) {
                    num = Math.ceil(n1) * step + min;
                } else {
                    num = Math.floor(n1) * step + min;
                }
            }
            if ($target.data("max")) {
                num = Math.min(Number($target.data("max")), num);
            }
            if ($target.data("min")) {
                num = Math.max(min, num);
            }
        }
    } else {
        num = Number($target.data("default"));
    }

    // Note: here and in similar lines, I am using [0].textContent rather than
    // jQuery's text() as the latter triggers two mutationobserver calls --
    // first it deletes all children of the node, then creates a new text node.
    $target[0].textContent = num.toPrecision(15) / 1;
    // Note: mutation observer handles change to Shiny message & aria-valuenow
}

// Bind a number input to its slider
function bind_slider(el) {
    var slider = slider_of(el).data("ionRangeSlider");
    var $number = $(el);

    function update_textbox(data) {
        if (!$number.data("dontChange")) {
            $number[0].textContent = data.from;
        }
    }

    // Keep label synced while dragging and on programmatic updates
    slider.update({
        onChange: update_textbox,
        onUpdate: update_textbox
    });
}

// Bind a date input to its datepicker
function bind_datepicker(el) {
    var $datepicker = datepicker_of(el);
    var datepicker = $datepicker.bsDatepicker();
    var $date = $(el);

    // Fix bug in Shiny <1.11.1 with datesdisabled
    var dd = $datepicker.attr("data-date-dates-disabled");
    if (dd !== null) {
        const regex = /\d{4}-\d{2}-\d{2}/g;
        dd = (dd.match(regex) || []).map((ymd) => format_date_dp(ymd, $datepicker));
        $datepicker.data().datepicker.setDatesDisabled(dd);
    }

    // Set initial date to today if needed
    if ($date.data("value") === undefined) {
        $date.data("value", Date())
    }

    // Set initial date to match datepicker format
    $date[0].textContent = format_date_dp($date.data("value"), $datepicker);

    // Set datepicker's initial date
    $datepicker.data().datepicker.update($date[0].textContent);

    // Attach event handler so that date input changes with datepicker selection
    datepicker.on("changeDate", function(e) {
        if ($datepicker.data("autoclose")) {
            const $dropdown = dropdown_of(el);
            const dropdown = bootstrap.Dropdown.getInstance($dropdown) ||
                new bootstrap.Dropdown($dropdown);
            dropdown.hide();
        }
        $date.data("value", e.format("yyyy-mm-dd"));
        $date[0].textContent = e.format();
    });
}

// Set switch label
function set_switch_label($switch) {
    if ($switch.is(":checked")) {
        switch_label_of($switch).html($switch.data("on-label"));
        $switch.attr("aria-checked", "true")
    } else {
        switch_label_of($switch).html($switch.data("off-label"));
        $switch.attr("aria-checked", "false")
    }
}

// Select an item in a list widget and close its dropdown
function list_select($option) {
    var item = $option.data("item");
    list_form_of($option)[0].textContent = item;

    $option.parent().parent().children().children().removeClass("active");
    $option.addClass("active");

    var $dropdown = dropdown_of($option);
    const dropdown = bootstrap.Dropdown.getInstance($dropdown) ||
        new bootstrap.Dropdown($dropdown);
    dropdown.hide();
}

// Process a .inshiny-text-form element: set placeholder visibility,
// validate content, sync slider/datepicker, and return the value.
function process_text_form(el) {
    var $el = $(el);
    var content = el.textContent || "";

    // Get datepicker, if present
    var datepicker;
    var $datepicker;
    if ($el.hasClass("inshiny-with-datepicker")) {
        $datepicker = datepicker_of(el);
        datepicker = $datepicker.data().datepicker;
    }

    if (content === "") {
        // If content is empty, display placeholder
        $el.siblings(".inshiny-text-placeholder").css("display", "inline");
        if ($el.hasClass("inshiny-number-form")) {
            // If number: remove invalid class (placeholder covers this)
            // and set content to default, update aria "valuenow".
            $el.siblings(".inshiny-text-box").removeClass("inshiny-invalid");
            content = Number($el.data("default"));
            $el.attr("aria-valuenow", content);
        } else if ($el.hasClass("inshiny-date-form")) {
            // If number: remove invalid class (placeholder covers this)
            // and set content to default (in datepicker's format),
            // update "value" to default (in ISO format).
            $el.siblings(".inshiny-text-box").removeClass("inshiny-invalid");
            content = $el.data("default");
            $el.data("value", $el.data("default"));
        }
    } else {
        // If content is present, remove placeholder
        $el.siblings(".inshiny-text-placeholder").css("display", "none");

        // Check validity for different inputs
        if ($el.hasClass("inshiny-number-form")) {
            // Number: is number, and not outside range
            var num = Number(content);
            var min = $el.data("min");
            var max = $el.data("max");
            if (!is_number(content) || (min !== undefined && num < min) ||
                    (max !== undefined && num > max)) {
                $el.siblings(".inshiny-text-box").addClass("inshiny-invalid");
                content = Number($el.data("default"));
            } else {
                $el.siblings(".inshiny-text-box").removeClass("inshiny-invalid");
                content = num; // format as number
            }
            $el.attr("aria-valuenow", content);
        } else if ($el.hasClass("inshiny-date-form")) {
            // Date: textbox contents can be parsed and then reformatted
            // and still the same, and date not disallowed or outside range.
            var proposed_date = parse_date_dp(content, $datepicker);
            var formatted = format_date_dp(proposed_date, $datepicker);
            if (datepicker.dateIsDisabled(proposed_date) ||
                    !datepicker.dateWithinRange(proposed_date) ||
                    content != formatted) {
                $el.siblings(".inshiny-text-box").addClass("inshiny-invalid");
                content = $el.data("default");
                $el.data("value", $el.data("default"));
            } else {
                $el.siblings(".inshiny-text-box").removeClass("inshiny-invalid");
                content = format_date_iso(proposed_date);
                $el.data("value", format_date_iso(proposed_date));
            }
        }
    }

    // If this is a number form with associated slider, update the slider
    if ($el.hasClass("inshiny-with-slider")) {
        var slider = slider_of(el).data("ionRangeSlider");
        if (slider.result.from != content) {
            $el.data("dontChange", true);
            slider.update({ from: content });
            $el.data("dontChange", false);
        }
    }
    // Similarly, update any associated datepicker
    if ($el.hasClass("inshiny-with-datepicker")) {
        if (format_date_iso(datepicker.getUTCDate()) != content) {
            datepicker.update(format_date_dp(content, $datepicker));
        }
    }

    return content;
}

// Resize selectize spacer
function resize_select($el) {
    var $spacer = $el.parent().parent().siblings(".inshiny-sel-spacer");

    // Give the spacer room before measuring, so that the selected items are
    // laid out on one line. Measured while they are wrapped, they report the
    // width they have been squeezed into rather than the width they need.
    $spacer.outerWidth(10000, true);

    var width = 0;
    $el.children().each(function() {
        width += $(this).outerWidth(true);
    });
    width = "calc(" + width + "px + 1.5rem)";
    $spacer.outerWidth(width, true);

    // Put an open menu back in line with the resized widget
    var select = $el.closest(".inshiny-sel").find("select")[0];
    if (select && select.selectize && select.selectize.isOpen) {
        select.selectize.positionDropdown();
    }
}

// Size a selectize widget's spacer and keep it in step with the widget
function bind_select(el) {
    unbind_select(el);

    var $el = $(el).parent().find(".selectize-input");
    if (!$el.length) return;

    // Set initial width of the select element
    resize_select($el);

    // Keep track of changes to the select element
    var resize_observer = new MutationObserver(function() {
        resize_select($el);
    });

    // Configuration of the observer
    var resize_config = {
        subtree: true,
        characterData: true,
        childList: true
    };

    resize_observer.observe($el[0], resize_config);

    $(el).data("inshinyResizeObserver", resize_observer);
}

// Stop keeping a selectize widget's spacer in step with the widget
function unbind_select(el) {
    var resize_observer = $(el).data("inshinyResizeObserver");
    if (resize_observer) {
        resize_observer.disconnect();
        $(el).removeData("inshinyResizeObserver");
    }
}


// GENERAL SETUP

// Inject styles derived from the active Bootstrap theme.
// Can be called multiple times.
var styles_injected = false;
function ensure_styles() {
    if (styles_injected) return;
    styles_injected = true;

    // Create form text colour style
    // Taken from .form-control
    const form_color = get_css_property("input", "form-control", "color", false);
    var form_style = document.createElement("style");
    form_style.type = "text/css";
    form_style.innerHTML = ".inshiny-text-form { color: " + form_color + "; }";
    document.head.appendChild(form_style);

    // Create fake focus style
    // In Boostrap themes, box-shadow is the only element of .focus-ring besides outline: 0.
    // We also change the border-color to --bs-primary.
    const box_shadow = get_css_property("input", "focus-ring", "box-shadow", true);
    var focus_style = document.createElement("style");
    focus_style.type = "text/css";
    focus_style.innerHTML = ".inshiny-focus { box-shadow: " + box_shadow + "; border-color: var(--bs-primary); }";
    document.head.appendChild(focus_style);
}

// Runs when DOM is ready, or runs immediately if the document is already loaded
// (which is the case when this script only arrives with dynamically rendered UI)
$(ensure_styles);

// Auto-dropup dropdowns when too close to bottom
$(document).on("shown.bs.dropdown", ".dropdown-center", function() {
    // calculate the required sizes, spaces
    var $ul = $(this).children(".dropdown-menu");
    var $button = $(this).children(":not(.dropdown-menu)");
    var ulOffset = $ul.offset();
    // how much space would be left on the top if the dropdown opened that direction
    var spaceUp = (ulOffset.top - $button.height() - $ul.height()) - $(window).scrollTop();
    // how much space is left at the bottom
    var spaceDown = $(window).scrollTop() + $(window).height() - (ulOffset.top + $ul.height());
    // switch to dropup only if there is no space at the bottom AND there is space at the top, or there isn't either but it would be still better fit
    if (spaceDown < 0 && (spaceUp >= 0 || spaceUp > spaceDown)) {
        $(this).addClass("dropup dropup-center");
    }
}).on("hidden.bs.dropdown", ".dropdown-center", function() {
    // always reset after close
    $(this).removeClass("dropup dropup-center");
});


// EVENT HANDLERS

// Delegated from document, so they also apply to widgets added after load.

// Prevent newline
$(document).on("keydown", ".inshiny-text-form", function(e) {
    if (e.which == 13) { // return
        e.preventDefault();
    }
});

// Prevent newlines in paste
$(document).on("paste", ".inshiny-text-form", function (event) {
    event.preventDefault();

    // Strip newlines
    var text = (event.originalEvent.clipboardData || window.clipboardData).getData("text");
    text = text.replace(/\r?\n|\r/g, " ");

    // Insert text at caret
    const selection = window.getSelection();
    if (!selection.rangeCount) return;
    const range = selection.getRangeAt(0);
    range.deleteContents();
    range.insertNode(document.createTextNode(text));

    // Move caret after inserted text
    range.collapse(false);
    selection.removeAllRanges();
    selection.addRange(range);
});

// Focus event
$(document).on("focus", ".inshiny-text-form", function() {
    $(this).siblings(".inshiny-text-box").addClass("inshiny-focus");
});

// Blur event
$(document).on("blur", ".inshiny-text-form", function() {
    $(this).siblings(".inshiny-text-box").removeClass("inshiny-focus");
});

// Defer focus to form-text
$(document).on("click", ".inshiny-text-placeholder", function() {
    $(this).siblings(".inshiny-text-form")[0].focus();
});

$(document).on("click", ".inshiny-text-rightpadding", function() {
    let sel = window.getSelection();
    sel.selectAllChildren($(this).siblings(".inshiny-text-form")[0]);
    sel.collapseToEnd();
});

// Click-up event
$(document).on("mousedown", ".inshiny-inc", function() {
    adjust_number(number_of(this), +1);
});

// Click-down event
$(document).on("mousedown", ".inshiny-dec", function() {
    adjust_number(number_of(this), -1);
});

// Arrow keys up/down
$(document).on("keydown", ".inshiny-number-form", function(e) {
    if (e.which == 38) {
        adjust_number(this, +1); // Up arrow
    } else if (e.which == 40) {
        adjust_number(this, -1); // Down arrow
    } else if (e.which == 33) {
        adjust_number(this, +10); // Page up
    } else if (e.which == 34) {
        adjust_number(this, -10); // Page down
    } else if (e.which == 35) {
        adjust_number(this, +99); // End
    } else if (e.which == 36) {
        adjust_number(this, -99); // Home
    } else {
        return;
    }
    e.preventDefault();
});

// Toggle labels and aria-checked on switch toggle
$(document).on("change", ".inshiny-switch", function() {
    set_switch_label($(this));
});

// Note, these two item select handlers are attached to the document
// and delegated to each item. This ensures changes to choices don't
// get lost and avoids the need to attach one handler for each item.
$(document).on("click", ".inshiny-menu .inshiny-item", function() {
    list_select($(this));
});

$(document).on("keydown", ".inshiny-menu .inshiny-item", function(e) {
    // Enter or space
    if (e.which == 13 || e.which == 32) {
        list_select($(this));
        e.preventDefault();
    }
});

// This below causes the dropdown to open on keyboard select. This is
// obviously useful to have and otherwise it's not there. We have to only
// take keyboard focus events here (hence :focus-visible) as otherwise this
// interferes with data-bs-toggle on mouse events.
$(document).on("focus", ".inshiny-list-form", function() {
    if (this.matches(":focus-visible")) {
        var $dropdown = dropdown_of(this);
        const dropdown = bootstrap.Dropdown.getInstance($dropdown) ||
            new bootstrap.Dropdown($dropdown);
        dropdown.show();
    }
});


// INPUT BINDINGS

// Text, number, slider, date and single select widgets all share the
// .inshiny-text-form span, so one binding serves them all.
var text_binding = new Shiny.InputBinding();

$.extend(text_binding, {
    find: function(scope) {
        return $(scope).find(".inshiny-text-form");
    },

    initialize: function(el) {
        ensure_styles();

        var $el = $(el);

        // Bind number forms to their sliders
        if ($el.hasClass("inshiny-with-slider")) {
            bind_slider(el);
        }

        // Bind date forms to their datepickers
        if ($el.hasClass("inshiny-with-datepicker")) {
            bind_datepicker(el);
        }
    },

    getValue: function(el) {
        return process_text_form(el);
    },

    getType: function(el) {
        return $(el).hasClass("inshiny-date-form") ? "shiny.date" : null;
    },

    subscribe: function(el, callback) {
        // A mutation observer is used rather than jQuery's change event, as
        // the inshiny-text-form elements are spans, and the change event only
        // works for input, select, and textarea elements.
        var observer = new MutationObserver(function() {
            callback(false);
        });

        observer.observe(el, {
            subtree: true,
            characterData: true,
            childList: true
        });

        $(el).data("inshinyObserver", observer);
    },

    unsubscribe: function(el) {
        var observer = $(el).data("inshinyObserver");
        if (observer) {
            observer.disconnect();
            $(el).removeData("inshinyObserver");
        }
    },

    receiveMessage: function(el, data) {
        var $target = $(el);

        if ($target.hasClass("inshiny-date-form")) {
            // date: value (NULL), min (NULL), max (NULL), datesdisabled (NULL), daysofweekdisabled (NULL)
            var $datepicker = datepicker_of(el);
            var datepicker = $datepicker.data().datepicker;
            var curr_date = datepicker.getUTCDate();
            if (data.min !== undefined) {
                if (data.min === null) datepicker.setStartDate(null);
                else datepicker.setStartDate(new Date(data.min));
            }
            if (data.max !== undefined) {
                if (data.max === null) datepicker.setEndDate(null);
                else datepicker.setEndDate(new Date(data.max));
            }
            if (data.datesdisabled !== undefined) {
                if (data.datesdisabled === null) {
                    datepicker.setDatesDisabled(null);
                } else {
                    var ddis;
                    if (!Array.isArray(data.datesdisabled)) {
                        ddis = [new Date(data.datesdisabled)];
                    } else {
                        ddis = data.datesdisabled.map(x => new Date(x))
                    }
                    datepicker.setDatesDisabled(ddis);
                }
            }
            if (data.daysofweekdisabled !== undefined) {
                datepicker.setDaysOfWeekDisabled(data.daysofweekdisabled);
            }
            datepicker.setUTCDate(curr_date); // need to reset this after above changes
            if (data.value === null) {
                $target.text(format_date_dp(Date(), $datepicker)); // current date
            } else if (data.value !== undefined) {
                $target.text(format_date_dp(data.value, $datepicker)); // accepts Date or string
            }
        } else if ($target.hasClass("inshiny-list-form")) {
            // select: choices
            if (data.choices != null) {
                menu_of(el)[0].innerHTML = data.choices;
            }
        } else if ($target.hasClass("inshiny-with-slider")) {
            // slider: min, max, step (NULL), default
            var min = Number(deflt(data.min, $target.data("min")));
            var max = Number(deflt(data.max, $target.data("max")));
            var step = defln(data.step, $target.data("step"));
            if (step === null) { // auto choose step if null -- same method as in slider.R.
                let range = max - min;
                if (range < 2 || max != Math.floor(max) || min != Math.floor(min)) {
                    step = Math.pow(10, Math.round(Math.log10(range / 100)));
                } else {
                    step = 1;
                }
            }
            // numeric input
            $target.data({
                "min":     min,
                "max":     max,
                "step":    step,
                "default": deflt(data.default, $target.data("default"))
            });
            $target.attr({
                "aria-valuemin": min,
                "aria-valuemax": max
            })
            // slider
            slider_of(el).data("ionRangeSlider").update({
                min: min,
                max: max,
                step: step
            })
        } else if ($target.hasClass("inshiny-number-form")) {
            // numeric: min (NULL), max (NULL), step (NULL), default
            $target.data({
                "min":     deflt(data.min,     $target.data("min")),
                "max":     deflt(data.max,     $target.data("max")),
                "step":    deflt(data.step,    $target.data("step")),
                "default": deflt(data.default, $target.data("default"))
            });
            if (data.min === null)  rm_data($target, "min");
            if (data.max === null)  rm_data($target, "max");
            if (data.step === null) rm_data($target, "step");
            $target.attr({
                "aria-valuemin": defln(data.min, $target.attr("aria-valuemin")),
                "aria-valuemax": defln(data.max, $target.attr("aria-valuemax"))
            })
        }
        // all text except date: value, placeholder
        if (data.value != null && !$target.hasClass("inshiny-date-form")) {
            $target.text(data.value);
        }
        if (data.placeholder != null) {
            $target.attr("aria-placeholder", plain_text(data.placeholder));
            $target.siblings(".inshiny-text-placeholder")[0].innerHTML = data.placeholder;
        }
        if (data.meaning !== undefined) {
            $target.attr("aria-label", data.meaning);
        }
    }
});

// Registered at a lower priority than Shiny's own bindings so that it
// initializes after them: bind_slider and bind_datepicker need the slider and
// datepicker that those bindings create.
Shiny.inputBindings.register(text_binding, "inshiny.text", -1);

// The remaining widgets are built on Shiny's own inputs, so their bindings are
// based on Shiny's. Only the elements the binding applies to and the handling
// of update messages differ; everything else is inherited.
function extend_binding(name, selector, receive) {
    var base = Shiny.inputBindings.bindingNames[name].binding;
    var binding = Object.create(base);

    binding.find = function(scope) {
        return $(scope).find(selector);
    };

    binding.receiveMessage = function(el, data) {
        return receive.call(this, base, el, data);
    };

    return binding;
}

// Switch widgets, which Shiny sees as checkboxes
var switch_binding = extend_binding("shiny.checkboxInput", ".inshiny-switch",
    function(base, el, data) {
        // switch: value, on, off
        var $target = $(el);
        $target.data({
            "on-label":  deflt(data.on,  $target.data("on-label")),
            "off-label": deflt(data.off, $target.data("off-label"))
        });
        if (data.value != null) {
            $target[0].checked = data.value;
            $target[0].dispatchEvent(new Event('change', { bubbles: true }));
        }
        set_switch_label($target);
        if (data.meaning !== undefined) {
            $target.attr("aria-label", data.meaning);
        }
    });

// Link and button widgets, which Shiny sees as action buttons
var action_binding = extend_binding("shiny.actionButtonInput",
    ".inshiny-link, .inshiny-btn",
    function(base, el, data) {
        // link, button: label, icon, accent
        // get existing icon & label, replace with any new ones
        var $target = $(el);
        var $icon = $target.children("i:first[role=presentation]");
        var $label = $target.contents().not($icon);
        var icon = $icon.length ? $icon.prop("outerHTML") : "";
        var label = $label.length ? $label.clone().wrapAll("<div>").parent().html() : "";
        if (data.icon !== undefined)  icon = data.icon || "";
        if (data.label !== undefined) label = data.label || "";
        var new_content = icon + " " + label.trim();
        $target[0].innerHTML = new_content;

        // element-specific actions
        if ($target.hasClass("inshiny-link")) {
            // link - replace accent
            if (data.accent !== undefined) {
                $target.removeClass(function(i, n) { return (n.match(/(^|\s)link-\S+/g) || []).join(' '); });
                if (data.accent !== null) {
                    $target.addClass("link-" + data.accent);
                }
            }
        } else {
            // button - modify spacer and replace accent
            $target.siblings(".inshiny-btn-spacer")[0].innerHTML = new_content;
            if (data.accent !== undefined) {
                $target.removeClass(function(i, n) { return (n.match(/(^|\s)btn-\S+/g) || []).join(' '); });
                $target.addClass("btn-" + deflt(data.accent, "default"));
            }
        }
        if (data.meaning !== undefined) {
            $target.attr("aria-label", data.meaning);
        }
    });

// Multiple select widgets, which are Shiny select inputs
var select_binding = extend_binding("shiny.selectInput",
    ".inshiny-sel .shiny-input-select",
    function(base, el, data) {
        // selectize: options, selected
        var $target = $(el);
        var select_data = {};
        if (data.options !== undefined) {
            select_data.options = data.options;
        }
        if (data.selected !== undefined) {
            select_data.value = data.selected === null ? [] : data.selected;
        }
        if (data.meaning !== undefined) {
            $target.attr("aria-label", data.meaning);
        }

        // Shiny's binding replaces the selectize control, so the new one is
        // bound once it has done so
        return Promise.resolve(base.receiveMessage.call(this, el, select_data))
            .then(function() {
                bind_select(el);
            });
    });

Shiny.inputBindings.register(switch_binding, "inshiny.switch", 1);
Shiny.inputBindings.register(action_binding, "inshiny.action", 1);
Shiny.inputBindings.register(select_binding, "inshiny.select", 1);


// INLINE SELECTIZE WIDGET

// Selectize controls belong to Shiny's own select input binding, so their
// spacer is sized when that binding reports the element as bound.
$(document).on("shiny:bound", ".inshiny-sel .shiny-input-select", function(e) {
    if (e.bindingType !== "input") return;

    bind_select(this);
});

$(document).on("shiny:unbound", ".inshiny-sel .shiny-input-select", function(e) {
    if (e.bindingType !== "input") return;

    unbind_select(this);
});
