AO3: [Wrangling] Smaller Tag Search

makes the Tag Search form take up less space (best on desktop/landscape screens)

当前为 2025-03-18 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         AO3: [Wrangling] Smaller Tag Search
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @version      5.1
// @description  makes the Tag Search form take up less space (best on desktop/landscape screens)
// @author       escctrl
// @match        *://*.archiveofourown.org/tags/search*
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js
// @require      https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js
// @require      https://update.greasyfork.org/scripts/491888/1355841/Light%20or%20Dark.js
// @grant        none
// @license      MIT
// ==/UserScript==

/* eslint-disable no-multi-spaces */
/* global jQuery, lightOrDark */

(function($) {
    'use strict';

    // --- THE USUAL INIT STUFF AT THE BEGINNING -------------------------------------------------------------------------------

    // on retry later, break off the rest of this script to avoid console errors
    if ($('#new_tag_search').length == 0) return;

    if(document.head.querySelector('link[href$="/font-awesome.min.css"]') === null)
        $("head").append(`<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">`);

    let cfg = 'smallertagsearch'; // name of dialog and localstorage used throughout
    let dlg = '#'+cfg;

    /* *** EXAMPLE STORAGE: equivalent of a Map() with settings for reducing size and default selections on pageload
        settings = [ ["text","1"],["labels", "n"],["tag","b"],["sort","b"],["btntxt", "y"],
                     ["deftype","Freeform"],["defstatus",""],["defsortby","name"],["defsortdir","asc"]
                   ];

        text: (1) next to each other, (2) below each other with labels
        tag & sort labels (above options): (y) yes, (n) no
        tag show as: (b) buttons, (s) select
        sort show as: (b) buttons, (s) select, (h) hide
        tag & sort buttontext (on options): (y) yes, (n) no
    */
    let settings = loadConfig();

    // config migration
    if (settings.get('defstatus') === 'T') settings.set('defstatus', 'canonical');
    else if (settings.get('defstatus') === 'F') settings.set('defstatus', 'noncanonical');

    // display text/icon for search parameters
    var type_alias = new Map([['Fandom',       ['Fandom',       `<i class="fa fa-archive"></i>`]],
                              ['Character',    ['Character',    `<i class="fa fa-user"></i>`]],
                              ['Relationship', ['Relationship', `<i class="fa fa-users"></i>`]],
                              ['Freeform',     ['Freeform',     `<i class="fa fa-tags"></i>`]],
                              ['',             ['Any Type',     `<i class="fa fa-asterisk"></i>`]]]);

    var stat_alias = new Map([['canonical',                  ['Canonical',         `<i class="fa fa-check-square"></i>`, 'Canonicals']],
                              ['noncanonical',               ['Not canonical',     `<i class="fa fa-square-o"></i>`,     'Any except Canonicals']],
                              ['synonymous',                 ['Syns',              `<i class="fa fa-code-fork"></i>`,    'Synonyms']],
                              ['canonical_synonymous',       ['In Filters',        `<i class="fa fa-filter"></i>`,       'Canonicals or Synonyms (appearing in filters)']],
                              ['noncanonical_nonsynonymous', ['Unfiltered',        `<i class="fa fa-low-vision"></i>`,   'Any except Canonicals or Synonyms (not appearing in filters)']],
                              ['',                           ['Any Status',        `<i class="fa fa-asterisk"></i>`,     'Any wrangling status']]]);

    var sort_alias = new Map([["name",       ["Name",          `<i class="fa fa-font"></i>`]],
                              ["created_at", ["Creation Date", `<i class="fa fa-calendar"></i>`]],
                              ["uses",       ["Uses",          `<i class="fa fa-bar-chart"></i>`]]]);

    // ASC/DESC translation for the different sort options: x -> [ASC, icon for ASC, DESC, icon for DESC]
    var dir_alias = new Map([["name",       ["A → Z",           `<i class="fa fa-sort-alpha-asc"></i>`,   "Z → A",           `<i class="fa fa-sort-alpha-desc"></i>`]],
                             ["created_at", ["oldest → newest", `<i class="fa fa-sort-amount-asc"></i>`,  "newest → oldest", `<i class="fa fa-sort-amount-desc"></i>`]],
                             ["uses",       ["fewest → most",   `<i class="fa fa-sort-numeric-asc"></i>`, "most → fewest",   `<i class="fa fa-sort-numeric-desc"></i>`]]]);
    let getAscDescAlias = (by, dir) => dir_alias.get(by)[ (dir == "asc" ? 0 : 2) ]; // function to retrieve readable name based on which sort-by is selected
    let getAscDescIcon  = (by, dir) => dir_alias.get(by)[ (dir == "asc" ? 1 : 3) ]; // function to retrieve matching icon based on which sort-by is selected

    // figure out which option currently needs to be selected, based on search parameters in the URL vs. configured defaults
    var opt_selected = new Map();
    let params = new URLSearchParams(document.location.search);
    ["tag_search[type]", "tag_search[wrangling_status]", "tag_search[sort_column]", "tag_search[sort_direction]"].forEach((name) => {
        // if we've already done a search, select that option again
        if (params.size !== 0) {
            if (params.get(name)) opt_selected.set(name, params.get(name)); // if this parameter was actually part of the URL
            else { // otherwise go with what AO3 selects as defaults on partial search strings
                switch (name) {
                    case "tag_search[type]":
                    case "tag_search[wrangling_status]":
                        opt_selected.set(name, "");
                        break;
                    case "tag_search[sort_column]": opt_selected.set(name, "name"); break;
                    case "tag_search[sort_direction]": opt_selected.set(name, "asc"); break;
                    default: break;
                }
            }
        }
        // otherwise pick the configured defaults
        else {
            switch (name) {
                case "tag_search[type]": opt_selected.set(name, settings.get("deftype")); break;
                case "tag_search[wrangling_status]": opt_selected.set(name, settings.get("defstatus")); break;
                case "tag_search[sort_column]": opt_selected.set(name, settings.get("defsortby")); break;
                case "tag_search[sort_direction]": opt_selected.set(name, settings.get("defsortdir")); break;
                default: break;
            }
        }
    });

    // --- CONFIGURATION DIALOG HANDLING -------------------------------------------------------------------------------

    createDialog();

    function createDialog() {

        // adding the jQuery stylesheet to style the dialog, and fixing the interference of AO3's styling
        if(document.head.querySelector('link[href$="/jquery-ui.css"]') === null) {
            // if the background is dark, use the dark UI theme to match
            let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "dark-hive" : "base";
            $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`);
        }
        $("head").append(`<style type="text/css">.ui-widget, ${dlg}, .ui-dialog .ui-dialog-buttonpane button {font-size: revert; line-height: 1.286;}
        ${dlg} form {box-shadow: revert; cursor:auto;}
        ${dlg} fieldset {background: revert; box-shadow: revert; margin-left: 0; margin-right: 0;}
        ${dlg} legend {font-size: inherit; height: auto; width: auto; opacity: inherit;}
        ${dlg} fieldset p { padding-left: 0; padding-right: 0; }
        ${dlg} select { width: auto; }
        ${dlg} #tagsearchdefaults label { width: 9em; display: inline-block; }
        </style>`);

        // wrapper div for the dialog
        $("#main").append(`<div id="${cfg}"></div>`);

        let selected = 'selected="selected"';
        let checked = 'checked="checked"';

        let deftype = "", defstatus = "", defsort = "", wstatshow = "";
        let hidewstat = settings.get("hidewstat")?.split(",") || []; // wrangling status options which user chose to hide
        type_alias.forEach(
            (v, k) => { deftype += `<option value="${k}" ${k === settings.get("deftype") ? selected : ""}>${v[0]}</option>`; }
        );
        stat_alias.forEach(
            (v, k) => {
                defstatus += `<option value="${k}" ${k === settings.get("defstatus") ? selected : ""}>${v[0]}</option>`;
                if (k !== "") wstatshow += `<label for="tagsearchdisplay_wstat_${k}">${v[1]} ${v[0]}</label><input type="checkbox" name="tagsearchdisplay_wstat_${k}" id="tagsearchdisplay_wstat_${k}"
                                            ${hidewstat.includes(k) ? "" : checked}>`;
            }
        );
        sort_alias.forEach(
            (v, k) => { defsort += `<option value="${k}-asc" ${k === settings.get("defsortby") && settings.get("defsortdir") === "asc" ? selected : ""}>${v[0]}, ${getAscDescAlias(k, 'asc')}</option>
                                    <option value="${k}-desc" ${k === settings.get("defsortby") && settings.get("defsortdir") === "desc" ? selected : ""}>${v[0]}, ${getAscDescAlias(k, 'desc')}</option>`; }
        );

        $(dlg).html(`<form>
            <fieldset><legend>Display</legend>
                <p><label for="tagsearchdisplay_text">Show Search Text and Fandom input fields</label><br />
                    <select name="tagsearchdisplay_text" id="tagsearchdisplay_text" style="width: 20em;">
                        <option value="1" ${settings.get('text')==="1" ? selected : ""}>next to each other, without labels</option>
                        <option value="2" ${settings.get('text')==="2" ? selected : ""}>below each other, with labels</option>
                    </select>
                </p>
                <p class="radiocontrol">Show the Tag Type and Status options as<br />
                    <label for="tag_buttons"><i class="fa fa-toggle-on" aria-hidden="true"></i> Buttons</label><input type="radio" name="tagsearchdisplay_tag" id="tag_buttons" value="b" ${settings.get('tag') === "b" ? checked : ""}>
                    <label for="tag_select"><i class="fa fa-caret-square-o-down" aria-hidden="true"></i> Dropdown</label><input type="radio" name="tagsearchdisplay_tag" id="tag_select" value="s" ${settings.get('tag') === "s" ? checked : ""}>
                </p>
                <p id="tagsearchwranglingstatus">Choose which Tag Status options to display. "Any" will always be displayed.<br />
                    ${ wstatshow }
                </p>
                <p class="radiocontrol">Show the Sort By and Direction options as<br />
                    <label for="sort_buttons"><i class="fa fa-toggle-on" aria-hidden="true"></i> Buttons</label><input type="radio" name="tagsearchdisplay_sort" id="sort_buttons" value="b" ${settings.get('sort') === "b" ? checked : ""}>
                    <label for="sort_select"><i class="fa fa-caret-square-o-down" aria-hidden="true"></i> Dropdown</label><input type="radio" name="tagsearchdisplay_sort" id="sort_select" value="s" ${settings.get('sort') === "s" ? checked : ""}>
                    <label for="sort_hide"><i class="fa fa-eye-slash" aria-hidden="true"></i> Hidden</label><input type="radio" name="tagsearchdisplay_sort" id="sort_hide" value="h" ${settings.get('sort') === "h" ? checked : ""}>
                </p>
                <p><label for="tagsearchdisplay_labels">Show labels above all options</label>
                    <input type="checkbox" name="tagsearchdisplay_labels" id="tagsearchdisplay_labels" ${settings.get('labels') === "y" ? checked : ""}><br />
                    <label for="tagsearchdisplay_btntxt">Show all buttons with icon + text</label>
                    <input type="checkbox" name="tagsearchdisplay_btntxt" id="tagsearchdisplay_btntxt" ${settings.get('btntxt') === "y" ? checked : ""}>
                </p>
            </fieldset>
            <fieldset id='tagsearchdefaults'>
                <legend>Defaults</legend>
                <p>Pick defaults for the tag type, wrangling status, and sort order.</p>
                <label for="deftype">Tag Type</label>
                <select name="tagsearchdefault_type" id="deftype">
                  ${ deftype }
                </select><br />
                <label for="defstatus">Wrangling Status</label>
                <select name="tagsearchdefault_status" id="defstatus" style="width: 12em;">
                  ${ defstatus }
                </select><br />
                <label for="defsort">Sort By</label>
                <select name="tagsearchdefault_sort" id="defsort" style="width: 20em;">
                  ${ defsort }
                </select>
            </fieldset>
            <!--<fieldset id='tagsearchquick'>
                <legend>Quick Search Buttons</legend>
            </fieldset>-->
        </form>`);

        // optimizing the size of the GUI in case it's a mobile device
        let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px)
        dialogwidth = dialogwidth > 600 ? 600 : dialogwidth * 0.9;

        // initialize the dialog (but don't open it)
        $( dlg ).dialog({
            appendTo: "#main",
            modal: true,
            title: 'Smaller Tag Search Config',
            draggable: true,
            resizable: false,
            autoOpen: false,
            width: dialogwidth,
            position: {my:"center", at: "center top"},
            buttons: {
                Reset: deleteConfig,
                Save: storeConfig,
                Cancel: function() { $( dlg ).dialog( "close" ); }
            }
        });

        // event triggers if form is submitted with the <enter> key
        $( dlg+" form" ).on("submit", (e) => {
            e.preventDefault();
            storeConfig();
        });

        // if no other script has created it yet, write out a "Userscripts" option to the main navigation
        if ($('#scriptconfig').length == 0) {
            $('#header ul.primary.navigation li.dropdown').last()
                .after(`<li class="dropdown" id="scriptconfig">
                    <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a>
                    <ul class="menu dropdown-menu"></ul></li>`);
        }

        // then add this script's config option to navigation dropdown
        $('#scriptconfig .dropdown-menu').append(`<li><a href="javascript:void(0);" id="opencfg_${cfg}">Smaller Tag Search</a></li>`);

        // on click, open the configuration dialog
        $("#opencfg_"+cfg).on("click", function(e) {
            $( dlg ).dialog('open');

            // turn checkboxes and radiobuttons into pretty buttons. only once the dialog is open bc sizing doesn't work correctly on hidden elements
            $( `${dlg} input[type='checkbox']` ).checkboxradio();
            $( `${dlg} select` ).selectmenu({ width: null });
            $( `${dlg} input[type='radio'], ${dlg} #tagsearchwranglingstatus input[type='checkbox']` ).checkboxradio({ icon: false });
            $( `${dlg} .radiocontrol` ).controlgroup();

        });
    }

    // --- LOCALSTORAGE MANIPULATION -------------------------------------------------------------------------------

    function loadConfig() {
        // load storage on page startup, or default values if there's no storage item
        return new Map(JSON.parse(localStorage.getItem(cfg) ||
                                  `[["text","1"],["labels","n"],["tag","b"],["sort","s"],["btntxt","n"],["deftype",""],["defstatus",""],["defsortby","name"],["defsortdir","asc"]]`));
    }

    function deleteConfig() {
        // deselects all buttons, empties all fields in the form
        $(dlg+' form').trigger("reset");

        // deletes the localStorage
        localStorage.removeItem(cfg);

        $( dlg ).dialog( "close" );
        location.reload();
    }

    function storeConfig() {
        // fill a Map() with the choices in the Config GUI
        let toStore = new Map();
        let hidewstat = [];

        // checkboxes: show labels?
        $(`${dlg} input[type='checkbox']`).each( function() {
            if ($(this).prop('name') == "tagsearchdisplay_labels") {
                if ($(this).prop('checked')) toStore.set('labels', "y");
                else toStore.set('labels', "n");
            }
            else if ($(this).prop('name') == "tagsearchdisplay_btntxt") {
                if ($(this).prop('checked')) toStore.set('btntxt', "y");
                else toStore.set('btntxt', "n");
            }
            else if ($(this).prop('name').startsWith("tagsearchdisplay_wstat_")) {
                let wstat = $(this).prop('name').slice(23);
                if (!$(this).prop('checked')) hidewstat.push(wstat);
            }
        } );
        // radiobuttions: how to show tag type/status and sort by/direction
        $(`${dlg} input[type='radio']:checked`).each( function() {
            if      ($(this).prop('name') == "tagsearchdisplay_tag")  toStore.set('tag', $(this).prop('value'));
            else if ($(this).prop('name') == "tagsearchdisplay_sort") toStore.set('sort', $(this).prop('value'));
        } );
        // selects: how many lines for textinput fields, what to select by default
        $(`${dlg} select`).each( function() {
            let name = $(this).prop('name'), value = $(this).prop('value');

            if (name == "tagsearchdisplay_text") toStore.set('text', value);
            else if (name == "tagsearchdefault_type") toStore.set('deftype', value);
            else if (name == "tagsearchdefault_status") toStore.set('defstatus', value);
            else if (name == "tagsearchdefault_sort") {
                toStore.set('defsortby', value.slice(0, value.indexOf("-")));
                toStore.set('defsortdir', value.slice(value.indexOf("-")+1));
            }
        } );

        // sets the localStorage (turn Map() into an Array for stringify to understand it)
        // btw this overwrites any old configurations, since we're still using the same key name
        toStore.set('hidewstat', hidewstat.join(','));
        localStorage.setItem(cfg, JSON.stringify(toStore.entries().toArray()));

        $( dlg ).dialog( "close" );
        location.reload();
    }

    // --- WRITING THE NEW TAG SEARCH -------------------------------------------------------------------------------

    // for the fields to move/wrap nicely no matter the screen width, we have to group: the two text fields vs. the four selectors (including their respective labels)
    $('#new_tag_search dl > *').slice(0,4).wrapAll('<div id="smallsearch_first"></div>');
    $('#new_tag_search dl > *').slice(1).wrapAll('<div id="smallsearch_second"></div>');

    // general CSS for the fields and flexbox for the four selects
    let custom_css = `
        #fandom-field-description { display: none; }
        #new_tag_search #smallsearch_second { display: flex; flex-flow: row wrap; column-gap: 1rem; row-gap: 0rem; }
        #new_tag_search #smallsearch_second dd { width: auto; }
        #new_tag_search dd li.input { margin: 0; }
        #new_tag_search input[type="text"]::placeholder { opacity: 0.5; font-style: italic; }
        `;

    // one-line display: flexbox to move underneath each other on small screens, hide the appropriate <label>s and their <dt>s
    if (settings.get('text') == "1") {
        $('#new_tag_search dt').hide();
        custom_css += `
            #new_tag_search #smallsearch_first { display: flex; flex-flow: row wrap; column-gap: 2%; row-gap: 0rem; align-items: flex-end; }
            #new_tag_search #smallsearch_first dd { width: 49%; flex-grow: 1; min-width: 15em; }
            `;

        // adding a placeholder text to the <input> fields since the labels are gone
        $('input#tag_search_name').prop('placeholder', 'Tag Name');
        $('input#tag_search_fandoms_autocomplete').prop('placeholder', 'Fandom');
    }
    // two-line display with labels in separate flexboxes, or the second label would always move up into the first row
    else {
        $('#new_tag_search #smallsearch_second dt').hide();

        $('#new_tag_search #smallsearch_first > *').slice(0,2).wrapAll('<div id="smallsearch_firstA"></div>');
        $('#new_tag_search #smallsearch_first > *').slice(1).wrapAll('<div id="smallsearch_firstB"></div>');
        custom_css += `
            #new_tag_search #smallsearch_firstA, #new_tag_search #smallsearch_firstB {
                display: flex; flex-flow: row wrap; column-gap: 1rem; row-gap: 0rem; align-items: flex-end;
            }
            #new_tag_search #smallsearch_first dt { float: none; align-self: start; max-width: 10em; }
            #new_tag_search #smallsearch_first dd { min-width: 15em; width: unset; flex-grow: 1; }
            `;
    }

    $("head").append("<style type='text/css'>" + custom_css + "</style>");

    let labels = settings.get('labels') == "y" ? true : false;
    let btntxt = settings.get('btntxt') == "y" ? true : false;

    // (code readability) calling functions that'll rewrite the choices into buttons or selects, per config
    writeTagTypeChoices();
    writeTagStatusChoices();
    if (settings.get('sort') === "h") {
        // hide the sort by/dir from view (but their original <select> are still there)
        $('#new_tag_search #smallsearch_second dd').slice(2).hide();
        // select the correct <option> in the background so default sort config will still work
        $('#new_tag_search select[name="tag_search[sort_column]"]').find(`option[value="${opt_selected.get("tag_search[sort_column]")}"]`).prop('selected', true);
        $('#new_tag_search select[name="tag_search[sort_direction]"]').find(`option[value="${opt_selected.get("tag_search[sort_direction]")}"]`).prop('selected', true);
    }
    else { // if not hidden, build them as buttons or selects
        writeSortByChoices();
        writeSortDirChoices();
    }

    // --- HELPER FUNCTIONS TO WRITE PAGE HTML -------------------------------------------------------------------------------

    function writeTagTypeChoices() {
        let choices = $('#new_tag_search input[name="tag_search[type]"]');
        let style = settings.get('tag') || "s";
        let html = `<div id="search_type_choice">`;
        if (labels) html += `<label for="tag_search[type]">Tag Type</label><br />`;

        if (style === "b") { // buttons in control group
            $(choices).each(function() {
                let alias = type_alias.get(this.value);
                html += `<label for="${this.id}" title="${alias[0]}">${alias[1]}
                    ${btntxt ? alias[0] : "" }</label><input type="radio" id="${this.id}" name="tag_search[type]" value="${this.value}"
                    ${ opt_selected.get("tag_search[type]") === this.value ? 'checked="checked"' : "" }>`;
            });
            html += "</div>";
            $('#new_tag_search #smallsearch_second dd:nth-of-type(1)').html(html);
            $('input[name="tag_search[type]"]').checkboxradio({ icon: false });
            $('#search_type_choice').controlgroup();
        }
        else if (style === "s") { // dropdown select
            html += `<select name="tag_search[type]" style="width: max-content">`;
            $(choices).each(function() {
                let alias = type_alias.get(this.value);
                html += `<option value="${this.value}">${alias[0]}</option>`;
            });
            html += "</select></div>";
            $('#new_tag_search #smallsearch_second dd:nth-of-type(1)').html(html); // write the new <select> to page
            $('#new_tag_search select[name="tag_search[type]"]').find(`option[value="${opt_selected.get("tag_search[type]")}"]`).prop('selected', true); // select the correct <option>
            if (settings.get('sort') === 'b') $('#new_tag_search select[name="tag_search[type]"]').selectmenu({ width: null }); // prettify
        }
    }

    function writeTagStatusChoices() {
        let choices = $('#new_tag_search input[name="tag_search[wrangling_status]"]');
        let style = settings.get('tag') || "s";
        let hidden = settings.get("hidewstat")?.split(",") || []; // wrangling status options which user chose to hide
        let html = `<div id="search_status_choice">`;
        if (labels) html += `<label for="tag_search[wrangling_status]">Tag Status</label><br />`;

        if (style === "b") { // buttons in control group
            $(choices).each(function() {
                if (!hidden.includes(this.value) || this.value === "" || opt_selected.get("tag_search[wrangling_status]") === this.value) {
                    let alias = stat_alias.get(this.value);
                    html += `<label for="${this.id}" title="${alias[2]}">${alias[1]}
                        ${btntxt ? alias[0] : "" }</label><input type="radio" id="${this.id}" name="tag_search[wrangling_status]" value="${this.value}"
                        ${ opt_selected.get("tag_search[wrangling_status]") === this.value ? 'checked="checked"' : "" }>`;
                }
            });
            html += "</div>";
            $('#new_tag_search #smallsearch_second dd:nth-of-type(2)').html(html);
            $('input[name="tag_search[wrangling_status]"]').checkboxradio({ icon: false });
            $('#search_status_choice').controlgroup();
        }
        else if (style === "s") { // dropdown select
            html += `<select name="tag_search[wrangling_status]" style="width: max-content">`;
            $(choices).each(function() {
                if (!hidden.includes(this.value) || this.value === "" || opt_selected.get("tag_search[wrangling_status]") === this.value) {
                    let alias = stat_alias.get(this.value);
                    html += `<option value="${this.value}">${alias[0]}</option>"`;
                }
            });
            html += "</select></div>";
            $('#new_tag_search #smallsearch_second dd:nth-of-type(2)').html(html); // write the new <select> to page
            $('#new_tag_search select[name="tag_search[wrangling_status]"]').find(`option[value="${opt_selected.get("tag_search[wrangling_status]")}"]`).prop('selected', true); // select the correct <option>
            if (settings.get('sort') === 'b') $('#new_tag_search select[name="tag_search[wrangling_status]"]').selectmenu({ width: null }); // prettify
        }
    }

    function writeSortByChoices() {
        let choices = $('#new_tag_search select[name="tag_search[sort_column]"] option');
        let style = settings.get('sort') || "s";
        let html = `<div id="search_sort_choice">`;

        if (style === "b") { // buttons in control group
            if (labels) html += `<label for="tag_search[sort_column]">Sort By</label><br />`;
            $(choices).each(function() {
                let alias = sort_alias.get(this.value);
                html += `<label for="tag_search_sort_${this.value}" title="${alias[0]}">${alias[1]}
                    ${btntxt ? alias[0] : "" }</label><input type="radio" id="tag_search_sort_${this.value}" name="tag_search[sort_column]" value="${this.value}"
                    ${ opt_selected.get("tag_search[sort_column]") === this.value ? 'checked="checked"' : "" }>`;
            });
            html += "</div>";
            $('#new_tag_search #smallsearch_second dd:nth-of-type(3)').html(html);
            // jQueryUI make it pretty
            $('input[name="tag_search[sort_column]"]').checkboxradio({ icon: false });
            $('#search_sort_choice').controlgroup();
            // change eventhandler (on any <input> = button within this controlgroup) to dynamically update the ASC/DESC labels
            $('#search_sort_choice').on('change', "input", function() { onSortByChange('BUTTON'); });
        }
        else if (style === "s") { // dropdown select
            let select = $('#new_tag_search select[name="tag_search[sort_column]"]').css('width', '15em');
            if (!labels) $(choices).prepend("Sort by "); // add the "sort by" text into the <option>s if the labels are hidden
            else $(select).before(`<label for="tag_search[sort_column]">Sort By</label><br />`);
            $(choices).each(function() {
                if (opt_selected.get("tag_search[sort_column]") === this.value) $(this).prop('selected', true);
            });
            // jQueryUI make it pretty (width null forces original size) - with a change eventhandler to dynamically update the ASC/DESC labels
            if (settings.get('tag') === 'b') $( select ).selectmenu({ width: null, change: function(event, ui) { onSortByChange('SELECT'); } });
            else $( select ).on('change', function() { onSortByChange('SELECT'); });
        }
    }

    function writeSortDirChoices() {
        let choices = $('#new_tag_search select[name="tag_search[sort_direction]"] option');
        let style = settings.get('sort') || "s";
        let html = `<div id="search_order_choice">`;

        if (style === "b") { // buttons in control group
            if (labels) html += `<label for="tag_search[sort_direction]">Sort Direction</label><br />`;
            $(choices).each(function() {
                let dir_readable = getAscDescAlias($('[name="tag_search[sort_column]"]:checked').prop('value'), this.value); // readable name depends on which sort-by is selected
                let dir_icon = !btntxt ? getAscDescIcon($('[name="tag_search[sort_column]"]:checked').prop('value'), this.value) : `<i class="fa fa-sort-amount-${this.value}"></i>`;
                html += `<label for="tag_search_sort_${this.value}" title="${dir_readable}">${dir_icon} ${btntxt ? dir_readable : "" }</label>
                    <input type="radio" id="tag_search_sort_${this.value}" name="tag_search[sort_direction]" value="${this.value}"
                    ${ opt_selected.get("tag_search[sort_direction]") === this.value ? 'checked="checked"' : "" }>`;
            });
            html += "</div>";
            $('#new_tag_search #smallsearch_second dd:nth-of-type(4)').html(html);
            // jQueryUI make it pretty
            $('input[name="tag_search[sort_direction]"]').checkboxradio({ icon: false });
            $('#search_order_choice').controlgroup();
        }
        else if (style === "s") { // dropdown select
            let select = $('#new_tag_search select[name="tag_search[sort_direction]"]').css('width', '13em');
            if (labels) $(select).before(`<label for="tag_search[sort_direction]">Sort Direction</label><br />`);
            // change ASC/DESC into something human-readable
            $(select).find('option').each(function() {
                let dir_readable = getAscDescAlias($('[name="tag_search[sort_column]"]').prop('value'), this.value);
                this.innerText = dir_readable;
                if (opt_selected.get("tag_search[sort_direction]") === this.value) $(this).prop('selected', true);
            });
            // jQueryUI make it pretty (width null forces original size)
            if (settings.get('tag') === 'b') $( select ).selectmenu({ width: null });
        }
    }

    // --- DELEGATED EVENT HANDLERS FOR REACTIVE PAGE -------------------------------------------------------------------------------

    // event handler listening to user changing the sort by field so we can update the text on the ASC/DESC
    function onSortByChange(elemType) {
        if (elemType === "SELECT") {
            // grab the now selected sort-by
            let new_sort_by =  $('[name="tag_search[sort_column]"]').prop('value');
            // update the labels of the ASC/DESC on our original form elements
            $('#new_tag_search [name="tag_search[sort_direction]"] option').each(function() {
                let dir_readable = getAscDescAlias(new_sort_by, this.value);
                this.innerText = dir_readable;
            });
            // refresh the jQueryUI elements to show the same new labels
            $('#new_tag_search select[name="tag_search[sort_direction]"]').selectmenu( "refresh" );
        }
        else {
            // grab the now selected sort-by
            let new_sort_by =  $('[name="tag_search[sort_column]"]:checked').prop('value');
            // update the labels of the ASC/DESC on our original form elements
            let dir_readable = getAscDescAlias(new_sort_by, 'asc');
            if (btntxt) $('#new_tag_search label[for="tag_search_sort_asc"]').prop('title', dir_readable).html(`<i class="fa fa-sort-amount-asc"></i> ${dir_readable}`);
            else $('#new_tag_search label[for="tag_search_sort_asc"]').prop('title', dir_readable).html(getAscDescIcon(new_sort_by, 'asc'));

            dir_readable = getAscDescAlias(new_sort_by, 'desc');
            if (btntxt) $('#new_tag_search label[for="tag_search_sort_desc"]').prop('title', dir_readable).html(`<i class="fa fa-sort-amount-desc"></i> ${dir_readable}`);
            else $('#new_tag_search label[for="tag_search_sort_desc"]').prop('title', dir_readable).html(getAscDescIcon(new_sort_by, 'desc'));
        }
    }

})(jQuery);