diff --git a/DESCRIPTION b/DESCRIPTION index cd987d022..0b3019de1 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -40,7 +40,8 @@ Imports: memoise (>= 2.0.1), mime, rlang, - sass (>= 0.4.9) + sass (>= 0.4.9), + shiny (>= 1.11.1.9000) Suggests: bsicons, curl, @@ -52,7 +53,6 @@ Suggests: magrittr, rappdirs, rmarkdown (>= 2.7), - shiny (>= 1.11.1), testthat, thematic, tools, diff --git a/NAMESPACE b/NAMESPACE index 07c1eb683..f0a630351 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -163,6 +163,7 @@ export(toggle_sidebar) export(toggle_switch) export(toggle_tooltip) export(toolbar) +export(toolbar_input_button) export(tooltip) export(update_popover) export(update_submit_textarea) diff --git a/NEWS.md b/NEWS.md index 45a9d1a03..28bf37c1f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -7,6 +7,7 @@ * Added toast notifications based on [Bootstrap's Toast component](https://getbootstrap.com/docs/5.3/components/toasts/): Use `toast()` to create customizable toast objects, `show_toast()` to display a toast message, `hide_toast()` for manual dismissal, and `toast_header()` for structured headers with icons and status indicators. (#1246) * Added a new `toolbar()` component for creating Bootstrap toolbars that can contain buttons, text, and other elements. (#1247) + * Added `toolbar_input_button()` for easily creating buttons to include in a `toolbar()`. (#1248) ## Improvements and bug fixes diff --git a/R/toolbar.R b/R/toolbar.R index f100b5951..ba6325094 100644 --- a/R/toolbar.R +++ b/R/toolbar.R @@ -4,21 +4,35 @@ #' A toolbar which can contain buttons, inputs, and other UI elements in a small #' form suitable for inclusion in card headers, footers, and other small places. #' +#' @examplesIf rlang::is_interactive() +#' toolbar( +#' align = "right", +#' toolbar_input_button(id = "see", icon = icon("eye")), +#' toolbar_input_button(id = "save", icon = icon("save")), +#' toolbar_input_button(id = "edit", icon = icon("pencil")) +#' ) +#' #' @param ... UI elements for the toolbar. #' @param align Determines if toolbar should be aligned to the `"right"` or #' `"left"`. +#' @param gap A CSS length unit defining the gap (i.e., spacing) between +#' elements in the toolbar. Defaults to `0` (no gap). #' @return Returns a toolbar element. #' +#' @family Toolbar components #' @export toolbar <- function( ..., - align = c("right", "left") + align = c("right", "left"), + gap = NULL ) { align <- rlang::arg_match(align) + gap <- validateCssUnit(gap) tag <- div( class = "bslib-toolbar bslib-gap-spacing", "data-align" = align, + style = css(gap = gap), ..., component_dependencies() ) @@ -26,3 +40,118 @@ toolbar <- function( tag_require(tag, version = 5, caller = "toolbar()") as_fragment(tag) } + +#' Add toolbar button input +#' +#' @description +#' A button designed to fit well in small places such as in a [toolbar()]. +#' +#' @examplesIf rlang::is_interactive() +#' toolbar( +#' align = "right", +#' toolbar_input_button(id = "see", icon = icon("eye")), +#' toolbar_input_button(id = "save", label = "Save")), +#' toolbar_input_button(id = "edit", icon = icon("pencil"), label="Edit") +#' ) +#' +#' @param id The input ID. +#' @param icon An icon to display in the button. (One of icon or label must be +#' supplied.) +#' @param label The label to display in the button. (One of icon or label must +#' be supplied.) +#' @param tooltip An optional [tooltip()] to display when hovering over the +#' button. +#' @param disabled If `TRUE`, the button will not be clickable. Use +#' [shiny::updateActionButton()] to dynamically enable/disable the button. +#' @param border Whether to show a border around the button. +#' @param ... UI elements for the button. +#' +#' @return Returns a button suitable for use in a toolbar. +#' +#' @family Toolbar components +#' @export + +toolbar_input_button <- function( + id, + icon = NULL, + label = NULL, + tooltip = NULL, + ..., + disabled = FALSE, + border = FALSE +) { + if (is.null(icon) && is.null(label)) { + stop( + "At least one of 'icon' or 'label' must be provided.", + call. = TRUE + ) + } + has_icon <- !is.null(icon) + has_label <- !is.null(label) + + btn_type <- + if (has_icon && !has_label) { + "icon" + } else if (has_label && !has_icon) { + "label" + } else { + # Can't both be missing (checked above) + "both" + } + + button <- shiny::actionButton( + id, + label = label, + icon = icon, + disabled = disabled, + class = "bslib-toolbar-input-button btn-sm", + class = if (!border) "border-0" else "border-1", + "data-type" = btn_type, + ... + ) + + if (!is.null(tooltip)) { + button <- tooltip(button, tooltip, placement = "bottom") + } + button +} + +#' @describeIn toolbar Add a spacer or divider to a toolbar +#' +#' @description +#' `toolbar_spacer()` creates flexible space between toolbar elements or adds +#' a visual divider line. +#' +#' @param width The width of the spacer. Can be: +#' - `"auto"` (default): Flexible space that pushes subsequent items +#' - A CSS length unit (e.g., `"10px"`, `"1rem"`): Fixed-width space +#' @param rule If `TRUE`, displays a vertical dividing line instead of empty space. +#' +#' @examplesIf rlang::is_interactive() +#' toolbar( +#' toolbar_input_button(id = "left1", label = "Left"), +#' toolbar_spacer(), +#' toolbar_input_button(id = "right1", label = "Right") +#' ) +#' +#' toolbar( +#' toolbar_input_button(id = "a", label = "A"), +#' toolbar_spacer(width = "20px"), +#' toolbar_input_button(id = "b", label = "B"), +#' toolbar_spacer(rule = TRUE), +#' toolbar_input_button(id = "c", label = "C") +#' ) +#' +#' @export +toolbar_spacer <- function(width = "auto", rule = FALSE) { + width <- if (identical(width, "auto")) "auto" else validateCssUnit(width) + + tag <- div( + class = "bslib-toolbar-spacer", + class = if (rule) "bslib-toolbar-divider", + style = css(width = if (!identical(width, "auto")) width), + `aria-hidden` = "true" + ) + + as_fragment(tag) +} diff --git a/inst/components/scss/card.scss b/inst/components/scss/card.scss index 30833e7e3..47d2f0572 100644 --- a/inst/components/scss/card.scss +++ b/inst/components/scss/card.scss @@ -31,6 +31,8 @@ flex-direction: row; align-items: center; align-self: stretch; + min-height: 2.5rem; + padding-block: 4px; gap: 0.25rem; // Give the nav flex: 1 so that if the card header contains a nav, it will take all the available space diff --git a/inst/components/scss/toolbar.scss b/inst/components/scss/toolbar.scss index 75f11209d..ca5b320e7 100644 --- a/inst/components/scss/toolbar.scss +++ b/inst/components/scss/toolbar.scss @@ -2,15 +2,70 @@ .bslib-toolbar { display: flex; align-items: center; + gap: 0; /* ---- Toolbar options ---- */ &[data-align="left"] { margin-right: auto; + justify-content: start; } &[data-align="right"] { margin-left: auto; + justify-content: end; + } + + /* ---- Toolbar Input Buttons ---- */ + + // Keep labels and icons centered in the button + .bslib-toolbar-input-button { + align-items: center; + justify-content: center; + line-height: 1; // Override Bootstrap's line-height to avoid too much vertical space + } + + // Square icon-only buttons + .bslib-toolbar-input-button[data-type="icon"] { + height: var(--_icon-size, 2rem); + aspect-ratio: 1; + line-height: 1 !important; // Ensure no line-height interference + + // Ensure icon is centered + >.action-icon { + display: flex; + align-items: center; + justify-content: center; + line-height: 1; // Remove excess line-height + margin: 0; // Remove any default margins + } + } + + /* ---- Toolbar Spacer ---- */ + + .bslib-toolbar-spacer { + flex-shrink: 0; + align-self: stretch; + + // Default behavior: flexible spacer that pushes items + &:not([style*="width"]) { + flex: 1 1 auto; + min-width: 0; + } + + // Divider variant: vertical line + &.bslib-toolbar-divider { + flex: 0 0 auto; + width: 1px; + background-color: var(--bs-border-color); + margin: 0 0.5rem; + opacity: 0.5; + + // Override width if explicitly set + &[style*="width"] { + width: var(--_spacer-width, 1px); + } + } } /* ---- Adjustments to other elements ---- */ @@ -18,7 +73,7 @@ // Ensures uniformity of font sizing in elements and sub-elements (e.g., input select) &, & * { - font-size: 0.8rem; + font-size: 0.9rem; } &>* { @@ -26,11 +81,4 @@ width: auto; align-self: center; } - - // Card header is flex by default, but card footer is not and must be in order for - // toolbar alignment to work - .card-footer:has(> &) { - display: flex; - align-items: center; - } } diff --git a/man/toolbar.Rd b/man/toolbar.Rd index 6a398d85f..6588e9060 100644 --- a/man/toolbar.Rd +++ b/man/toolbar.Rd @@ -4,13 +4,16 @@ \alias{toolbar} \title{Toolbar component} \usage{ -toolbar(..., align = c("right", "left")) +toolbar(..., align = c("right", "left"), gap = NULL) } \arguments{ \item{...}{UI elements for the toolbar.} \item{align}{Determines if toolbar should be aligned to the \code{"right"} or \code{"left"}.} + +\item{gap}{A CSS length unit defining the gap (i.e., spacing) between +elements in the toolbar. Defaults to \code{0} (no gap).} } \value{ Returns a toolbar element. diff --git a/man/toolbar_input_button.Rd b/man/toolbar_input_button.Rd new file mode 100644 index 000000000..131bd7352 --- /dev/null +++ b/man/toolbar_input_button.Rd @@ -0,0 +1,40 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/toolbar.R +\name{toolbar_input_button} +\alias{toolbar_input_button} +\title{Add toolbar button input} +\usage{ +toolbar_input_button( + id, + icon = NULL, + label = NULL, + tooltip = NULL, + ..., + disabled = FALSE, + border = FALSE +) +} +\arguments{ +\item{id}{The \code{input} slot that will be used to access the value.} + +\item{icon}{An icon to display in the button. +(One of icon or label must be supplied.)} + +\item{label}{The label to display in the button. +(One of icon or label must be supplied.)} + +\item{tooltip}{An optional tooltip to display when hovering over the button.} + +\item{...}{UI elements for the button.} + +\item{disabled}{If \code{TRUE}, the button will not be clickable. +Use \code{updateActionButton()} to dynamically enable/disable the button.} + +\item{border}{Whether to show a border around the button.} +} +\value{ +Returns a button suitable for use in a toolbar. +} +\description{ +A button designed to fit well in small places such as toolbars. +} diff --git a/tests/testthat/_snaps/toolbar.md b/tests/testthat/_snaps/toolbar.md index 2b0f09a1d..5a26d54a5 100644 --- a/tests/testthat/_snaps/toolbar.md +++ b/tests/testthat/_snaps/toolbar.md @@ -8,6 +8,16 @@ Item 2 +--- + + Code + show_raw_html(toolbar("Item 1", "Item 2", gap = "10px")) + Output +
+ Item 1 + Item 2 +
+ # toolbar() aligns correctly Code @@ -28,3 +38,91 @@ Item 2 +# toolbar_input_button() has correct attributes + + Code + show_raw_html(toolbar_input_button(id = "label_only", label = "Click me")) + Output + + +--- + + Code + show_raw_html(toolbar_input_button(id = "icon_only", icon = shiny::icon("star"))) + Output + + +--- + + Code + show_raw_html(toolbar_input_button(id = "both", label = "Save", icon = shiny::icon( + "save"))) + Output + + +# toolbar_input_button() disabled parameter + + Code + show_raw_html(toolbar_input_button(id = "disabled_btn", label = "Disabled", + disabled = TRUE)) + Output + + +--- + + Code + show_raw_html(toolbar_input_button(id = "enabled_btn", label = "Enabled", + disabled = FALSE)) + Output + + +# toolbar_input_button() border parameter + + Code + show_raw_html(toolbar_input_button(id = "no_border", label = "No Border", + border = FALSE)) + Output + + +--- + + Code + show_raw_html(toolbar_input_button(id = "with_border", label = "With Border", + border = TRUE)) + Output + + +# toolbar_input_button() tooltip parameter + + Code + show_raw_html(toolbar_input_button(id = "tooltip_icon", icon = shiny::icon( + "question"), tooltip = "Help")) + Output + + + + + diff --git a/tests/testthat/helper-html.R b/tests/testthat/helper-html.R index 9965671bf..7048305d4 100644 --- a/tests/testthat/helper-html.R +++ b/tests/testthat/helper-html.R @@ -3,7 +3,7 @@ show_raw_html <- function(x) { } expect_snapshot_html <- function(x, .envir = parent.frame()) { - x_str <- deparse(substitute(x)) + x_str <- deparse1(substitute(x)) code <- parse(text = sprintf("expect_snapshot(show_raw_html(%s))", x_str)) eval(code, envir = .envir) } diff --git a/tests/testthat/test-toolbar.R b/tests/testthat/test-toolbar.R index b58d13bd5..cc7050ce2 100644 --- a/tests/testthat/test-toolbar.R +++ b/tests/testthat/test-toolbar.R @@ -1,3 +1,4 @@ +# Tests for toolbar container # test_that("toolbar() basic attributes and defaults", { tb <- as.tags(toolbar(htmltools::span("Test"))) expect_match(htmltools::tagGetAttribute(tb, "class"), "bslib-toolbar") @@ -5,6 +6,9 @@ test_that("toolbar() basic attributes and defaults", { expect_snapshot_html( toolbar("Item 1", "Item 2") ) + expect_snapshot_html( + toolbar("Item 1", "Item 2", gap = "10px") + ) }) test_that("toolbar() aligns correctly", { @@ -18,3 +22,120 @@ test_that("toolbar() aligns correctly", { ) expect_error(toolbar("x", align = "center")) }) + + +# Tests for toolbar_input_button() # +test_that("toolbar_input_button() has correct attributes", { + btn_label <- toolbar_input_button(id = "test_btn", label = "Click me") + expect_match( + htmltools::tagGetAttribute(btn_label, "class"), + "bslib-toolbar-input-button" + ) + expect_match(htmltools::tagGetAttribute(btn_label, "class"), "btn-sm") + + expect_snapshot_html( + toolbar_input_button(id = "label_only", label = "Click me") + ) + + btn_icon <- toolbar_input_button(id = "icon_only", icon = shiny::icon("star")) + expect_equal(htmltools::tagGetAttribute(btn_icon, "data-type"), "icon") + + expect_snapshot_html( + toolbar_input_button(id = "icon_only", icon = shiny::icon("star")) + ) + expect_snapshot_html( + toolbar_input_button( + id = "both", + label = "Save", + icon = shiny::icon("save") + ) + ) +}) + +test_that("toolbar_input_button() requires icon or label", { + expect_error( + toolbar_input_button(id = "empty"), + "At least one of 'icon' or 'label' must be provided" + ) +}) + +test_that("toolbar_input_button() disabled parameter", { + expect_snapshot_html( + toolbar_input_button( + id = "disabled_btn", + label = "Disabled", + disabled = TRUE + ) + ) + expect_snapshot_html( + toolbar_input_button( + id = "enabled_btn", + label = "Enabled", + disabled = FALSE + ) + ) +}) + +test_that("toolbar_input_button() border parameter", { + expect_snapshot_html( + toolbar_input_button( + id = "no_border", + label = "No Border", + border = FALSE + ) + ) + expect_snapshot_html( + toolbar_input_button( + id = "with_border", + label = "With Border", + border = TRUE + ) + ) +}) + + +test_that("toolbar_input_button() tooltip parameter", { + expect_snapshot_html( + toolbar_input_button( + id = "tooltip_icon", + icon = shiny::icon("question"), + tooltip = "Help" + ) + ) +}) + + +# Tests for toolbar_spacer() # +test_that("toolbar_spacer() creates spacer element", { + expect_snapshot_html( + toolbar_spacer() + ) + expect_snapshot_html( + toolbar_spacer(width = "20px") + ) + expect_snapshot_html( + toolbar_spacer(rule = TRUE) + ) + expect_snapshot_html( + toolbar_spacer(width = "2rem", rule = TRUE) + ) +}) + +test_that("toolbar_spacer() in toolbar context", { + expect_snapshot_html( + toolbar( + toolbar_input_button(id = "left", label = "Left"), + toolbar_spacer(), + toolbar_input_button(id = "right", label = "Right") + ) + ) + expect_snapshot_html( + toolbar( + toolbar_input_button(id = "a", label = "A"), + toolbar_spacer(width = "10px"), + toolbar_input_button(id = "b", label = "B"), + toolbar_spacer(rule = TRUE), + toolbar_input_button(id = "c", label = "C") + ) + ) +})