diff --git a/DESCRIPTION b/DESCRIPTION index c536d7246..33ff4c473 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -119,6 +119,7 @@ Collate: 'files.R' 'fill.R' 'imports.R' + 'input-button-group.R' 'input-dark-mode.R' 'input-switch.R' 'layout.R' diff --git a/NAMESPACE b/NAMESPACE index 3a9f6c9c7..070547179 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -73,7 +73,9 @@ export(font_collection) export(font_face) export(font_google) export(font_link) +export(input_check_buttons) export(input_dark_mode) +export(input_radio_buttons) export(input_switch) export(is.card_item) export(is_bs_theme) @@ -137,7 +139,9 @@ export(toggle_sidebar) export(toggle_switch) export(toggle_tooltip) export(tooltip) +export(update_check_buttons) export(update_popover) +export(update_radio_buttons) export(update_switch) export(update_tooltip) export(value_box) diff --git a/R/input-button-group.R b/R/input-button-group.R new file mode 100644 index 000000000..821be4e0f --- /dev/null +++ b/R/input-button-group.R @@ -0,0 +1,169 @@ +#' 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()` +#' +#' @param theme a theme color. +#' @export +input_check_buttons <- function( + id, + choices, + ..., + selected = NULL, + gap = 0, + theme = NULL +) { + + input_tags <- toggle_button_tags( + type = "checkbox", id = id, choices = choices, + selected = selected, theme = theme + ) + + res <- toggle_button_container( + id = id, + input_tags = input_tags, + gap = gap, + ... + ) + + as_fragment( + tag_require(res, version = 5, caller = "input_check_buttons()") + ) +} + +#' @export +#' @rdname input_check_buttons +input_radio_buttons <- function( + id, + choices, + ..., + selected = NULL, + gap = 0, + theme = NULL +) { + + input_tags <- toggle_button_tags( + type = "radio", id = id, choices = choices, + selected = selected, theme = theme + ) + + res <- toggle_button_container( + id = id, + input_tags = input_tags, + gap = gap, + ... + ) + + as_fragment( + tag_require(res, version = 5, caller = "input_radio_buttons()") + ) +} + + +#' @export +#' @rdname input_check_buttons +update_check_buttons <- function(id, choices = NULL, selected = NULL, session = get_current_session()) { + if (!is.null(choices)) { + choices <- processDeps( + toggle_button_tags(type = "checkbox", id, choices, selected), + session + ) + } + message <- dropNulls(list( + choices = choices, + selected = as.list(selected) + )) + session$sendInputMessage(id, message) +} + +#' @export +#' @rdname input_check_buttons +update_radio_buttons <- function(id, choices = NULL, selected = NULL, session = get_current_session()) { + if (!is.null(choices)) { + choices <- processDeps( + toggle_button_tags(type = "radio", id, choices, selected), + session + ) + } + message <- dropNulls(list( + choices = choices, + selected = as.list(selected) + )) + session$sendInputMessage(id, message) +} + + +# TODO: container should have an aria-label! +toggle_button_container <- function(id, input_tags, gap = 0, ...) { + + has_gap <- !identical(gap, 0) + + div( + id = id, + class = "bslib-toggle-buttons bslib-mb-spacing", + class = if (!has_gap) "btn-group", + style = css( + display = "flex", + gap = validateCssUnit(gap), + flexWrap = if (has_gap) "wrap" + ), + role = "group", + ..., + !!!input_tags, + toggle_dependency() + ) +} + + +toggle_button_tags <- function(type = c("radio", "checkbox"), id, choices, selected, theme = NULL) { + + 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) + } + + unname(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-", theme %||% "secondary"), + `for` = this_id, lbl + ) + ) + } + )) +} + +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..3968394fc --- /dev/null +++ b/man/input_check_buttons.Rd @@ -0,0 +1,33 @@ +% 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{input_radio_buttons} +\alias{update_check_buttons} +\alias{update_radio_buttons} +\title{Create a button group of radio/check boxes} +\usage{ +input_check_buttons(id, choices, ..., selected = NULL, gap = 0, theme = NULL) + +input_radio_buttons(id, choices, ..., selected = NULL, gap = 0, theme = NULL) + +update_check_buttons( + id, + choices = NULL, + selected = NULL, + session = get_current_session() +) + +update_radio_buttons( + id, + choices = NULL, + selected = NULL, + session = get_current_session() +) +} +\arguments{ +\item{theme}{a theme color.} +} +\description{ +Use \code{input_check_buttons()} if multiple choices may be selected at once; otherwise, use \code{input_radio_buttons()} +}