From 07de69becb775fee77ab26aa68c19d083709ef62 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 10 Feb 2023 13:45:07 -0600 Subject: [PATCH 1/3] Add input_check_search() --- DESCRIPTION | 1 + NAMESPACE | 2 + R/input-check-search.R | 106 ++++++++++++++++++++++++++++++ inst/components/check-search.js | 106 ++++++++++++++++++++++++++++++ inst/components/check-search.scss | 25 +++++++ 5 files changed, 240 insertions(+) create mode 100644 R/input-check-search.R create mode 100644 inst/components/check-search.js create mode 100644 inst/components/check-search.scss diff --git a/DESCRIPTION b/DESCRIPTION index 99424402d..089323c43 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -61,6 +61,7 @@ Collate: 'deprecated.R' 'files.R' 'imports.R' + 'input-check-search.R' 'layout.R' 'nav-items.R' 'nav-update.R' diff --git a/NAMESPACE b/NAMESPACE index 4d6bc01d1..b9612c7b4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -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) @@ -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) diff --git a/R/input-check-search.R b/R/input-check-search.R new file mode 100644 index 000000000..421e2403c --- /dev/null +++ b/R/input-check-search.R @@ -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", + 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" + ) +} diff --git a/inst/components/check-search.js b/inst/components/check-search.js new file mode 100644 index 000000000..e69e2d74d --- /dev/null +++ b/inst/components/check-search.js @@ -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); diff --git a/inst/components/check-search.scss b/inst/components/check-search.scss new file mode 100644 index 000000000..960318192 --- /dev/null +++ b/inst/components/check-search.scss @@ -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; + } +} From 68a584cec484e46e270e0c434be181029fb30dde Mon Sep 17 00:00:00 2001 From: cpsievert Date: Fri, 10 Feb 2023 19:48:49 +0000 Subject: [PATCH 2/3] `devtools::document()` (GitHub Actions) --- man/input_check_search.Rd | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 man/input_check_search.Rd diff --git a/man/input_check_search.Rd b/man/input_check_search.Rd new file mode 100644 index 000000000..8efc9d3f6 --- /dev/null +++ b/man/input_check_search.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/input-check-search.R +\name{input_check_search} +\alias{input_check_search} +\title{A searchable list of checkboxes} +\usage{ +input_check_search( + id, + choices, + selected = NULL, + placeholder = "🔍 Search", + height = NULL, + width = NULL +) +} +\arguments{ +\item{id}{an input id.} + +\item{choices}{a vector/list of choices. If there are names on the on the vector, those names are used as the input value.} + +\item{selected}{a vector/list of choices to select by default.} + +\item{placeholder}{some text to appear when no search input is provided} + +\item{height}{a valid CSS unit for the height of the input.} +} +\description{ +A searchable list of checkboxes +} From e847d5ab1952237b3a67f9b42341e3dfbbb19e8c Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 10 Feb 2023 14:00:47 -0600 Subject: [PATCH 3/3] Updates/fixes --- R/bs-theme.R | 1 + R/input-check-search.R | 11 +++++++---- .../{check-search.js => input_check_search.js} | 0 .../{check-search.scss => input_check_search.scss} | 0 4 files changed, 8 insertions(+), 4 deletions(-) rename inst/components/{check-search.js => input_check_search.js} (100%) rename inst/components/{check-search.scss => input_check_search.scss} (100%) diff --git a/R/bs-theme.R b/R/bs-theme.R index 08dbf86e4..f92c91da6 100644 --- a/R/bs-theme.R +++ b/R/bs-theme.R @@ -276,6 +276,7 @@ bootstrap_bundle <- function(version) { !!!rule_bundles(c( system_file("components", "accordion.scss", package = "bslib"), system_file("components", "card.scss", package = "bslib"), + system_file("components", "input_check_search.scss", package = "bslib"), system_file("components", "value_box.scss", package = "bslib"), system_file("components", "layout_column_wrap.scss", package = "bslib") )) diff --git a/R/input-check-search.R b/R/input-check-search.R index 421e2403c..4b2467e35 100644 --- a/R/input-check-search.R +++ b/R/input-check-search.R @@ -12,7 +12,10 @@ input_check_search <- function(id, choices, selected = NULL, placeholder = "🔍 tag <- div( id = id, class = "bslib-check-search", - style = css(height = height, width = width), + style = css( + height = validateCssUnit(height), + width = validateCssUnit(width) + ), tags$a(class = "clear-options", role = "button", "Clear all"), tags$input( type = "text", @@ -33,9 +36,9 @@ input_check_search <- function(id, choices, selected = NULL, placeholder = "🔍 #' @export -update_check_search <- function(id, choices = NULL, selected = NULL, placeholder = NULL, height = NULL, session = shiny::getDefaultReactiveDomain()) { +update_check_search <- function(id, choices = NULL, selected = NULL, placeholder = NULL, height = NULL, session = get_current_session()) { if (!is.null(choices)) { - choices <- process_ui( + choices <- processDeps( check_search_choices(id, choices, selected), session ) @@ -101,6 +104,6 @@ check_search_dependency <- function() { version = get_package_version("bslib"), package = "bslib", src = "components", - script = "check-search.js" + script = "input_check_search.js" ) } diff --git a/inst/components/check-search.js b/inst/components/input_check_search.js similarity index 100% rename from inst/components/check-search.js rename to inst/components/input_check_search.js diff --git a/inst/components/check-search.scss b/inst/components/input_check_search.scss similarity index 100% rename from inst/components/check-search.scss rename to inst/components/input_check_search.scss