Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add input_check_search() #484

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Collate:
'deprecated.R'
'files.R'
'imports.R'
'input-check-search.R'
'layout.R'
'nav-items.R'
'nav-update.R'
Expand Down
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export(font_collection)
export(font_face)
export(font_google)
export(font_link)
export(input_check_search)
export(is.card_item)
export(is_bs_theme)
export(layout_column_wrap)
Expand Down Expand Up @@ -94,6 +95,7 @@ export(showcase_left_center)
export(showcase_top_right)
export(theme_bootswatch)
export(theme_version)
export(update_check_search)
export(value_box)
export(version_default)
export(versions)
Expand Down
106 changes: 106 additions & 0 deletions R/input-check-search.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#' A searchable list of checkboxes
#'
#' @param id an input id.
#' @param choices a vector/list of choices. If there are names on the on the vector, those names are used as the input value.
#' @param selected a vector/list of choices to select by default.
#' @param placeholder some text to appear when no search input is provided
#' @param height a valid CSS unit for the height of the input.
#'
#' @export
input_check_search <- function(id, choices, selected = NULL, placeholder = "🔍 Search", height = NULL, width = NULL) {

tag <- div(
id = id,
class = "bslib-check-search",
style = css(height = height, width = width),
tags$a(class = "clear-options", role = "button", "Clear all"),
tags$input(
type = "text",
id = paste0(id, "-search"),
class = "form-control form-control-sm",
class = "shiny-no-bind", # TODO: require shiny PR
placeholder = placeholder,
autocomplete = "off"
),
check_search_choices(id, choices, selected),
check_search_dependency()
)

tag <- tag_require(tag, version = 5, caller = "input_check_search")

as_fragment(tag)
}


#' @export
update_check_search <- function(id, choices = NULL, selected = NULL, placeholder = NULL, height = NULL, session = shiny::getDefaultReactiveDomain()) {
if (!is.null(choices)) {
choices <- process_ui(
check_search_choices(id, choices, selected),
session
)
}

message <- dropNulls(list(
choices = choices,
selected = as.list(selected), # make sure this is always a JS array
placeholder = placeholder,
height = height
))
session$sendInputMessage(id, message)
}

check_search_choices <- function(id, choices, selected) {
if (is.null(names(choices)) && is.atomic(choices)) {
names(choices) <- choices
}
if (is.null(names(choices))) {
stop("names() must be provided on list() vectors provided to choices")
}

vals <- rlang::names2(choices)
#if (!all(nzchar(vals))) {
# stop("Input values must be non-empty character strings")
#}

is_selected <- vapply(vals, function(x) {
isTRUE(x %in% selected) || identical(selected, I("all"))
}, logical(1))

checks <- unname(Map(
vals, choices, is_selected, paste0(id, "-", seq_along(is_selected)),
f = form_check
))

# Always bring selections to the top
idx <- c(which(is_selected), which(!is_selected))

div(
class = "check-search-choices",
!!!checks[idx]
)
}

form_check <- function(val, lbl, checked, this_id) {
div(
class = "form-check", `data-value` = val,
tags$input(
type = "checkbox",
class = "form-check-input",
class = "shiny-no-bind",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to self: try to remember why this is here...I think this'll require a shiny PR 😅

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id = this_id,
checked = if (checked) NA
),
tags$label(class = "form-check-label", `for` = this_id, lbl)
)
}

check_search_dependency <- function() {
htmlDependency(
"bslib-check-search",
version = get_package_version("bslib"),
package = "bslib",
src = "components",
script = "check-search.js"
)
}
106 changes: 106 additions & 0 deletions inst/components/check-search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
const checkSearchInputBinding = new Shiny.InputBinding();
$.extend(checkSearchInputBinding, {

find: function(scope) {
return $(scope).find(".bslib-check-search");
},

getValue: function(el) {
const inputs = $(el).find(".form-check-input");
let vals = [];
inputs.each(function(i) {
if (this.checked) {
vals.push($(this).parent(".form-check").attr("data-value"));
}
});
return vals.length > 0 ? vals : null;
},

subscribe: function(el, callback) {
const self = this;
$(el).on('change.checkSearch', function(event) {

const choices = $(event.target).parents(".check-search-choices");

// Move new selections to the top
const firstNotChecked = choices
.find("input:not(:checked)")
.parents(".form-check")
.last();
const thisForm = $(event.target).parent(".form-check");
firstNotChecked.before(thisForm);

// TODO: if we're unchecking a box, should we move it back to it's "original" position???

self._resolveClearVisibility(el);

callback(true);
});
},

unsubscribe: function(el) {
$(el).off(".checkSearchInputBinding");
},

initialize: function(el) {
el.oninput = onInput;

function onInput(e) {
const needle = e.target.value.toLowerCase();

const haystack = $(e.target.parentNode).find(".form-check");
haystack.each(function(i) {
const val = $(this).attr("data-value").toLowerCase();
const display = val.includes(needle) ? "" : "none";
$(this).css("display", display);
});
}

const clear = $(el).find(".clear-options");
const self = this;
clear.click(function() {
self.receiveMessage(el, {selected: []});
});

this._resolveClearVisibility(el);
},

receiveMessage: function(el, data) {
const $el = $(el);
if (data.hasOwnProperty("placeholder")) {
$el.find("input").attr("placeholder", data.placeholder);
return;
}
if (data.hasOwnProperty("height")) {
$el.css("height", data.height);
return;
}
// In this case, selected is already handled in the markup
if (data.hasOwnProperty("choices")) {
const choices = $el.find(".check-search-choices");
Shiny.renderContent(choices, data.choices);
} else if (data.hasOwnProperty("selected")) {
const checks = $el.find(".form-check");
checks.each(function(i) {
const val = $(this).attr("data-value");
const checked = data.selected.indexOf(val) > -1;
this.querySelector("input").checked = checked;
});
}

// Since we're possibly changed the input value at this point,
// trigger a subscribe() event, so that the input value will actually update
$el.trigger("change.checkSearch");

this._resolveClearVisibility(el);
},

_resolveClearVisibility: function(el) {
const clear = $(el).find(".clear-options");
const anySelected = $(el).find("input:checked").length > 0;
clear.css("visibility", anySelected ? "visible" : "hidden");
}

});

Shiny.inputBindings.register(checkSearchInputBinding);
25 changes: 25 additions & 0 deletions inst/components/check-search.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.bslib-check-search {
height: 200px;
width: fit-content;
width: -moz-fit-content;

.form-control {
position: sticky;
margin-bottom: 5px;
}

.clear-options {
visibility: hidden;
text-decoration: none;
float: right;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
}

.check-search-choices {
overflow: scroll;
height: 100%;
width: 100%;
padding-left: 0.2rem;
}
}