diff --git a/DESCRIPTION b/DESCRIPTION index 99424402d..c2375b6de 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -61,6 +61,7 @@ Collate: 'deprecated.R' 'files.R' 'imports.R' + 'input-button-group.R' 'layout.R' 'nav-items.R' 'nav-update.R' diff --git a/NAMESPACE b/NAMESPACE index 4d6bc01d1..b83807616 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -61,6 +61,8 @@ export(font_collection) export(font_face) export(font_google) export(font_link) +export(input_check_buttons) +export(input_radio_buttons) export(is.card_item) export(is_bs_theme) export(layout_column_wrap) @@ -94,6 +96,8 @@ export(showcase_left_center) export(showcase_top_right) export(theme_bootswatch) export(theme_version) +export(update_check_buttons) +export(update_radio_buttons) export(value_box) export(version_default) export(versions) diff --git a/R/input-button-group.R b/R/input-button-group.R new file mode 100644 index 000000000..019001fd7 --- /dev/null +++ b/R/input-button-group.R @@ -0,0 +1,135 @@ +#' Create a button group of radio/check boxes +#' +#' Use `input_check_buttons()` if multiple choices may be selected at once; otherwise, use `input_radio_buttons()` +#' +#' @inheritParams input_check_search +#' @param size size of the button group +#' @param bg a theme color to use for the btn modifier class +#' @export +input_check_buttons <- function(id, choices, ..., selected = NULL, size = c("md", "sm", "lg"), bg = "primary") { + size <- match.arg(size) + tag <- div( + id = id, + class = "btn-group bslib-toggle-buttons", + class = if (size != "md") paste0("btn-group-", size), + role = "group", + ..., + !!!input_buttons_container( + type = "checkbox", id = id, choices = choices, selected = selected, + size = size, bg = bg + ), + toggle_dependency() + ) + tag <- tag_require(tag, version = 5, caller = "input_check_buttons()") + as_fragment(tag) +} + +#' @export +#' @rdname input_check_buttons +update_check_buttons <- function(id, choices = NULL, selected = NULL, session = get_current_session()) { + if (!is.null(choices)) { + choices <- processDeps( + input_buttons_container(type = "checkbox", id, choices, selected), + session + ) + } + message <- dropNulls(list( + choices = choices, + selected = as.list(selected) + )) + session$sendInputMessage(id, message) +} + +#' @export +#' @rdname input_check_buttons +input_radio_buttons <- function(id, choices, ..., selected = NULL, size = c("md", "sm", "lg"), bg = "primary") { + size <- match.arg(size) + tag <- div( + id = id, + class = "btn-group bslib-toggle-buttons", + class = if (size != "md") paste0("btn-group-", size), + role = "group", + ..., + !!!input_buttons_container( + type = "checkbox", id = id, choices = choices, selected = selected, + size = size, bg = bg + ), + toggle_dependency() + ) + tag <- tag_require(tag, version = 5, caller = "input_radio_buttons()") + as_fragment(tag) +} + +#' @export +#' @rdname input_check_buttons +update_radio_buttons <- function(id, choices = NULL, selected = NULL, session = get_current_session()) { + + if (!is.null(choices)) { + choices <- processDeps( + input_buttons_container(type = "radio", id, choices, selected), + session + ) + } + message <- dropNulls(list( + choices = choices, + selected = as.list(selected) + )) + session$sendInputMessage(id, message) +} + + +input_buttons_container <- function(type = c("radio", "checkbox"), id, choices, selected, size = "md", bg = "primary") { + + 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_checked <- vapply(vals, function(x) isTRUE(x %in% selected) || identical(I("all"), selected), logical(1)) + + if (!any(is_checked) && !identical(selected, I("none"))) { + is_checked[1] <- TRUE + } + + type <- match.arg(type) + if (type == "radio" && sum(is_checked) > 1) { + stop("input_radio_buttons() doesn't support more than one selected choice (do you want input_check_buttons() instead?)", call. = FALSE) + } + + inputs <- Map( + vals, choices, is_checked, paste0(id, "-", seq_along(is_checked)), + f = function(val, lbl, checked, this_id) { + list( + tags$input( + type = type, class = "btn-check", name = id, + id = this_id, autocomplete = "off", + `data-value` = val, + checked = if (checked) NA + ), + tags$label( + class = paste0("btn btn-outline-", bg), + `for` = this_id, lbl + ) + ) + } + ) + + inputs <- unlist(inputs, recursive = FALSE, use.names = FALSE) +} + +toggle_dependency <- function() { + htmltools::htmlDependency( + "bslib-toggle-buttons", + version = get_package_version("bslib"), + package = "bslib", + src = "components", + script = "toggle-buttons.js" + ) +} diff --git a/inst/components/toggle-buttons.js b/inst/components/toggle-buttons.js new file mode 100644 index 000000000..23455e052 --- /dev/null +++ b/inst/components/toggle-buttons.js @@ -0,0 +1,47 @@ +var toggleButtonsInputBinding = new Shiny.InputBinding(); +$.extend(toggleButtonsInputBinding, { + + find: function(scope) { + return $(scope).find(".btn-group.bslib-toggle-buttons"); + }, + + getValue: function(el) { + var inputs = $(el).find("input.btn-check"); + var vals = []; + inputs.each(function(i) { + if (this.checked) { + vals.push($(this).attr("data-value")); + } + }); + return vals.length > 0 ? vals : null; + }, + + subscribe: function(el, callback) { + $(el).on( + 'change.toggleButtonsInputBinding', + function(event) { callback(true); } + ); + }, + + unsubscribe: function(el) { + $(el).off(".toggleButtonsInputBinding"); + }, + + receiveMessage: function(el, data) { + if (data.hasOwnProperty("choices")) { + Shiny.renderContent(el, data.choices); + } else if (data.hasOwnProperty("selected")) { + const inputs = $(el).find("input"); + inputs.each(function(i) { + const val = $(this).attr("data-value"); + const checked = data.selected.indexOf(val) > -1; + this.checked = checked; + }); + } + + $(el).trigger("change.toggleButtonsInputBinding"); + } + +}); + +Shiny.inputBindings.register(toggleButtonsInputBinding); diff --git a/man/input_check_buttons.Rd b/man/input_check_buttons.Rd new file mode 100644 index 000000000..e8b62cd76 --- /dev/null +++ b/man/input_check_buttons.Rd @@ -0,0 +1,55 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/input-button-group.R +\name{input_check_buttons} +\alias{input_check_buttons} +\alias{update_check_buttons} +\alias{input_radio_buttons} +\alias{update_radio_buttons} +\title{Create a button group of radio/check boxes} +\usage{ +input_check_buttons( + id, + choices, + ..., + selected = NULL, + size = c("md", "sm", "lg"), + bg = "primary" +) + +update_check_buttons( + id, + choices = NULL, + selected = NULL, + session = get_current_session() +) + +input_radio_buttons( + id, + choices, + ..., + selected = NULL, + size = c("md", "sm", "lg"), + bg = "primary" +) + +update_radio_buttons( + id, + choices = NULL, + selected = NULL, + session = get_current_session() +) +} +\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{size}{size of the button group} + +\item{bg}{a theme color to use for the btn modifier class} +} +\description{ +Use \code{input_check_buttons()} if multiple choices may be selected at once; otherwise, use \code{input_radio_buttons()} +} 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 +}