diff --git a/NAMESPACE b/NAMESPACE index 5a9b78e3d..a02ae45e6 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -165,6 +165,7 @@ export(toggle_switch) export(toggle_tooltip) export(toolbar) export(toolbar_divider) +export(toolbar_download_button) export(toolbar_input_button) export(toolbar_input_select) export(toolbar_spacer) @@ -174,6 +175,7 @@ export(update_popover) export(update_submit_textarea) export(update_switch) export(update_task_button) +export(update_toolbar_download_button) export(update_toolbar_input_button) export(update_toolbar_input_select) export(update_tooltip) diff --git a/NEWS.md b/NEWS.md index 3585168ca..04cb28fa1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,9 @@ # bslib (development version) +## New features + +* Added `toolbar_download_button()` and `update_toolbar_download_button()` for adding a download button to a `toolbar()`, styled consistently with other toolbar inputs. (#1292) + ## Improvements and bug fixes * Fixed `toolbar_input_button()` alignment and spacing issues. (#1290) diff --git a/R/sysdata.rda b/R/sysdata.rda index 70283fe7c..3f76075dd 100644 Binary files a/R/sysdata.rda and b/R/sysdata.rda differ diff --git a/R/toolbar.R b/R/toolbar.R index d36243ca5..c8e0d3358 100644 --- a/R/toolbar.R +++ b/R/toolbar.R @@ -440,7 +440,7 @@ toolbar_input_button <- function( icon = icon_elem, disabled = disabled, class = "bslib-toolbar-input-button btn-sm", - class = if (!border) "border-0" else "border-1", + class = if (!border) "border-0" else "border", "data-type" = btn_type, "aria-labelledby" = label_id, ... @@ -960,3 +960,149 @@ toolbar_divider <- function(..., width = NULL, gap = NULL) { toolbar_spacer <- function() { div(class = "bslib-toolbar-spacer") } + +#' Toolbar Download Button +#' +#' @description +#' A download button designed to fit well in small places such as in a [toolbar()]. +#' +#' @param outputId The download output ID (connects to [shiny::downloadHandler()] in server). +#' @param label The button label. By default, `label` is not shown but is used by +#' `tooltip`. Set `show_label = TRUE` to show the label. +#' @param icon An icon. Defaults to `shiny::icon("download")`. +#' @param show_label Whether to show the label text. If `FALSE` (the default), +#' only the icon is shown. If `TRUE`, the label text is shown alongside the icon. +#' @param tooltip Tooltip text to display when hovering. Can be: +#' * `TRUE` (default when `show_label = FALSE`) - shows tooltip with `label` text +#' * `FALSE` (default when `show_label = TRUE`) - no tooltip +#' * A character string - shows tooltip with custom text +#' @param ... Additional attributes passed to the `` tag. +#' @param disabled If `TRUE`, the button will not be clickable. Since `` tags +#' have no native disabled attribute, this adds `class="disabled"`, +#' `aria-disabled="true"`, and `tabindex="-1"`. +#' @param border Whether to show a border around the button. +#' +#' @return Returns a download button suitable for use in a toolbar. +#' +#' @examplesIf rlang::is_interactive() +#' # Download button in a card toolbar +#' card( +#' card_header( +#' "Flower Data", +#' toolbar( +#' align = "right", +#' toolbar_download_button("download_data", label = "Download") +#' ) +#' ) +#' ) +#' +#' @family toolbar components +#' @export +toolbar_download_button <- function( + outputId, + label = "Download", + icon = shiny::icon("download"), + show_label = FALSE, + tooltip = !show_label, + ..., + disabled = FALSE, + border = FALSE +) { + btn_type <- + if (is.null(icon)) { + if (!show_label) { + rlang::abort( + "If `show_label` is FALSE, `icon` must be provided." + ) + } + "label" + } else { + if (show_label) "both" else "icon" + } + + # Validate that label has text for accessibility + label_text <- paste(unlist(find_characters(label)), collapse = " ") + # Verifies the label contains non-empty text + if (!nzchar(trimws(label_text))) { + warning( + "Consider providing a non-empty string label for accessibility." + ) + } + + label_id <- paste0("btn-label-", p_randomInt(1000, 10000)) + + # We hide the label visually if `!show_label` but keep the label field for + # use with `aria-labelledby`. This ensures that ARIA will always use the + # label text. + label_elem <- span( + class = "action-label", + span( + id = label_id, + class = "bslib-toolbar-label", + hidden = if (!show_label) NA else NULL, + label + ) + ) + + # And we wrap the icon to ensure that it is always treated as decorative + icon_elem <- span( + class = "action-icon", + span( + class = "bslib-toolbar-icon", + `aria-hidden` = "true", + style = "pointer-events: none", + icon + ) + ) + + button <- tags$a( + id = outputId, + class = "bslib-toolbar-download-button btn btn-sm shiny-download-link", + class = if (!border) "border-0" else "border", + class = if (disabled) "disabled", + href = "", + target = "_blank", + rel = "noopener noreferrer", + download = NA, + `data-type` = btn_type, + `aria-labelledby` = label_id, + `aria-disabled` = if (disabled) "true" else NULL, + tabindex = if (disabled) "-1" else NULL, + icon_elem, + label_elem, + ... + ) + + # If tooltip is literally TRUE, use the label as the tooltip text. + if (isTRUE(tooltip)) { + tooltip <- label + } + if (isFALSE(tooltip)) { + tooltip <- NULL + } + if (!is.null(tooltip)) { + # Default placement is "bottom" for the toolbar case because otherwise the + # tooltip ends up covering the neighboring buttons in the header/footer. + button <- tooltip( + button, + tooltip, + id = sprintf("%s_tooltip", outputId), + placement = "bottom" + ) + } + + button +} + +#' @param session A Shiny session object. +#' +#' @describeIn toolbar_download_button Update a toolbar download button. +#' @export +update_toolbar_download_button <- function( + outputId, + disabled = NULL, + session = get_current_session() +) { + message <- dropNulls(list(disabled = disabled)) + session$sendInputMessage(outputId, message) +} diff --git a/design/2026-03-16-toolbar-download-button-design.md b/design/2026-03-16-toolbar-download-button-design.md new file mode 100644 index 000000000..303cffe97 --- /dev/null +++ b/design/2026-03-16-toolbar-download-button-design.md @@ -0,0 +1,219 @@ +# toolbar_download_button() Design Spec + +**Date:** 2026-03-16 +**Issue:** https://github.com/rstudio/bslib/issues/1292 +**Status:** Approved + +## Summary + +Add `toolbar_download_button()` to bslib's toolbar component family, providing a download button styled consistently with other toolbar inputs for use in card headers, footers, and other toolbar contexts. + +## Motivation + +Users want an elegant way to add download functionality to card toolbars. Currently, using Shiny's `downloadButton()` in a toolbar requires manual styling workarounds (adding classes, inline styles, and ARIA attributes). A dedicated `toolbar_download_button()` would provide first-class support with proper styling and accessibility out of the box. + +## Design + +### Function Signature + +```r +toolbar_download_button <- function( + outputId, + label = "Download", + icon = shiny::icon("download"), + show_label = FALSE, + tooltip = !show_label, + ..., + disabled = FALSE, + border = FALSE +) +``` + +#### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `outputId` | character | required | The download output ID (connects to `downloadHandler` in server) | +| `label` | character/tag | `"Download"` | Button label text; used for tooltip when `show_label = FALSE` | +| `icon` | icon | `shiny::icon("download")` | Icon to display; can be overridden with custom icon | +| `show_label` | logical | `FALSE` | Whether to show label text (icon-only by default) | +| `tooltip` | logical/character | `!show_label` | Tooltip behavior; `TRUE` shows label as tooltip, `FALSE` disables, or custom string | +| `...` | | | Additional HTML attributes passed to the `` tag | +| `disabled` | logical | `FALSE` | Initial disabled state. Since `` tags have no native `disabled` attribute, this adds `class="disabled"`, `aria-disabled="true"`, and `tabindex="-1"` | +| `border` | logical | `FALSE` | Show border around button | + +#### Return Value + +An `` tag suitable for use in a `toolbar()`, styled to match other toolbar components. + +### HTML Structure + +The generated HTML follows the same accessibility patterns as `toolbar_input_button()`: + +```html + + + + + + + + + + + + +``` + +Key structural decisions: +- **Built directly with `tags$a()`** - NOT wrapping `shiny::downloadButton()` or `shiny::actionButton()`. We construct the `` tag from scratch to have full control over structure. +- Uses `bslib-toolbar-download-button` class (new) plus `shiny-download-link` (Shiny's download machinery) +- Nested `span.action-icon > span.bslib-toolbar-icon` and `span.action-label > span.bslib-toolbar-label` - These wrappers are added manually to match the structure that `actionButton()` generates for `toolbar_input_button`, ensuring CSS rules apply consistently to both. The `aria-hidden` and `style` attributes go on the inner `.bslib-toolbar-icon` span, matching the pattern from `toolbar_input_button()` (see R/toolbar.R:430-435). +- Same `data-type` attribute pattern (`"icon"`, `"label"`, `"both"`) for CSS targeting +- `aria-labelledby` points to the label span for screen reader support +- Tooltip wrapper follows same pattern as `toolbar_input_button` + +### Update Function + +```r +update_toolbar_download_button <- function( + outputId, + disabled = NULL, + session = get_current_session() +) +``` + +Supports updating only the `disabled` state (not label/icon), as download buttons rarely need dynamic updates beyond enable/disable. + +#### Implementation Pattern + +Follows the same pattern as `toolbar_input_button` and bslib's tooltip/popover components: + +**R side:** +```r +update_toolbar_download_button <- function( + outputId, + disabled = NULL, + session = get_current_session() +) { + message <- dropNulls(list(disabled = disabled)) + session$sendInputMessage(outputId, message) +} +``` + +**TypeScript side:** A minimal input binding that handles `receiveMessage`: + +```typescript +class BslibToolbarDownloadButtonBinding extends InputBinding { + find(scope: HTMLElement) { + return $(scope).find(".bslib-toolbar-download-button"); + } + + getValue(el: HTMLElement) { + return null; // Not used as input + } + + receiveMessage(el: HTMLElement, message: { disabled?: boolean }) { + if (hasDefinedProperty(message, "disabled")) { + if (message.disabled) { + el.classList.add("disabled"); + el.setAttribute("aria-disabled", "true"); + el.setAttribute("tabindex", "-1"); + } else { + el.classList.remove("disabled"); + el.removeAttribute("aria-disabled"); + el.removeAttribute("tabindex"); + } + } + } +} + +registerBinding(BslibToolbarDownloadButtonBinding, "toolbar-download-button"); +``` + +**Why this works:** This pattern (input binding for a non-input component) is established precedent in bslib. Both `BslibTooltip` and `BslibPopover` set `static isShinyInput = true` to enable `sendInputMessage()` for server-to-client updates, even though they're not traditional inputs. Similarly, `toolbar_input_button` uses a standalone `InputBinding` class (see `toolbarInputButton.ts`). We follow the same standalone binding approach here. + +## Usage Example + +```r +library(shiny) +library(bslib) + +ui <- page_fluid( + card( + card_header( + "Flower Data", + toolbar( + align = "right", + toolbar_download_button("download_data", label = "Download") + ) + ), + card_body( + reactable::reactable(iris) + ) + ) +) + +server <- function(input, output, session) { + output$download_data <- downloadHandler( + filename = function() { + paste("iris-", Sys.Date(), ".csv", sep = "") + }, + content = function(file) { + write.csv(iris, file, row.names = FALSE) + } + ) +} + +shinyApp(ui, server) +``` + +## Files to Modify/Create + +| File | Action | Description | +|------|--------|-------------| +| `R/toolbar.R` | Modify | Add `toolbar_download_button()` and `update_toolbar_download_button()` | +| `srcts/src/components/toolbarDownloadButton.ts` | Create | New input binding for update handling | +| `inst/components/scss/toolbar.scss` | Modify | Update `data-type` attribute selectors (lines ~71, ~78, ~85) to include `.bslib-toolbar-download-button` alongside `.bslib-toolbar-input-button`. Base button styles use Bootstrap `.btn` classes. | +| `tests/testthat/test-toolbar.R` | Modify | Add tests for new functions | +| `man/toolbar_download_button.Rd` | Generated | Roxygen-generated documentation | +| `NAMESPACE` | Generated | Export new functions | + +## Testing Plan + +**Unit tests (automated):** +1. **Snapshot test** - Verify HTML structure matches expected output +2. **Parameter validation** - Test that `outputId` is required, label defaults work +3. **Tooltip behavior** - Test tooltip is added when `show_label = FALSE`, not added when `show_label = TRUE` +4. **Border/disabled states** - Test class application +5. **Icon override** - Test custom icon replaces default + +**Integration test (manual):** +6. **Download functionality** - Manual verification that download works in a Shiny app. Automated download testing is complex due to browser security restrictions; manual testing via the example app (`shiny::runExample("toolbar", package = "bslib")`) is sufficient. + +## Accessibility + +- `aria-labelledby` points to label span for screen reader support +- Tooltip provides accessible name when label is visually hidden +- Disabled state uses both `disabled` class and `aria-disabled="true"` +- Icon wrapped with `aria-hidden="true"` to prevent duplicate announcements + +## Alternatives Considered + +**Custom Download Input Binding:** Create a ` + # toolbar_input_button() tooltip parameter @@ -384,3 +384,164 @@ Warning: `selected` value 'D' is not in `choices`. +# toolbar_download_button() basic structure + + Code + show_raw_html(toolbar_download_button(outputId = "dl_icon_only")) + Output + + + + + + + + + + + + +# toolbar_download_button() with show_label + + Code + show_raw_html(toolbar_download_button(outputId = "dl_with_label", label = "Download", + show_label = TRUE)) + Output + + + + + + Download + + + +# toolbar_download_button() disabled parameter + + Code + show_raw_html(toolbar_download_button(outputId = "dl_disabled", disabled = TRUE, + show_label = TRUE)) + Output + + + + + + Download + + + +# toolbar_download_button() border parameter + + Code + show_raw_html(toolbar_download_button(outputId = "dl_no_border", border = FALSE, + show_label = TRUE)) + Output + + + + + + Download + + + +--- + + Code + show_raw_html(toolbar_download_button(outputId = "dl_with_border", border = TRUE, + show_label = TRUE)) + Output + + + + + + Download + + + +# toolbar_download_button() tooltip parameter + + Code + show_raw_html(toolbar_download_button(outputId = "dl_tooltip_default")) + Output + + + + + + + + + + + + +--- + + Code + show_raw_html(toolbar_download_button(outputId = "dl_no_tooltip", tooltip = FALSE)) + Output + + + + + + + + + +--- + + Code + show_raw_html(toolbar_download_button(outputId = "dl_custom_tooltip", tooltip = "Download the data")) + Output + + + + + + + + + + + + +# toolbar_download_button() custom icon + + Code + show_raw_html(toolbar_download_button(outputId = "dl_custom_icon", icon = shiny::icon( + "file-csv"))) + Output + + + + + + + + + + + + diff --git a/tests/testthat/test-toolbar.R b/tests/testthat/test-toolbar.R index 7e7f45cbb..a2c54a3f5 100644 --- a/tests/testthat/test-toolbar.R +++ b/tests/testthat/test-toolbar.R @@ -1022,3 +1022,115 @@ test_that("update_toolbar_input_button() can disable and reenable button", { expect_true(!is.null(session$last_message$label)) expect_true(!is.null(session$last_message$icon)) }) + +# Tests for toolbar_download_button() # +test_that("toolbar_download_button() basic structure", { + # Icon-only (default) + btn <- toolbar_download_button(outputId = "dl_test") + # Button is wrapped in tooltip by default, use tagQuery to extract it + btn_tag <- tagQuery(as.tags(btn))$find("a")$selectedTags()[[1]] + + expect_match( + htmltools::tagGetAttribute(btn_tag, "class"), + "bslib-toolbar-download-button" + ) + expect_match( + htmltools::tagGetAttribute(btn_tag, "class"), + "shiny-download-link" + ) + expect_match(htmltools::tagGetAttribute(btn_tag, "class"), "btn-sm") + expect_match(htmltools::tagGetAttribute(btn_tag, "data-type"), "icon") + + expect_snapshot_html( + toolbar_download_button(outputId = "dl_icon_only") + ) +}) + +test_that("toolbar_download_button() with show_label", { + btn <- toolbar_download_button( + outputId = "dl_test", + label = "Download CSV", + show_label = TRUE + ) + expect_match(htmltools::tagGetAttribute(btn, "data-type"), "both") + + expect_snapshot_html( + toolbar_download_button( + outputId = "dl_with_label", + label = "Download", + show_label = TRUE + ) + ) +}) + +test_that("toolbar_download_button() disabled parameter", { + expect_snapshot_html( + toolbar_download_button( + outputId = "dl_disabled", + disabled = TRUE, + show_label = TRUE + ) + ) + + # Check disabled attributes + btn <- toolbar_download_button( + outputId = "dl_test", + disabled = TRUE, + show_label = TRUE + ) + expect_match(htmltools::tagGetAttribute(btn, "class"), "disabled") + expect_equal(htmltools::tagGetAttribute(btn, "aria-disabled"), "true") + expect_equal(htmltools::tagGetAttribute(btn, "tabindex"), "-1") +}) + +test_that("toolbar_download_button() border parameter", { + expect_snapshot_html( + toolbar_download_button( + outputId = "dl_no_border", + border = FALSE, + show_label = TRUE + ) + ) + + expect_snapshot_html( + toolbar_download_button( + outputId = "dl_with_border", + border = TRUE, + show_label = TRUE + ) + ) +}) + +test_that("toolbar_download_button() tooltip parameter", { + # Default: icon-only has tooltip + expect_snapshot_html( + toolbar_download_button( + outputId = "dl_tooltip_default" + ) + ) + + # Explicit tooltip = FALSE + expect_snapshot_html( + toolbar_download_button( + outputId = "dl_no_tooltip", + tooltip = FALSE + ) + ) + + # Custom tooltip text + expect_snapshot_html( + toolbar_download_button( + outputId = "dl_custom_tooltip", + tooltip = "Download the data" + ) + ) +}) + +test_that("toolbar_download_button() custom icon", { + expect_snapshot_html( + toolbar_download_button( + outputId = "dl_custom_icon", + icon = shiny::icon("file-csv") + ) + ) +})