From 0c14c45e3f4f670b87e3e9bda5571074504de2fa Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 14:43:36 -0400 Subject: [PATCH 01/68] feat: Add toast notification R API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement core R functions for toast notifications: - toast(): Create toast objects with customizable options - show_toast(): Display toasts in Shiny apps - hide_toast(): Manually dismiss toasts - toast_header(): Create structured headers with icons/status Follows bslib component patterns with object-oriented design and server-side creation model. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- R/toast.R | 428 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 R/toast.R diff --git a/R/toast.R b/R/toast.R new file mode 100644 index 000000000..913f8de01 --- /dev/null +++ b/R/toast.R @@ -0,0 +1,428 @@ +#' Toast notifications +#' +#' @description +#' Toast notifications are lightweight, temporary messages designed to mimic +#' push notifications from mobile and desktop operating systems. They are built +#' on Bootstrap 5's native toast component. +#' +#' @param body Main content of the toast. Can be text, HTML, or Shiny UI elements. +#' @param header Optional header content. Can be a string, or the result of +#' [toast_header()]. If provided, creates a `.toast-header` with close button +#' (if `closable = TRUE`). +#' @param ... Additional HTML attributes passed to the toast container. +#' @param id Optional unique identifier for the toast. If `NULL`, an ID will be +#' automatically generated when the toast is shown via [show_toast()]. +#' Providing a stable ID allows you to update or hide the toast later. +#' @param type Optional semantic type. One of `NULL`, `"primary"`, `"secondary"`, +#' `"success"`, `"info"`, `"warning"`, `"danger"`, `"light"`, or `"dark"`. +#' Applies appropriate Bootstrap background utility classes (`text-bg-*`). +#' @param autohide Logical. Whether to automatically hide the toast after +#' `duration` milliseconds. Default `TRUE`. +#' @param duration Numeric. Time in milliseconds before auto-hiding. Default +#' `5000` (5 seconds). Ignored if `autohide = FALSE`. +#' @param position String. Where to position the toast container. One of +#' `"top-left"`, `"top-center"`, `"top-right"` (default), `"middle-left"`, +#' `"middle-center"`, `"middle-right"`, `"bottom-left"`, `"bottom-center"`, +#' or `"bottom-right"`. +#' @param closable Logical. Whether to include a close button. Default `TRUE`. +#' When `autohide = FALSE`, a close button is always included regardless of +#' this setting (for accessibility). +#' @param class Additional CSS classes for the toast. +#' +#' @return A `bslib_toast` object that can be passed to [show_toast()]. +#' +#' @export +#' @family Toast components +#' +#' @seealso [show_toast()] to display a toast, [hide_toast()] to dismiss a toast, +#' and [toast_header()] to create structured headers. +#' +#' @examplesIf rlang::is_interactive() +#' library(shiny) +#' library(bslib) +#' +#' ui <- page_fluid( +#' actionButton("show_toast", "Show Toast") +#' ) +#' +#' server <- function(input, output, session) { +#' observeEvent(input$show_toast, { +#' show_toast( +#' toast( +#' body = "Operation completed successfully!", +#' header = "Success", +#' type = "success" +#' ) +#' ) +#' }) +#' } +#' +#' shinyApp(ui, server) +toast <- function( + body, + header = NULL, + ..., + id = NULL, + type = NULL, + autohide = TRUE, + duration = 5000, + position = "top-right", + closable = TRUE, + class = NULL +) { + # Validate arguments + if (!is.null(type)) { + type <- rlang::arg_match(type, c( + "primary", "secondary", "success", "info", + "warning", "danger", "light", "dark" + )) + } + + position <- rlang::arg_match(position, c( + "top-left", "top-center", "top-right", + "middle-left", "middle-center", "middle-right", + "bottom-left", "bottom-center", "bottom-right" + )) + + # Enforce close button for non-autohiding toasts (accessibility) + if (!autohide) { + closable <- TRUE + } + + # Store toast data in a structured object + structure( + list( + body = body, + header = header, + id = id, + type = type, + autohide = autohide, + duration = duration, + position = position, + closable = closable, + class = class, + attribs = rlang::list2(...) + ), + class = "bslib_toast" + ) +} + +#' @rdname toast +#' @param x A `bslib_toast` object. +#' @export +as.tags.bslib_toast <- function(x, ...) { + # Generate ID if not provided + id <- x$id %||% paste0("bslib-toast-", p_randomInt(1000, 10000000)) + + toast_component( + body = x$body, + header = x$header, + type = x$type, + closable = x$closable, + id = id, + class = x$class, + !!!x$attribs + ) +} + +#' Display a toast notification +#' +#' @description +#' Displays a toast notification in a Shiny application. +#' +#' @param toast A `bslib_toast` object created by [toast()], or a string/UI +#' element (which will be automatically converted to a toast with default +#' settings). +#' @param ... Reserved for future extensions (currently ignored). +#' @param session Shiny session object. +#' +#' @return Invisibly returns the toast ID (string) that can be used with +#' [hide_toast()]. +#' +#' @export +#' @family Toast components +#' +#' @examplesIf rlang::is_interactive() +#' library(shiny) +#' library(bslib) +#' +#' ui <- page_fluid( +#' actionButton("show_simple", "Show Simple Toast"), +#' actionButton("show_success", "Show Success Toast") +#' ) +#' +#' server <- function(input, output, session) { +#' observeEvent(input$show_simple, { +#' # Simple string automatically converted to toast +#' show_toast("Operation completed!") +#' }) +#' +#' observeEvent(input$show_success, { +#' # Show a pre-created toast +#' show_toast( +#' toast( +#' body = "Your file has been uploaded.", +#' header = "Success", +#' type = "success" +#' ) +#' ) +#' }) +#' } +#' +#' shinyApp(ui, server) +show_toast <- function( + toast, + ..., + session = shiny::getDefaultReactiveDomain() +) { + # Check for unused arguments + rlang::check_dots_empty() + + # Convert to toast object if needed (convenience) + if (!inherits(toast, "bslib_toast")) { + toast <- toast(body = toast) + } + + # Generate ID if not already set + id <- toast$id %||% paste0("bslib-toast-", p_randomInt(1000, 10000000)) + + # Ensure ID is stored in toast object + toast$id <- id + + # Convert toast object to HTML tags + toast_tag <- as.tags(toast) + + # Render to HTML with dependencies + html <- as.character(toast_tag) + deps <- htmltools::resolveDependencies(htmltools::findDependencies(toast_tag)) + + # Prepare message data + data <- list( + html = html, + deps = lapply(deps, shiny::createWebDependency), + options = list( + autohide = toast$autohide, + delay = toast$duration + ), + position = toast$position, + id = id + ) + + # Send to client via custom message handler + callback <- function() { + session$sendCustomMessage("bslib.show-toast", data) + } + + session$onFlush(callback, once = TRUE) + + invisible(id) +} + +#' Hide a toast notification +#' +#' @description +#' Manually dismisses a toast notification. +#' +#' @param id String with the toast ID returned by [show_toast()] or a `toast` +#' object provided that the `id` was set when created/shown. +#' @param session Shiny session object. +#' +#' @return Called for side effects; returns `NULL` invisibly. +#' +#' @export +#' @family Toast components +#' +#' @examplesIf rlang::is_interactive() +#' library(shiny) +#' library(bslib) +#' +#' ui <- page_fluid( +#' actionButton("show_persistent", "Show Persistent Toast"), +#' actionButton("hide_persistent", "Hide Toast") +#' ) +#' +#' server <- function(input, output, session) { +#' toast_id <- reactiveVal(NULL) +#' +#' observeEvent(input$show_persistent, { +#' id <- show_toast( +#' toast( +#' body = "This toast won't disappear automatically.", +#' autohide = FALSE +#' ) +#' ) +#' toast_id(id) +#' }) +#' +#' observeEvent(input$hide_persistent, { +#' req(toast_id()) +#' hide_toast(toast_id()) +#' toast_id(NULL) +#' }) +#' } +#' +#' shinyApp(ui, server) +hide_toast <- function(id, session = shiny::getDefaultReactiveDomain()) { + force(id) + + callback <- function() { + session$sendCustomMessage("bslib.hide-toast", list(id = id)) + } + + session$onFlush(callback, once = TRUE) + + invisible(NULL) +} + +#' Create a structured toast header +#' +#' @description +#' Creates a structured header for a toast with optional icon and status +#' indicator. +#' +#' @param title Header text (required). +#' @param icon Optional icon element (e.g., from `bsicons::bs_icon()` or +#' `fontawesome::fa()`). +#' @param status Optional status indicator. One of `NULL`, `"primary"`, +#' `"secondary"`, `"success"`, `"info"`, `"warning"`, `"danger"`, `"light"`, +#' or `"dark"`. Adds a colored dot/badge before the title. +#' @param ... Additional HTML attributes passed to the header container. +#' +#' @return A tag object representing the toast header content. +#' +#' @export +#' @family Toast components +#' +#' @examplesIf rlang::is_interactive() +#' library(shiny) +#' library(bslib) +#' +#' ui <- page_fluid( +#' actionButton("show_header", "Show Toast with Header") +#' ) +#' +#' server <- function(input, output, session) { +#' observeEvent(input$show_header, { +#' show_toast( +#' toast( +#' body = "Your settings have been saved.", +#' header = toast_header( +#' title = "Settings Updated", +#' status = "success" +#' ), +#' type = "success" +#' ) +#' ) +#' }) +#' } +#' +#' shinyApp(ui, server) +toast_header <- function(title, icon = NULL, status = NULL, ...) { + # Validate status if provided + if (!is.null(status)) { + status <- rlang::arg_match(status, c( + "primary", "secondary", "success", "info", + "warning", "danger", "light", "dark" + )) + } + + # Build status indicator (colored dot) + status_indicator <- if (!is.null(status)) { + htmltools::span( + class = paste0("badge rounded-circle bg-", status, " me-2"), + style = "width: 0.5rem; height: 0.5rem; padding: 0;", + `aria-hidden` = "true" + ) + } + + # Combine elements + htmltools::tagList( + status_indicator, + icon, + htmltools::strong( + class = "me-auto", + if (!is.null(icon)) list(icon, " ", title) else title + ) + ) +} + +# Internal function to build toast HTML structure +toast_component <- function(body, header = NULL, type = NULL, + closable = TRUE, id = NULL, class = NULL, ...) { + # Determine accessibility attributes + aria_role <- if (!is.null(type) && type == "danger") "alert" else "status" + aria_live <- if (!is.null(type) && type == "danger") "assertive" else "polite" + + # Build type-based classes + type_class <- if (!is.null(type)) { + paste0("text-bg-", type) + } + + # Create close button (if needed) + close_button <- if (closable) { + htmltools::tags$button( + type = "button", + class = "btn-close", + `data-bs-dismiss` = "toast", + `aria-label` = "Close" + ) + } + + # Build header if provided + header_tag <- if (!is.null(header)) { + # Check if header is already a toast_header() result or just text/tags + header_content <- if (is.character(header)) { + htmltools::strong(class = "me-auto", header) + } else { + # Assume it's a tag object (from toast_header() or user-provided) + header + } + + htmltools::div( + class = "toast-header", + header_content, + close_button + ) + } + + # Build body with optional close button (if no header) + body_tag <- if (!is.null(header)) { + # Has header, so just body content (close button in header) + htmltools::div(class = "toast-body", body) + } else { + # No header, so include close button in body (if closable) + if (closable) { + htmltools::div( + class = "toast-body d-flex", + htmltools::div(class = "flex-grow-1", body), + htmltools::tags$button( + type = "button", + class = "btn-close me-2 m-auto", + `data-bs-dismiss` = "toast", + `aria-label` = "Close" + ) + ) + } else { + # No close button needed + htmltools::div(class = "toast-body", body) + } + } + + # Combine into toast structure + toast <- htmltools::div( + id = id, + class = paste(c("toast", type_class, class), collapse = " "), + role = aria_role, + `aria-live` = aria_live, + `aria-atomic` = "true", + ..., + header_tag, + body_tag + ) + + # Attach dependencies + toast <- htmltools::tagAppendChild(toast, component_dependencies()) + toast <- tag_require(toast, version = 5) + + as_fragment(toast) +} From 2facc38c117b7d59f8347b7ef16015d5f18bad2b Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 14:44:19 -0400 Subject: [PATCH 02/68] feat: Add TypeScript toast message handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement client-side toast functionality: - ToastContainerManager for auto-creating position-specific containers - showToast handler to display toasts with Bootstrap Toast API - hideToast handler to manually dismiss toasts - Auto-cleanup of toast elements and empty containers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- srcts/src/components/toast.ts | 146 ++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 srcts/src/components/toast.ts diff --git a/srcts/src/components/toast.ts b/srcts/src/components/toast.ts new file mode 100644 index 000000000..7b19b2c03 --- /dev/null +++ b/srcts/src/components/toast.ts @@ -0,0 +1,146 @@ +import type { HtmlDep } from "./_utils"; +import { Toast as BootstrapToast } from "bootstrap"; +import { shinyAddCustomMessageHandlers } from "./_shinyAddCustomMessageHandlers"; +import { shinyRenderDependencies } from "./_utils"; + +type ToastPosition = + | "top-left" + | "top-center" + | "top-right" + | "middle-left" + | "middle-center" + | "middle-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + +interface ToastOptions { + animation?: boolean; + autohide?: boolean; + delay?: number; +} + +interface ShowToastMessage { + html: string; + deps: HtmlDep[]; + options: ToastOptions; + position: ToastPosition; + id: string; +} + +interface HideToastMessage { + id: string; +} + +// Container management +class ToastContainerManager { + private containers: Map = new Map(); + + getOrCreateContainer(position: ToastPosition): HTMLElement { + let container = this.containers.get(position); + + if (!container || !document.body.contains(container)) { + container = this.createContainer(position); + this.containers.set(position, container); + } + + return container; + } + + private createContainer(position: ToastPosition): HTMLElement { + const container = document.createElement("div"); + container.className = "toast-container position-fixed p-3"; + container.setAttribute("data-bslib-toast-container", position); + + // Apply position classes + const positionClasses = this.getPositionClasses(position); + container.classList.add(...positionClasses); + + document.body.appendChild(container); + + return container; + } + + private getPositionClasses(position: ToastPosition): string[] { + const classMap: Record = { + "top-left": ["top-0", "start-0"], + "top-center": ["top-0", "start-50", "translate-middle-x"], + "top-right": ["top-0", "end-0"], + "middle-left": ["top-50", "start-0", "translate-middle-y"], + "middle-center": ["top-50", "start-50", "translate-middle"], + "middle-right": ["top-50", "end-0", "translate-middle-y"], + "bottom-left": ["bottom-0", "start-0"], + "bottom-center": ["bottom-0", "start-50", "translate-middle-x"], + "bottom-right": ["bottom-0", "end-0"], + }; + + return classMap[position]; + } +} + +const containerManager = new ToastContainerManager(); + +// Show toast handler +async function showToast(message: ShowToastMessage): Promise { + const { html, deps, options, position, id } = message; + + // Render dependencies + await shinyRenderDependencies(deps); + + // Get or create container for this position + const container = containerManager.getOrCreateContainer(position); + + // Create temporary div to parse HTML + const temp = document.createElement("div"); + temp.innerHTML = html; + const toastEl = temp.firstElementChild as HTMLElement; + + if (!toastEl) { + console.error("Failed to create toast element"); + return; + } + + // Append to container + container.appendChild(toastEl); + + // Initialize Bootstrap toast + const bsToast = new BootstrapToast(toastEl, options); + + // Show the toast + bsToast.show(); + + // Clean up after toast is hidden + toastEl.addEventListener("hidden.bs.toast", () => { + toastEl.remove(); + + // Remove empty containers + if (container.children.length === 0) { + container.remove(); + } + }); +} + +// Hide toast handler +function hideToast(message: HideToastMessage): void { + const { id } = message; + const toastEl = document.getElementById(id); + + if (!toastEl) { + console.warn(`Toast with id "${id}" not found`); + return; + } + + const bsToast = BootstrapToast.getInstance(toastEl); + + if (bsToast) { + bsToast.hide(); + } +} + +// Register message handlers +shinyAddCustomMessageHandlers({ + "bslib.show-toast": showToast, + "bslib.hide-toast": hideToast, +}); + +export type { ToastPosition, ToastOptions, ShowToastMessage, HideToastMessage }; From cffd3fd540648c06b0985ea5415edc702b199683 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 14:45:05 -0400 Subject: [PATCH 03/68] feat: Add toast SCSS styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add minimal custom styles for toast component: - CSS variable for runtime theming support - Ensure close button visibility on colored backgrounds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- inst/components/scss/_toast.scss | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 inst/components/scss/_toast.scss diff --git a/inst/components/scss/_toast.scss b/inst/components/scss/_toast.scss new file mode 100644 index 000000000..95c4ecc72 --- /dev/null +++ b/inst/components/scss/_toast.scss @@ -0,0 +1,19 @@ +// Toast customizations for bslib +// These extend Bootstrap's base toast styles with bslib-specific theming + +.toast { + // Use CSS variables for runtime theming + --bslib-toast-shadow: var(--bs-box-shadow); + box-shadow: var(--bslib-toast-shadow); +} + +// Ensure close button is visible on colored backgrounds +.toast.text-bg-primary .btn-close, +.toast.text-bg-secondary .btn-close, +.toast.text-bg-success .btn-close, +.toast.text-bg-info .btn-close, +.toast.text-bg-warning .btn-close, +.toast.text-bg-danger .btn-close, +.toast.text-bg-dark .btn-close { + filter: var(--bs-btn-close-white-filter); +} From dc54ca90c7fbc5a8d6f3e068436ffd1315fa4f9b Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 14:45:38 -0400 Subject: [PATCH 04/68] feat: Register toast component in TypeScript index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import toast component to ensure message handlers are registered when the components bundle is loaded. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- srcts/src/components/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/srcts/src/components/index.ts b/srcts/src/components/index.ts index 1f599a4b4..c94ef35ae 100644 --- a/srcts/src/components/index.ts +++ b/srcts/src/components/index.ts @@ -6,6 +6,7 @@ import "./card"; import "./sidebar"; import "./taskButton"; import "./submitTextArea"; +import "./toast"; // ---------------------------------------------------------------------------- // Register custom message handlers for Shiny From a307bde18f8ee1d8a615af797ba55292a947e1d3 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 14:46:56 -0400 Subject: [PATCH 05/68] test: Add comprehensive tests for toast component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test coverage includes: - Toast object creation and validation - Position and type argument validation - Accessibility attribute generation - Close button rendering logic - HTML structure generation - toast_header() functionality - All type and position options 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/testthat/test-toast.R | 204 ++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/testthat/test-toast.R diff --git a/tests/testthat/test-toast.R b/tests/testthat/test-toast.R new file mode 100644 index 000000000..2cb1bf74d --- /dev/null +++ b/tests/testthat/test-toast.R @@ -0,0 +1,204 @@ +test_that("toast() creates bslib_toast object", { + t <- toast("Test message") + + expect_s3_class(t, "bslib_toast") + expect_equal(t$body, "Test message") + expect_null(t$id) + expect_true(t$autohide) + expect_equal(t$duration, 5000) + expect_equal(t$position, "top-right") +}) + +test_that("toast() validates position argument", { + expect_no_error(toast("Test", position = "bottom-left")) + expect_no_error(toast("Test", position = "top-center")) + expect_no_error(toast("Test", position = "middle-center")) + expect_error(toast("Test", position = "invalid")) +}) + +test_that("toast() validates type argument", { + expect_no_error(toast("Test", type = "success")) + expect_no_error(toast("Test", type = "danger")) + expect_no_error(toast("Test", type = "info")) + expect_error(toast("Test", type = "invalid")) +}) + +test_that("toast() enforces closable for non-autohiding toasts", { + t <- toast("Test", autohide = FALSE, closable = FALSE) + expect_true(t$closable) +}) + +test_that("toast() preserves closable = TRUE when autohide = FALSE", { + t <- toast("Test", autohide = FALSE, closable = TRUE) + expect_true(t$closable) +}) + +test_that("toast() allows closable = FALSE when autohide = TRUE", { + t <- toast("Test", autohide = TRUE, closable = FALSE) + expect_false(t$closable) +}) + +test_that("as.tags.bslib_toast creates proper HTML structure", { + t <- toast( + body = "Test message", + header = "Test", + type = "success", + id = "test-toast" + ) + + tag <- as.tags(t) + + expect_s3_class(tag, "shiny.tag") + html_str <- as.character(tag) + expect_true(grepl("toast", html_str)) + expect_true(grepl("text-bg-success", html_str)) + expect_true(grepl('id="test-toast"', html_str)) +}) + +test_that("as.tags.bslib_toast generates ID if not provided", { + t <- toast("Test message") + tag <- as.tags(t) + + html_str <- as.character(tag) + expect_true(grepl('id="bslib-toast-', html_str)) +}) + +test_that("as.tags.bslib_toast respects accessibility attributes", { + # Danger type gets assertive + t_danger <- toast("Error message", type = "danger") + tag_danger <- as.tags(t_danger) + html_danger <- as.character(tag_danger) + expect_true(grepl('aria-live="assertive"', html_danger)) + expect_true(grepl('role="alert"', html_danger)) + + # Info type gets polite + t_info <- toast("Info message", type = "info") + tag_info <- as.tags(t_info) + html_info <- as.character(tag_info) + expect_true(grepl('aria-live="polite"', html_info)) + expect_true(grepl('role="status"', html_info)) + + # NULL type (default) gets polite + t_default <- toast("Default message") + tag_default <- as.tags(t_default) + html_default <- as.character(tag_default) + expect_true(grepl('aria-live="polite"', html_default)) + expect_true(grepl('role="status"', html_default)) +}) + +test_that("as.tags.bslib_toast includes close button appropriately", { + # With header, closable + t_header <- toast("Message", header = "Title", closable = TRUE) + tag_header <- as.tags(t_header) + html_header <- as.character(tag_header) + expect_true(grepl("btn-close", html_header)) + expect_true(grepl("toast-header", html_header)) + + # Without header, closable + t_no_header <- toast("Message", closable = TRUE) + tag_no_header <- as.tags(t_no_header) + html_no_header <- as.character(tag_no_header) + expect_true(grepl("btn-close", html_no_header)) + expect_false(grepl("toast-header", html_no_header)) + + # Non-closable with autohide + t_non_closable <- toast("Message", closable = FALSE, autohide = TRUE) + tag_non_closable <- as.tags(t_non_closable) + html_non_closable <- as.character(tag_non_closable) + expect_false(grepl("btn-close", html_non_closable)) +}) + +test_that("toast_header() creates structured header", { + # Simple header with just title + h1 <- toast_header("My Title") + expect_s3_class(h1, "shiny.tag.list") + html1 <- as.character(h1) + expect_true(grepl("My Title", html1)) + expect_true(grepl("me-auto", html1)) + + # Header with status + h2 <- toast_header("Success", status = "success") + html2 <- as.character(h2) + expect_true(grepl("badge", html2)) + expect_true(grepl("bg-success", html2)) + + # Header validates status + expect_error(toast_header("Test", status = "invalid")) +}) + +test_that("toast_header() works with icons", { + # Mock icon (just a simple span for testing) + icon <- htmltools::span(class = "test-icon") + + h <- toast_header("Title", icon = icon) + html <- as.character(h) + expect_true(grepl("test-icon", html)) + expect_true(grepl("Title", html)) +}) + +test_that("toast() stores additional attributes", { + t <- toast("Test", `data-test` = "value", class = "extra-class") + + expect_equal(t$attribs$`data-test`, "value") + expect_equal(t$class, "extra-class") + + tag <- as.tags(t) + html <- as.character(tag) + expect_true(grepl('data-test="value"', html)) + expect_true(grepl("extra-class", html)) +}) + +test_that("toast() with custom duration", { + t <- toast("Test", duration = 10000) + expect_equal(t$duration, 10000) +}) + +test_that("toast() with all type options", { + types <- c("primary", "secondary", "success", "info", "warning", "danger", "light", "dark") + + for (type in types) { + t <- toast("Test", type = type) + expect_equal(t$type, type) + + tag <- as.tags(t) + html <- as.character(tag) + expect_true(grepl(paste0("text-bg-", type), html)) + } +}) + +test_that("toast() with all position options", { + positions <- c( + "top-left", "top-center", "top-right", + "middle-left", "middle-center", "middle-right", + "bottom-left", "bottom-center", "bottom-right" + ) + + for (pos in positions) { + t <- toast("Test", position = pos) + expect_equal(t$position, pos) + } +}) + +test_that("toast with character header", { + t <- toast("Body", header = "Simple Header") + tag <- as.tags(t) + html <- as.character(tag) + + expect_true(grepl("toast-header", html)) + expect_true(grepl("Simple Header", html)) + expect_true(grepl("me-auto", html)) +}) + +test_that("toast with toast_header() result", { + t <- toast( + "Body", + header = toast_header("Title", status = "success") + ) + tag <- as.tags(t) + html <- as.character(tag) + + expect_true(grepl("toast-header", html)) + expect_true(grepl("Title", html)) + expect_true(grepl("badge", html)) + expect_true(grepl("bg-success", html)) +}) From 69b85716164dd547bbedb42e41ab3d40cd4a1b62 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 14:48:15 -0400 Subject: [PATCH 06/68] feat: Add toast demo Shiny app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create comprehensive demo app showcasing toast features: - Basic toasts with different types (success, error, warning, info) - Position options (all 9 positions) - Advanced features (persistent, long duration, no close button) - Interactive toasts with action buttons - Multiple toasts and stacking behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- inst/examples-shiny/toast/app.R | 243 ++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 inst/examples-shiny/toast/app.R diff --git a/inst/examples-shiny/toast/app.R b/inst/examples-shiny/toast/app.R new file mode 100644 index 000000000..cdd6c410f --- /dev/null +++ b/inst/examples-shiny/toast/app.R @@ -0,0 +1,243 @@ +library(shiny) +library(bslib) + +ui <- page_fluid( + theme = bs_theme(version = 5), + h2("Toast Notifications Demo"), + + layout_column_wrap( + width = 1 / 2, + card( + card_header("Basic Toasts"), + card_body( + actionButton("show_basic", "Show Basic Toast", class = "mb-2 w-100"), + actionButton("show_success", "Show Success", class = "mb-2 w-100"), + actionButton("show_error", "Show Error", class = "mb-2 w-100"), + actionButton("show_warning", "Show Warning", class = "mb-2 w-100"), + actionButton("show_info", "Show Info", class = "mb-2 w-100") + ) + ), + + card( + card_header("Position Options"), + card_body( + actionButton("show_top_left", "Top Left", class = "mb-2 w-100"), + actionButton("show_top_center", "Top Center", class = "mb-2 w-100"), + actionButton("show_top_right", "Top Right", class = "mb-2 w-100"), + actionButton("show_bottom_left", "Bottom Left", class = "mb-2 w-100"), + actionButton("show_bottom_right", "Bottom Right", class = "mb-2 w-100") + ) + ), + + card( + card_header("Advanced Features"), + card_body( + actionButton("show_persistent", "Show Persistent Toast", class = "mb-2 w-100"), + actionButton("hide_persistent", "Hide Persistent Toast", class = "mb-2 w-100"), + actionButton("show_long_duration", "Long Duration (10s)", class = "mb-2 w-100"), + actionButton("show_no_close", "No Close Button", class = "mb-2 w-100"), + actionButton("show_custom_header", "Custom Header with Status", class = "mb-2 w-100") + ) + ), + + card( + card_header("Interactive Toasts"), + card_body( + actionButton("show_action_buttons", "Toast with Action Buttons", class = "mb-2 w-100"), + actionButton("show_multiple", "Show Multiple Toasts", class = "mb-2 w-100"), + actionButton("show_all_positions", "Test All Positions", class = "mb-2 w-100") + ) + ) + ) +) + +server <- function(input, output, session) { + # Store persistent toast ID + persistent_toast_id <- reactiveVal(NULL) + + # Basic toasts + observeEvent(input$show_basic, { + show_toast("This is a basic toast notification!") + }) + + observeEvent(input$show_success, { + show_toast( + toast( + body = "Operation completed successfully!", + header = "Success", + type = "success" + ) + ) + }) + + observeEvent(input$show_error, { + show_toast( + toast( + body = "An error occurred while processing your request.", + header = "Error", + type = "danger" + ) + ) + }) + + observeEvent(input$show_warning, { + show_toast( + toast( + body = "Please save your work before continuing.", + header = "Warning", + type = "warning" + ) + ) + }) + + observeEvent(input$show_info, { + show_toast( + toast( + body = "This is an informational message.", + header = "Info", + type = "info" + ) + ) + }) + + # Position options + observeEvent(input$show_top_left, { + show_toast( + toast("Toast at top-left", type = "primary", position = "top-left") + ) + }) + + observeEvent(input$show_top_center, { + show_toast( + toast("Toast at top-center", type = "secondary", position = "top-center") + ) + }) + + observeEvent(input$show_top_right, { + show_toast( + toast("Toast at top-right (default)", type = "success", position = "top-right") + ) + }) + + observeEvent(input$show_bottom_left, { + show_toast( + toast("Toast at bottom-left", type = "info", position = "bottom-left") + ) + }) + + observeEvent(input$show_bottom_right, { + show_toast( + toast("Toast at bottom-right", type = "warning", position = "bottom-right") + ) + }) + + # Advanced features + observeEvent(input$show_persistent, { + id <- show_toast( + toast( + body = "This toast won't disappear automatically. Use the 'Hide' button to dismiss it.", + header = "Persistent Toast", + type = "info", + autohide = FALSE + ) + ) + persistent_toast_id(id) + }) + + observeEvent(input$hide_persistent, { + req(persistent_toast_id()) + hide_toast(persistent_toast_id()) + persistent_toast_id(NULL) + }) + + observeEvent(input$show_long_duration, { + show_toast( + toast( + body = "This toast will stay visible for 10 seconds.", + header = "Long Duration", + type = "primary", + duration = 10000 + ) + ) + }) + + observeEvent(input$show_no_close, { + show_toast( + toast( + body = "This toast has no close button but will auto-hide in 3 seconds.", + type = "secondary", + closable = FALSE, + duration = 3000 + ) + ) + }) + + observeEvent(input$show_custom_header, { + show_toast( + toast( + body = "Your profile has been updated successfully.", + header = toast_header( + title = "Profile Updated", + status = "success" + ), + type = "success" + ) + ) + }) + + # Interactive toasts + observeEvent(input$show_action_buttons, { + show_toast( + toast( + body = tagList( + p("Would you like to save your changes?"), + div( + class = "mt-2", + actionButton("save_yes", "Save", class = "btn-sm btn-primary me-2"), + actionButton("save_no", "Don't Save", class = "btn-sm btn-secondary") + ) + ), + header = "Unsaved Changes", + type = "warning", + autohide = FALSE + ) + ) + }) + + observeEvent(input$save_yes, { + showNotification("Changes saved!", type = "message") + }) + + observeEvent(input$save_no, { + showNotification("Changes discarded.", type = "message") + }) + + observeEvent(input$show_multiple, { + show_toast(toast("First notification", type = "primary")) + Sys.sleep(0.2) + show_toast(toast("Second notification", type = "success")) + Sys.sleep(0.2) + show_toast(toast("Third notification", type = "info")) + }) + + observeEvent(input$show_all_positions, { + positions <- c( + "top-left", "top-center", "top-right", + "bottom-left", "bottom-center", "bottom-right" + ) + + for (i in seq_along(positions)) { + pos <- positions[i] + show_toast( + toast( + body = paste("Toast at", pos), + type = c("primary", "success", "info", "warning", "danger", "secondary")[i], + duration = 3000, + position = pos + ) + ) + } + }) +} + +shinyApp(ui, server) From 6b3f1050d4d2f1055c477409027d670e3cea149f Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 16:04:31 -0400 Subject: [PATCH 07/68] chore: document() --- DESCRIPTION | 1 + NAMESPACE | 5 +++ man/hide_toast.Rd | 60 ++++++++++++++++++++++++++ man/show_toast.Rd | 62 +++++++++++++++++++++++++++ man/toast.Rd | 100 ++++++++++++++++++++++++++++++++++++++++++++ man/toast_header.Rd | 61 +++++++++++++++++++++++++++ 6 files changed, 289 insertions(+) create mode 100644 man/hide_toast.Rd create mode 100644 man/show_toast.Rd create mode 100644 man/toast.Rd create mode 100644 man/toast_header.Rd diff --git a/DESCRIPTION b/DESCRIPTION index c20469d01..edde6d835 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -115,6 +115,7 @@ Collate: 'shiny-devmode.R' 'sidebar.R' 'staticimports.R' + 'toast.R' 'tooltip.R' 'utils-deps.R' 'utils-shiny.R' diff --git a/NAMESPACE b/NAMESPACE index 27d4445aa..fa7cdfd57 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,6 +1,7 @@ # Generated by roxygen2: do not edit by hand S3method(as.tags,bslib_sidebar) +S3method(as.tags,bslib_toast) S3method(bind_task_button,ExtendedTask) S3method(bind_task_button,default) S3method(brand_resolve,"NULL") @@ -86,6 +87,7 @@ export(font_collection) export(font_face) export(font_google) export(font_link) +export(hide_toast) export(input_dark_mode) export(input_submit_textarea) export(input_switch) @@ -140,6 +142,7 @@ export(popover) export(precompiled_css_path) export(remove_all_fill) export(run_with_themer) +export(show_toast) export(showcase_bottom) export(showcase_left_center) export(showcase_top_right) @@ -147,6 +150,8 @@ export(sidebar) export(sidebar_toggle) export(theme_bootswatch) export(theme_version) +export(toast) +export(toast_header) export(toggle_dark_mode) export(toggle_popover) export(toggle_sidebar) diff --git a/man/hide_toast.Rd b/man/hide_toast.Rd new file mode 100644 index 000000000..dc12fffaa --- /dev/null +++ b/man/hide_toast.Rd @@ -0,0 +1,60 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/toast.R +\name{hide_toast} +\alias{hide_toast} +\title{Hide a toast notification} +\usage{ +hide_toast(id, session = shiny::getDefaultReactiveDomain()) +} +\arguments{ +\item{id}{String with the toast ID returned by \code{\link[=show_toast]{show_toast()}} or a \code{toast} +object provided that the \code{id} was set when created/shown.} + +\item{session}{Shiny session object.} +} +\value{ +Called for side effects; returns \code{NULL} invisibly. +} +\description{ +Manually dismisses a toast notification. +} +\examples{ +\dontshow{if (rlang::is_interactive()) withAutoprint(\{ # examplesIf} +library(shiny) +library(bslib) + +ui <- page_fluid( + actionButton("show_persistent", "Show Persistent Toast"), + actionButton("hide_persistent", "Hide Toast") +) + +server <- function(input, output, session) { + toast_id <- reactiveVal(NULL) + + observeEvent(input$show_persistent, { + id <- show_toast( + toast( + body = "This toast won't disappear automatically.", + autohide = FALSE + ) + ) + toast_id(id) + }) + + observeEvent(input$hide_persistent, { + req(toast_id()) + hide_toast(toast_id()) + toast_id(NULL) + }) +} + +shinyApp(ui, server) +\dontshow{\}) # examplesIf} +} +\seealso{ +Other Toast components: +\code{\link{show_toast}()}, +\code{\link{toast}()}, +\code{\link{toast_header}()} +} +\concept{Toast components} diff --git a/man/show_toast.Rd b/man/show_toast.Rd new file mode 100644 index 000000000..797d9bdcb --- /dev/null +++ b/man/show_toast.Rd @@ -0,0 +1,62 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/toast.R +\name{show_toast} +\alias{show_toast} +\title{Display a toast notification} +\usage{ +show_toast(toast, ..., session = shiny::getDefaultReactiveDomain()) +} +\arguments{ +\item{toast}{A \code{bslib_toast} object created by \code{\link[=toast]{toast()}}, or a string/UI +element (which will be automatically converted to a toast with default +settings).} + +\item{...}{Reserved for future extensions (currently ignored).} + +\item{session}{Shiny session object.} +} +\value{ +Invisibly returns the toast ID (string) that can be used with +\code{\link[=hide_toast]{hide_toast()}}. +} +\description{ +Displays a toast notification in a Shiny application. +} +\examples{ +\dontshow{if (rlang::is_interactive()) withAutoprint(\{ # examplesIf} +library(shiny) +library(bslib) + +ui <- page_fluid( + actionButton("show_simple", "Show Simple Toast"), + actionButton("show_success", "Show Success Toast") +) + +server <- function(input, output, session) { + observeEvent(input$show_simple, { + # Simple string automatically converted to toast + show_toast("Operation completed!") + }) + + observeEvent(input$show_success, { + # Show a pre-created toast + show_toast( + toast( + body = "Your file has been uploaded.", + header = "Success", + type = "success" + ) + ) + }) +} + +shinyApp(ui, server) +\dontshow{\}) # examplesIf} +} +\seealso{ +Other Toast components: +\code{\link{hide_toast}()}, +\code{\link{toast}()}, +\code{\link{toast_header}()} +} +\concept{Toast components} diff --git a/man/toast.Rd b/man/toast.Rd new file mode 100644 index 000000000..c6d33a6fe --- /dev/null +++ b/man/toast.Rd @@ -0,0 +1,100 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/toast.R +\name{toast} +\alias{toast} +\alias{as.tags.bslib_toast} +\title{Toast notifications} +\usage{ +toast( + body, + header = NULL, + ..., + id = NULL, + type = NULL, + autohide = TRUE, + duration = 5000, + position = "top-right", + closable = TRUE, + class = NULL +) + +\method{as.tags}{bslib_toast}(x, ...) +} +\arguments{ +\item{body}{Main content of the toast. Can be text, HTML, or Shiny UI elements.} + +\item{header}{Optional header content. Can be a string, or the result of +\code{\link[=toast_header]{toast_header()}}. If provided, creates a \code{.toast-header} with close button +(if \code{closable = TRUE}).} + +\item{...}{Additional HTML attributes passed to the toast container.} + +\item{id}{Optional unique identifier for the toast. If \code{NULL}, an ID will be +automatically generated when the toast is shown via \code{\link[=show_toast]{show_toast()}}. +Providing a stable ID allows you to update or hide the toast later.} + +\item{type}{Optional semantic type. One of \code{NULL}, \code{"primary"}, \code{"secondary"}, +\code{"success"}, \code{"info"}, \code{"warning"}, \code{"danger"}, \code{"light"}, or \code{"dark"}. +Applies appropriate Bootstrap background utility classes (\verb{text-bg-*}).} + +\item{autohide}{Logical. Whether to automatically hide the toast after +\code{duration} milliseconds. Default \code{TRUE}.} + +\item{duration}{Numeric. Time in milliseconds before auto-hiding. Default +\code{5000} (5 seconds). Ignored if \code{autohide = FALSE}.} + +\item{position}{String. Where to position the toast container. One of +\code{"top-left"}, \code{"top-center"}, \code{"top-right"} (default), \code{"middle-left"}, +\code{"middle-center"}, \code{"middle-right"}, \code{"bottom-left"}, \code{"bottom-center"}, +or \code{"bottom-right"}.} + +\item{closable}{Logical. Whether to include a close button. Default \code{TRUE}. +When \code{autohide = FALSE}, a close button is always included regardless of +this setting (for accessibility).} + +\item{class}{Additional CSS classes for the toast.} + +\item{x}{A \code{bslib_toast} object.} +} +\value{ +A \code{bslib_toast} object that can be passed to \code{\link[=show_toast]{show_toast()}}. +} +\description{ +Toast notifications are lightweight, temporary messages designed to mimic +push notifications from mobile and desktop operating systems. They are built +on Bootstrap 5's native toast component. +} +\examples{ +\dontshow{if (rlang::is_interactive()) withAutoprint(\{ # examplesIf} +library(shiny) +library(bslib) + +ui <- page_fluid( + actionButton("show_toast", "Show Toast") +) + +server <- function(input, output, session) { + observeEvent(input$show_toast, { + show_toast( + toast( + body = "Operation completed successfully!", + header = "Success", + type = "success" + ) + ) + }) +} + +shinyApp(ui, server) +\dontshow{\}) # examplesIf} +} +\seealso{ +\code{\link[=show_toast]{show_toast()}} to display a toast, \code{\link[=hide_toast]{hide_toast()}} to dismiss a toast, +and \code{\link[=toast_header]{toast_header()}} to create structured headers. + +Other Toast components: +\code{\link{hide_toast}()}, +\code{\link{show_toast}()}, +\code{\link{toast_header}()} +} +\concept{Toast components} diff --git a/man/toast_header.Rd b/man/toast_header.Rd new file mode 100644 index 000000000..fc2189c8f --- /dev/null +++ b/man/toast_header.Rd @@ -0,0 +1,61 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/toast.R +\name{toast_header} +\alias{toast_header} +\title{Create a structured toast header} +\usage{ +toast_header(title, icon = NULL, status = NULL, ...) +} +\arguments{ +\item{title}{Header text (required).} + +\item{icon}{Optional icon element (e.g., from \code{bsicons::bs_icon()} or +\code{fontawesome::fa()}).} + +\item{status}{Optional status indicator. One of \code{NULL}, \code{"primary"}, +\code{"secondary"}, \code{"success"}, \code{"info"}, \code{"warning"}, \code{"danger"}, \code{"light"}, +or \code{"dark"}. Adds a colored dot/badge before the title.} + +\item{...}{Additional HTML attributes passed to the header container.} +} +\value{ +A tag object representing the toast header content. +} +\description{ +Creates a structured header for a toast with optional icon and status +indicator. +} +\examples{ +\dontshow{if (rlang::is_interactive()) withAutoprint(\{ # examplesIf} +library(shiny) +library(bslib) + +ui <- page_fluid( + actionButton("show_header", "Show Toast with Header") +) + +server <- function(input, output, session) { + observeEvent(input$show_header, { + show_toast( + toast( + body = "Your settings have been saved.", + header = toast_header( + title = "Settings Updated", + status = "success" + ), + type = "success" + ) + ) + }) +} + +shinyApp(ui, server) +\dontshow{\}) # examplesIf} +} +\seealso{ +Other Toast components: +\code{\link{hide_toast}()}, +\code{\link{show_toast}()}, +\code{\link{toast}()} +} +\concept{Toast components} From ec6c6679af2c314ef1b571cd1682a8556681bc68 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 16:05:05 -0400 Subject: [PATCH 08/68] chore: Add missing `shinyRenderDependencies()` and clean up lint errors --- srcts/src/components/_utils.ts | 12 +++++++++++ srcts/src/components/toast.ts | 37 ++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/srcts/src/components/_utils.ts b/srcts/src/components/_utils.ts index 19dc5dd28..e00f6f3d9 100644 --- a/srcts/src/components/_utils.ts +++ b/srcts/src/components/_utils.ts @@ -119,6 +119,17 @@ async function shinyRenderContent( } } +async function shinyRenderDependencies(deps: HtmlDep[]): Promise { + if (!Shiny) { + throw new Error("This function must be called in a Shiny app."); + } + if (Shiny.renderDependenciesAsync) { + return await Shiny.renderDependenciesAsync(deps); + } else { + return Shiny.renderDependencies(deps); + } +} + // Copied from shiny utils async function updateLabel( labelContent: string | { html: string; deps: HtmlDep[] } | undefined, @@ -153,6 +164,7 @@ export { doWindowResizeOnElementResize, getAllFocusableChildren, shinyRenderContent, + shinyRenderDependencies, showShinyClientMessage, Shiny, updateLabel, diff --git a/srcts/src/components/toast.ts b/srcts/src/components/toast.ts index 7b19b2c03..6e5630940 100644 --- a/srcts/src/components/toast.ts +++ b/srcts/src/components/toast.ts @@ -4,15 +4,15 @@ import { shinyAddCustomMessageHandlers } from "./_shinyAddCustomMessageHandlers" import { shinyRenderDependencies } from "./_utils"; type ToastPosition = - | "top-left" - | "top-center" - | "top-right" - | "middle-left" + | "bottom-center" + | "bottom-left" + | "bottom-right" | "middle-center" + | "middle-left" | "middle-right" - | "bottom-left" - | "bottom-center" - | "bottom-right"; + | "top-center" + | "top-left" + | "top-right"; interface ToastOptions { animation?: boolean; @@ -40,20 +40,20 @@ class ToastContainerManager { let container = this.containers.get(position); if (!container || !document.body.contains(container)) { - container = this.createContainer(position); + container = this._createContainer(position); this.containers.set(position, container); } return container; } - private createContainer(position: ToastPosition): HTMLElement { + private _createContainer(position: ToastPosition): HTMLElement { const container = document.createElement("div"); container.className = "toast-container position-fixed p-3"; container.setAttribute("data-bslib-toast-container", position); // Apply position classes - const positionClasses = this.getPositionClasses(position); + const positionClasses = this._getPositionClasses(position); container.classList.add(...positionClasses); document.body.appendChild(container); @@ -61,16 +61,25 @@ class ToastContainerManager { return container; } - private getPositionClasses(position: ToastPosition): string[] { - const classMap: Record = { + private _getPositionClasses(position: ToastPosition): string[] { + const classMap: { [key in ToastPosition]: string[] } = { + // eslint-disable-next-line @typescript-eslint/naming-convention "top-left": ["top-0", "start-0"], + // eslint-disable-next-line @typescript-eslint/naming-convention "top-center": ["top-0", "start-50", "translate-middle-x"], + // eslint-disable-next-line @typescript-eslint/naming-convention "top-right": ["top-0", "end-0"], + // eslint-disable-next-line @typescript-eslint/naming-convention "middle-left": ["top-50", "start-0", "translate-middle-y"], + // eslint-disable-next-line @typescript-eslint/naming-convention "middle-center": ["top-50", "start-50", "translate-middle"], + // eslint-disable-next-line @typescript-eslint/naming-convention "middle-right": ["top-50", "end-0", "translate-middle-y"], + // eslint-disable-next-line @typescript-eslint/naming-convention "bottom-left": ["bottom-0", "start-0"], + // eslint-disable-next-line @typescript-eslint/naming-convention "bottom-center": ["bottom-0", "start-50", "translate-middle-x"], + // eslint-disable-next-line @typescript-eslint/naming-convention "bottom-right": ["bottom-0", "end-0"], }; @@ -82,7 +91,7 @@ const containerManager = new ToastContainerManager(); // Show toast handler async function showToast(message: ShowToastMessage): Promise { - const { html, deps, options, position, id } = message; + const { html, deps, options, position } = message; // Render dependencies await shinyRenderDependencies(deps); @@ -139,7 +148,9 @@ function hideToast(message: HideToastMessage): void { // Register message handlers shinyAddCustomMessageHandlers({ + // eslint-disable-next-line @typescript-eslint/naming-convention "bslib.show-toast": showToast, + // eslint-disable-next-line @typescript-eslint/naming-convention "bslib.hide-toast": hideToast, }); From 410ae7be5608d44c089bd2090979aa3f0398882d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 16:08:06 -0400 Subject: [PATCH 09/68] fix: Use window.bootstrap for Toast with type safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change from direct import to window.bootstrap access pattern: - Import Toast type only, not implementation - Access Toast from window.bootstrap with class fallback - Add console warning if Bootstrap is not available - Follows pattern used by tooltip and popover components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- srcts/src/components/toast.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/srcts/src/components/toast.ts b/srcts/src/components/toast.ts index 6e5630940..60263ee4a 100644 --- a/srcts/src/components/toast.ts +++ b/srcts/src/components/toast.ts @@ -1,8 +1,12 @@ import type { HtmlDep } from "./_utils"; -import { Toast as BootstrapToast } from "bootstrap"; +import type { Toast as ToastType } from "bootstrap"; import { shinyAddCustomMessageHandlers } from "./_shinyAddCustomMessageHandlers"; import { shinyRenderDependencies } from "./_utils"; +const BootstrapToast = ( + window.bootstrap ? window.bootstrap.Toast : class {} +) as typeof ToastType; + type ToastPosition = | "bottom-center" | "bottom-left" @@ -93,6 +97,14 @@ const containerManager = new ToastContainerManager(); async function showToast(message: ShowToastMessage): Promise { const { html, deps, options, position } = message; + // Check if Bootstrap is available + if (!window.bootstrap || !window.bootstrap.Toast) { + console.warn( + "Toast requires Bootstrap 5 to be available on window.bootstrap.Toast" + ); + return; + } + // Render dependencies await shinyRenderDependencies(deps); From 945373cd83f9e9cf815a9445fd2651cd550cfea1 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 16:23:00 -0400 Subject: [PATCH 10/68] sass: toast styles --- inst/builtin/bs5/shiny/_variables.scss | 4 ++++ inst/components/scss/_toast.scss | 14 +++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/inst/builtin/bs5/shiny/_variables.scss b/inst/builtin/bs5/shiny/_variables.scss index c7d45a002..3d01e2fd7 100644 --- a/inst/builtin/bs5/shiny/_variables.scss +++ b/inst/builtin/bs5/shiny/_variables.scss @@ -172,5 +172,9 @@ $modal-header-border-width: none !default; $modal-header-padding: 1.5rem !default; $modal-backdrop-bg: #464646 !default; +// Toasts +$toast-spacing: 0.5rem !default; +$toast-border-width: 0 !default; + // Shiny: Base shiny.scss variables $notification-close-color: currentColor !default; diff --git a/inst/components/scss/_toast.scss b/inst/components/scss/_toast.scss index 95c4ecc72..f377e8e98 100644 --- a/inst/components/scss/_toast.scss +++ b/inst/components/scss/_toast.scss @@ -8,12 +8,12 @@ } // Ensure close button is visible on colored backgrounds -.toast.text-bg-primary .btn-close, -.toast.text-bg-secondary .btn-close, -.toast.text-bg-success .btn-close, -.toast.text-bg-info .btn-close, -.toast.text-bg-warning .btn-close, -.toast.text-bg-danger .btn-close, -.toast.text-bg-dark .btn-close { +.toast.text-bg-primary .toast-body .btn-close, +.toast.text-bg-secondary .toast-body .btn-close, +.toast.text-bg-success .toast-body .btn-close, +.toast.text-bg-info .toast-body .btn-close, +.toast.text-bg-warning .toast-body .btn-close, +.toast.text-bg-danger .toast-body .btn-close, +.toast.text-bg-dark .toast-body .btn-close { filter: var(--bs-btn-close-white-filter); } From f34fab831ba357300dc950eacff676863fd1a5fc Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 16:23:31 -0400 Subject: [PATCH 11/68] chore: better wrapping of bootstrap.Toast --- srcts/src/components/toast.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/srcts/src/components/toast.ts b/srcts/src/components/toast.ts index 60263ee4a..b4c646993 100644 --- a/srcts/src/components/toast.ts +++ b/srcts/src/components/toast.ts @@ -3,7 +3,7 @@ import type { Toast as ToastType } from "bootstrap"; import { shinyAddCustomMessageHandlers } from "./_shinyAddCustomMessageHandlers"; import { shinyRenderDependencies } from "./_utils"; -const BootstrapToast = ( +const bootstrapToast = ( window.bootstrap ? window.bootstrap.Toast : class {} ) as typeof ToastType; @@ -53,7 +53,7 @@ class ToastContainerManager { private _createContainer(position: ToastPosition): HTMLElement { const container = document.createElement("div"); - container.className = "toast-container position-fixed p-3"; + container.className = "toast-container position-fixed p-1 p-md-2"; container.setAttribute("data-bslib-toast-container", position); // Apply position classes @@ -125,7 +125,7 @@ async function showToast(message: ShowToastMessage): Promise { container.appendChild(toastEl); // Initialize Bootstrap toast - const bsToast = new BootstrapToast(toastEl, options); + const bsToast = new bootstrapToast(toastEl, options); // Show the toast bsToast.show(); @@ -151,7 +151,7 @@ function hideToast(message: HideToastMessage): void { return; } - const bsToast = BootstrapToast.getInstance(toastEl); + const bsToast = bootstrapToast.getInstance(toastEl); if (bsToast) { bsToast.hide(); From 70c78e7e3a3381b2f8c540ca62468c5dbf117cf2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 16:27:44 -0400 Subject: [PATCH 12/68] refactor: Rewrite toast demo as interactive builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace basic examples with comprehensive toast builder: - Interactive form with all toast options - input_switch() for binary settings (header, icon, status, autohide, etc) - Conditional panels to show/hide relevant options - Select menus for type, position, and icon choices - Slider for duration control - Custom ID option for manual toast management - Two buttons: show toast and hide last toast - Keep advanced features and interactive toast examples Users can now experiment with all toast parameters interactively. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- inst/examples-shiny/toast/app.R | 267 +++++++++++++++++++++----------- 1 file changed, 180 insertions(+), 87 deletions(-) diff --git a/inst/examples-shiny/toast/app.R b/inst/examples-shiny/toast/app.R index cdd6c410f..226a09792 100644 --- a/inst/examples-shiny/toast/app.R +++ b/inst/examples-shiny/toast/app.R @@ -7,28 +7,138 @@ ui <- page_fluid( layout_column_wrap( width = 1 / 2, - card( - card_header("Basic Toasts"), - card_body( - actionButton("show_basic", "Show Basic Toast", class = "mb-2 w-100"), - actionButton("show_success", "Show Success", class = "mb-2 w-100"), - actionButton("show_error", "Show Error", class = "mb-2 w-100"), - actionButton("show_warning", "Show Warning", class = "mb-2 w-100"), - actionButton("show_info", "Show Info", class = "mb-2 w-100") - ) - ), + # Toast Builder Card card( - card_header("Position Options"), + card_header("Toast Builder"), card_body( - actionButton("show_top_left", "Top Left", class = "mb-2 w-100"), - actionButton("show_top_center", "Top Center", class = "mb-2 w-100"), - actionButton("show_top_right", "Top Right", class = "mb-2 w-100"), - actionButton("show_bottom_left", "Bottom Left", class = "mb-2 w-100"), - actionButton("show_bottom_right", "Bottom Right", class = "mb-2 w-100") + # Body content + textAreaInput( + "body", + "Body Content", + value = "This is a toast notification!", + rows = 3, + width = "100%" + ), + + # Header options + input_switch("use_header", "Include Header", value = FALSE), + conditionalPanel( + "input.use_header", + textInput("header_title", "Header Title", value = "Notification"), + input_switch("use_header_icon", "Include Icon", value = FALSE), + conditionalPanel( + "input.use_header_icon", + selectInput( + "header_icon", + "Icon", + choices = c( + "None" = "", + "Check" = "check", + "Info" = "info-circle", + "Warning" = "exclamation-triangle", + "Error" = "times-circle", + "Star" = "star", + "Heart" = "heart", + "Bell" = "bell", + "User" = "user", + "Cog" = "cog" + ), + selected = "" + ) + ), + input_switch("use_header_status", "Include Status Indicator", value = FALSE), + conditionalPanel( + "input.use_header_status", + selectInput( + "header_status", + "Status", + choices = c( + "Primary" = "primary", + "Secondary" = "secondary", + "Success" = "success", + "Info" = "info", + "Warning" = "warning", + "Danger" = "danger", + "Light" = "light", + "Dark" = "dark" + ), + selected = "primary" + ) + ) + ), + + # Type + selectInput( + "type", + "Type (Background Color)", + choices = c( + "None (default)" = "", + "Primary" = "primary", + "Secondary" = "secondary", + "Success" = "success", + "Info" = "info", + "Warning" = "warning", + "Danger" = "danger", + "Light" = "light", + "Dark" = "dark" + ), + selected = "" + ), + + # Position + selectInput( + "position", + "Position", + choices = c( + "Top Left" = "top-left", + "Top Center" = "top-center", + "Top Right" = "top-right", + "Middle Left" = "middle-left", + "Middle Center" = "middle-center", + "Middle Right" = "middle-right", + "Bottom Left" = "bottom-left", + "Bottom Center" = "bottom-center", + "Bottom Right" = "bottom-right" + ), + selected = "top-right" + ), + + # Auto-hide options + input_switch("autohide", "Auto-hide", value = TRUE), + conditionalPanel( + "input.autohide", + sliderInput( + "duration", + "Duration (milliseconds)", + min = 1000, + max = 15000, + value = 5000, + step = 500, + width = "100%" + ) + ), + + # Close button + input_switch("closable", "Show Close Button", value = TRUE), + + # Custom ID + input_switch("use_custom_id", "Use Custom ID", value = FALSE), + conditionalPanel( + "input.use_custom_id", + textInput("custom_id", "Toast ID", value = "my-toast") + ), + + # Action buttons + div( + class = "mt-3 d-grid gap-2", + actionButton("show_toast", "Show Toast", class = "btn-primary"), + actionButton("hide_toast", "Hide Last Toast", class = "btn-secondary") + ) ) ), + # Advanced Features and Examples card( card_header("Advanced Features"), card_body( @@ -36,7 +146,7 @@ ui <- page_fluid( actionButton("hide_persistent", "Hide Persistent Toast", class = "mb-2 w-100"), actionButton("show_long_duration", "Long Duration (10s)", class = "mb-2 w-100"), actionButton("show_no_close", "No Close Button", class = "mb-2 w-100"), - actionButton("show_custom_header", "Custom Header with Status", class = "mb-2 w-100") + actionButton("show_custom_header", "Custom Header with Icon & Status", class = "mb-2 w-100") ) ), @@ -52,83 +162,62 @@ ui <- page_fluid( ) server <- function(input, output, session) { - # Store persistent toast ID + # Store last toast ID + last_toast_id <- reactiveVal(NULL) persistent_toast_id <- reactiveVal(NULL) - # Basic toasts - observeEvent(input$show_basic, { - show_toast("This is a basic toast notification!") - }) + # Show toast from builder + observeEvent(input$show_toast, { + # Build header if needed + header <- NULL + if (input$use_header) { + if (input$use_header_icon || input$use_header_status) { + # Use toast_header() for structured header + icon <- if (input$use_header_icon && nzchar(input$header_icon)) { + icon(input$header_icon) + } else { + NULL + } - observeEvent(input$show_success, { - show_toast( - toast( - body = "Operation completed successfully!", - header = "Success", - type = "success" - ) - ) - }) + status <- if (input$use_header_status) { + input$header_status + } else { + NULL + } - observeEvent(input$show_error, { - show_toast( - toast( - body = "An error occurred while processing your request.", - header = "Error", - type = "danger" - ) - ) - }) - - observeEvent(input$show_warning, { - show_toast( - toast( - body = "Please save your work before continuing.", - header = "Warning", - type = "warning" - ) - ) - }) - - observeEvent(input$show_info, { - show_toast( - toast( - body = "This is an informational message.", - header = "Info", - type = "info" - ) - ) - }) - - # Position options - observeEvent(input$show_top_left, { - show_toast( - toast("Toast at top-left", type = "primary", position = "top-left") - ) - }) + header <- toast_header( + title = input$header_title, + icon = icon, + status = status + ) + } else { + # Simple text header + header <- input$header_title + } + } - observeEvent(input$show_top_center, { - show_toast( - toast("Toast at top-center", type = "secondary", position = "top-center") + # Build toast + toast_obj <- toast( + body = input$body, + header = header, + id = if (input$use_custom_id) input$custom_id else NULL, + type = if (nzchar(input$type)) input$type else NULL, + autohide = input$autohide, + duration = input$duration, + position = input$position, + closable = input$closable ) - }) - observeEvent(input$show_top_right, { - show_toast( - toast("Toast at top-right (default)", type = "success", position = "top-right") - ) + # Show and store ID + id <- show_toast(toast_obj) + last_toast_id(id) }) - observeEvent(input$show_bottom_left, { - show_toast( - toast("Toast at bottom-left", type = "info", position = "bottom-left") - ) - }) - - observeEvent(input$show_bottom_right, { - show_toast( - toast("Toast at bottom-right", type = "warning", position = "bottom-right") - ) + # Hide last toast + observeEvent(input$hide_toast, { + req(last_toast_id()) + hide_toast(last_toast_id()) + last_toast_id(NULL) }) # Advanced features @@ -178,6 +267,7 @@ server <- function(input, output, session) { body = "Your profile has been updated successfully.", header = toast_header( title = "Profile Updated", + icon = icon("check"), status = "success" ), type = "success" @@ -223,16 +313,19 @@ server <- function(input, output, session) { observeEvent(input$show_all_positions, { positions <- c( "top-left", "top-center", "top-right", + "middle-left", "middle-center", "middle-right", "bottom-left", "bottom-center", "bottom-right" ) + types <- c("primary", "success", "info", "warning", "danger", "secondary", "light", "dark", "primary") + for (i in seq_along(positions)) { pos <- positions[i] show_toast( toast( body = paste("Toast at", pos), - type = c("primary", "success", "info", "warning", "danger", "secondary")[i], - duration = 3000, + type = types[i], + duration = 4000, position = pos ) ) From e6cc4ac94e862010cdf6a394ddb03e2cf2a56ba4 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 16:59:30 -0400 Subject: [PATCH 13/68] chore: reorganize inputs in example app --- inst/examples-shiny/toast/app.R | 337 ++++++++++++++++++-------------- 1 file changed, 193 insertions(+), 144 deletions(-) diff --git a/inst/examples-shiny/toast/app.R b/inst/examples-shiny/toast/app.R index 226a09792..a5281feaa 100644 --- a/inst/examples-shiny/toast/app.R +++ b/inst/examples-shiny/toast/app.R @@ -1,12 +1,23 @@ library(shiny) library(bslib) -ui <- page_fluid( +ui <- page_fillable( theme = bs_theme(version = 5), - h2("Toast Notifications Demo"), + title = "Toast Notifications Demo", + padding = 0, + gap = 0, + + h2("Toast Notifications Demo", class = "p-3 border-bottom mb-0"), + input_dark_mode(class = "position-absolute top-0 end-0 p-3"), layout_column_wrap( width = 1 / 2, + class = "bslib-page-dashboard", + style = css( + background = "var(--bslib-dashboard-main-bg)", + padding = "15px", + gap = "15px" + ), # Toast Builder Card card( @@ -21,39 +32,14 @@ ui <- page_fluid( width = "100%" ), - # Header options - input_switch("use_header", "Include Header", value = FALSE), - conditionalPanel( - "input.use_header", - textInput("header_title", "Header Title", value = "Notification"), - input_switch("use_header_icon", "Include Icon", value = FALSE), - conditionalPanel( - "input.use_header_icon", - selectInput( - "header_icon", - "Icon", - choices = c( - "None" = "", - "Check" = "check", - "Info" = "info-circle", - "Warning" = "exclamation-triangle", - "Error" = "times-circle", - "Star" = "star", - "Heart" = "heart", - "Bell" = "bell", - "User" = "user", - "Cog" = "cog" - ), - selected = "" - ) - ), - input_switch("use_header_status", "Include Status Indicator", value = FALSE), - conditionalPanel( - "input.use_header_status", + layout_columns( + div( + # Type selectInput( - "header_status", - "Status", + "type", + "Type (Background Color)", choices = c( + "None (default)" = "", "Primary" = "primary", "Secondary" = "secondary", "Success" = "success", @@ -63,72 +49,97 @@ ui <- page_fluid( "Light" = "light", "Dark" = "dark" ), - selected = "primary" - ) - ) - ), - - # Type - selectInput( - "type", - "Type (Background Color)", - choices = c( - "None (default)" = "", - "Primary" = "primary", - "Secondary" = "secondary", - "Success" = "success", - "Info" = "info", - "Warning" = "warning", - "Danger" = "danger", - "Light" = "light", - "Dark" = "dark" - ), - selected = "" - ), + selected = "" + ), - # Position - selectInput( - "position", - "Position", - choices = c( - "Top Left" = "top-left", - "Top Center" = "top-center", - "Top Right" = "top-right", - "Middle Left" = "middle-left", - "Middle Center" = "middle-center", - "Middle Right" = "middle-right", - "Bottom Left" = "bottom-left", - "Bottom Center" = "bottom-center", - "Bottom Right" = "bottom-right" + # Position + selectInput( + "position", + "Position", + choices = c( + "Top Left" = "top-left", + "Top Center" = "top-center", + "Top Right" = "top-right", + "Middle Left" = "middle-left", + "Middle Center" = "middle-center", + "Middle Right" = "middle-right", + "Bottom Left" = "bottom-left", + "Bottom Center" = "bottom-center", + "Bottom Right" = "bottom-right" + ), + selected = "top-right" + ), + + # Auto-hide options + sliderInput( + "duration", + "Duration (milliseconds)", + min = 0, + max = 25, + value = 5, + step = 1, + ticks = FALSE + ), + + # Close button + input_switch("closable", "Show Close Button", value = TRUE), + + textInput( + "custom_id", + "Toast ID", + placeholder = "Automatically generated" + ) ), - selected = "top-right" - ), - - # Auto-hide options - input_switch("autohide", "Auto-hide", value = TRUE), - conditionalPanel( - "input.autohide", - sliderInput( - "duration", - "Duration (milliseconds)", - min = 1000, - max = 15000, - value = 5000, - step = 500, - width = "100%" + div( + # Header options + input_switch("use_header", "Include Header", value = FALSE), + conditionalPanel( + "input.use_header", + textInput("header_title", "Header Title", value = "Notification"), + selectInput( + "header_icon", + "Icon", + choices = c( + "None" = "", + "Check" = "check", + "Info" = "info-circle", + "Warning" = "exclamation-triangle", + "Error" = "times-circle", + "Star" = "star", + "Heart" = "heart", + "Bell" = "bell", + "User" = "user", + "Cog" = "cog" + ), + selected = "" + ), + input_switch( + "use_header_status", + "Include Status Indicator", + value = FALSE + ), + conditionalPanel( + "input.use_header_status", + selectInput( + "header_status", + "Status", + choices = c( + "Primary" = "primary", + "Secondary" = "secondary", + "Success" = "success", + "Info" = "info", + "Warning" = "warning", + "Danger" = "danger", + "Light" = "light", + "Dark" = "dark" + ), + selected = "primary" + ) + ) + ), ) ), - # Close button - input_switch("closable", "Show Close Button", value = TRUE), - - # Custom ID - input_switch("use_custom_id", "Use Custom ID", value = FALSE), - conditionalPanel( - "input.use_custom_id", - textInput("custom_id", "Toast ID", value = "my-toast") - ), - # Action buttons div( class = "mt-3 d-grid gap-2", @@ -138,24 +149,60 @@ ui <- page_fluid( ) ), - # Advanced Features and Examples - card( - card_header("Advanced Features"), - card_body( - actionButton("show_persistent", "Show Persistent Toast", class = "mb-2 w-100"), - actionButton("hide_persistent", "Hide Persistent Toast", class = "mb-2 w-100"), - actionButton("show_long_duration", "Long Duration (10s)", class = "mb-2 w-100"), - actionButton("show_no_close", "No Close Button", class = "mb-2 w-100"), - actionButton("show_custom_header", "Custom Header with Icon & Status", class = "mb-2 w-100") - ) - ), - - card( - card_header("Interactive Toasts"), - card_body( - actionButton("show_action_buttons", "Toast with Action Buttons", class = "mb-2 w-100"), - actionButton("show_multiple", "Show Multiple Toasts", class = "mb-2 w-100"), - actionButton("show_all_positions", "Test All Positions", class = "mb-2 w-100") + layout_column_wrap( + width = 1, + + # Advanced Features and Examples + card( + card_header("Advanced Features"), + card_body( + actionButton( + "show_persistent", + "Show Persistent Toast", + class = "mb-2 w-100" + ), + actionButton( + "hide_persistent", + "Hide Persistent Toast", + class = "mb-2 w-100" + ), + actionButton( + "show_long_duration", + "Long Duration (10s)", + class = "mb-2 w-100" + ), + actionButton( + "show_no_close", + "No Close Button", + class = "mb-2 w-100" + ), + actionButton( + "show_custom_header", + "Custom Header with Icon & Status", + class = "mb-2 w-100" + ) + ) + ), + + card( + card_header("Interactive Toasts"), + card_body( + actionButton( + "show_action_buttons", + "Toast with Action Buttons", + class = "mb-2 w-100" + ), + actionButton( + "show_multiple", + "Show Multiple Toasts", + class = "mb-2 w-100" + ), + actionButton( + "show_all_positions", + "Test All Positions", + class = "mb-2 w-100" + ) + ) ) ) ) @@ -171,39 +218,21 @@ server <- function(input, output, session) { # Build header if needed header <- NULL if (input$use_header) { - if (input$use_header_icon || input$use_header_status) { - # Use toast_header() for structured header - icon <- if (input$use_header_icon && nzchar(input$header_icon)) { - icon(input$header_icon) - } else { - NULL - } - - status <- if (input$use_header_status) { - input$header_status - } else { - NULL - } - - header <- toast_header( - title = input$header_title, - icon = icon, - status = status - ) - } else { - # Simple text header - header <- input$header_title - } + header <- toast_header( + title = input$header_title, + icon = if (nzchar(input$header_icon)) icon(input$header_icon), + status = if (input$use_header_status) input$header_status + ) } # Build toast toast_obj <- toast( body = input$body, header = header, - id = if (input$use_custom_id) input$custom_id else NULL, - type = if (nzchar(input$type)) input$type else NULL, - autohide = input$autohide, - duration = input$duration, + id = if (nzchar(input$custom_id)) input$custom_id, + type = if (nzchar(input$type)) input$type, + autohide = input$duration > 0, + duration = input$duration * 1000, position = input$position, closable = input$closable ) @@ -284,7 +313,11 @@ server <- function(input, output, session) { div( class = "mt-2", actionButton("save_yes", "Save", class = "btn-sm btn-primary me-2"), - actionButton("save_no", "Don't Save", class = "btn-sm btn-secondary") + actionButton( + "save_no", + "Don't Save", + class = "btn-sm btn-secondary" + ) ) ), header = "Unsaved Changes", @@ -312,12 +345,28 @@ server <- function(input, output, session) { observeEvent(input$show_all_positions, { positions <- c( - "top-left", "top-center", "top-right", - "middle-left", "middle-center", "middle-right", - "bottom-left", "bottom-center", "bottom-right" + "top-left", + "top-center", + "top-right", + "middle-left", + "middle-center", + "middle-right", + "bottom-left", + "bottom-center", + "bottom-right" ) - types <- c("primary", "success", "info", "warning", "danger", "secondary", "light", "dark", "primary") + types <- c( + "primary", + "success", + "info", + "warning", + "danger", + "secondary", + "light", + "dark", + "primary" + ) for (i in seq_along(positions)) { pos <- positions[i] From a17b2d9b54de12fb85d66d001d168cfd802a04f1 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 16:59:43 -0400 Subject: [PATCH 14/68] fix: Only show icon once --- R/toast.R | 69 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/R/toast.R b/R/toast.R index 913f8de01..d93af5cb6 100644 --- a/R/toast.R +++ b/R/toast.R @@ -72,17 +72,35 @@ toast <- function( ) { # Validate arguments if (!is.null(type)) { - type <- rlang::arg_match(type, c( - "primary", "secondary", "success", "info", - "warning", "danger", "light", "dark" - )) + type <- rlang::arg_match( + type, + c( + "primary", + "secondary", + "success", + "info", + "warning", + "danger", + "light", + "dark" + ) + ) } - position <- rlang::arg_match(position, c( - "top-left", "top-center", "top-right", - "middle-left", "middle-center", "middle-right", - "bottom-left", "bottom-center", "bottom-right" - )) + position <- rlang::arg_match( + position, + c( + "top-left", + "top-center", + "top-right", + "middle-left", + "middle-center", + "middle-right", + "bottom-left", + "bottom-center", + "bottom-right" + ) + ) # Enforce close button for non-autohiding toasts (accessibility) if (!autohide) { @@ -320,10 +338,19 @@ hide_toast <- function(id, session = shiny::getDefaultReactiveDomain()) { toast_header <- function(title, icon = NULL, status = NULL, ...) { # Validate status if provided if (!is.null(status)) { - status <- rlang::arg_match(status, c( - "primary", "secondary", "success", "info", - "warning", "danger", "light", "dark" - )) + status <- rlang::arg_match( + status, + c( + "primary", + "secondary", + "success", + "info", + "warning", + "danger", + "light", + "dark" + ) + ) } # Build status indicator (colored dot) @@ -341,14 +368,22 @@ toast_header <- function(title, icon = NULL, status = NULL, ...) { icon, htmltools::strong( class = "me-auto", - if (!is.null(icon)) list(icon, " ", title) else title + class = if (!is.null(icon) || !is.null(status)) "ms-2", + title ) ) } # Internal function to build toast HTML structure -toast_component <- function(body, header = NULL, type = NULL, - closable = TRUE, id = NULL, class = NULL, ...) { +toast_component <- function( + body, + header = NULL, + type = NULL, + closable = TRUE, + id = NULL, + class = NULL, + ... +) { # Determine accessibility attributes aria_role <- if (!is.null(type) && type == "danger") "alert" else "status" aria_live <- if (!is.null(type) && type == "danger") "assertive" else "polite" @@ -397,7 +432,7 @@ toast_component <- function(body, header = NULL, type = NULL, htmltools::div(class = "flex-grow-1", body), htmltools::tags$button( type = "button", - class = "btn-close me-2 m-auto", + class = "btn-close", `data-bs-dismiss` = "toast", `aria-label` = "Close" ) From b9187e5fd71c0175964e4317d9c1a43fcc79b2ce Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 24 Oct 2025 17:03:28 -0400 Subject: [PATCH 15/68] fix: Change toast_header() status to timestamp text Change the status parameter in toast_header() from a colored indicator to status text (e.g., "11 mins ago", "just now") that appears as small muted text in the header, matching Bootstrap's toast examples. - Remove validation for status parameter - Render status as instead of badge - Update documentation and examples - Update tests to match new behavior --- R/toast.R | 42 ++++++++------------------------- inst/examples-shiny/toast/app.R | 30 +++++------------------ tests/testthat/test-toast.R | 18 +++++++------- 3 files changed, 24 insertions(+), 66 deletions(-) diff --git a/R/toast.R b/R/toast.R index d93af5cb6..c939e9fc1 100644 --- a/R/toast.R +++ b/R/toast.R @@ -301,9 +301,8 @@ hide_toast <- function(id, session = shiny::getDefaultReactiveDomain()) { #' @param title Header text (required). #' @param icon Optional icon element (e.g., from `bsicons::bs_icon()` or #' `fontawesome::fa()`). -#' @param status Optional status indicator. One of `NULL`, `"primary"`, -#' `"secondary"`, `"success"`, `"info"`, `"warning"`, `"danger"`, `"light"`, -#' or `"dark"`. Adds a colored dot/badge before the title. +#' @param status Optional status text (e.g., "11 mins ago", "just now") that +#' appears as small, muted text in the header. #' @param ... Additional HTML attributes passed to the header container. #' #' @return A tag object representing the toast header content. @@ -326,7 +325,7 @@ hide_toast <- function(id, session = shiny::getDefaultReactiveDomain()) { #' body = "Your settings have been saved.", #' header = toast_header( #' title = "Settings Updated", -#' status = "success" +#' status = "just now" #' ), #' type = "success" #' ) @@ -336,41 +335,20 @@ hide_toast <- function(id, session = shiny::getDefaultReactiveDomain()) { #' #' shinyApp(ui, server) toast_header <- function(title, icon = NULL, status = NULL, ...) { - # Validate status if provided - if (!is.null(status)) { - status <- rlang::arg_match( - status, - c( - "primary", - "secondary", - "success", - "info", - "warning", - "danger", - "light", - "dark" - ) - ) - } - - # Build status indicator (colored dot) - status_indicator <- if (!is.null(status)) { - htmltools::span( - class = paste0("badge rounded-circle bg-", status, " me-2"), - style = "width: 0.5rem; height: 0.5rem; padding: 0;", - `aria-hidden` = "true" - ) + # Build status text (small muted text) + status_text <- if (!is.null(status)) { + htmltools::tags$small(class = "text-muted", status) } # Combine elements htmltools::tagList( - status_indicator, - icon, + if (!is.null(icon)) icon, htmltools::strong( class = "me-auto", - class = if (!is.null(icon) || !is.null(status)) "ms-2", + if (!is.null(icon)) htmltools::tags$span(class = "ms-2"), title - ) + ), + status_text ) } diff --git a/inst/examples-shiny/toast/app.R b/inst/examples-shiny/toast/app.R index a5281feaa..77a307435 100644 --- a/inst/examples-shiny/toast/app.R +++ b/inst/examples-shiny/toast/app.R @@ -113,28 +113,10 @@ ui <- page_fillable( ), selected = "" ), - input_switch( - "use_header_status", - "Include Status Indicator", - value = FALSE - ), - conditionalPanel( - "input.use_header_status", - selectInput( - "header_status", - "Status", - choices = c( - "Primary" = "primary", - "Secondary" = "secondary", - "Success" = "success", - "Info" = "info", - "Warning" = "warning", - "Danger" = "danger", - "Light" = "light", - "Dark" = "dark" - ), - selected = "primary" - ) + textInput( + "header_status", + "Custom Status Text", + placeholder = "'Just now', '2 mins ago'" ) ), ) @@ -221,7 +203,7 @@ server <- function(input, output, session) { header <- toast_header( title = input$header_title, icon = if (nzchar(input$header_icon)) icon(input$header_icon), - status = if (input$use_header_status) input$header_status + status = if (nzchar(input$header_status)) input$header_status ) } @@ -297,7 +279,7 @@ server <- function(input, output, session) { header = toast_header( title = "Profile Updated", icon = icon("check"), - status = "success" + status = "just now" ), type = "success" ) diff --git a/tests/testthat/test-toast.R b/tests/testthat/test-toast.R index 2cb1bf74d..801f10476 100644 --- a/tests/testthat/test-toast.R +++ b/tests/testthat/test-toast.R @@ -116,14 +116,12 @@ test_that("toast_header() creates structured header", { expect_true(grepl("My Title", html1)) expect_true(grepl("me-auto", html1)) - # Header with status - h2 <- toast_header("Success", status = "success") + # Header with status text + h2 <- toast_header("Success", status = "11 mins ago") html2 <- as.character(h2) - expect_true(grepl("badge", html2)) - expect_true(grepl("bg-success", html2)) - - # Header validates status - expect_error(toast_header("Test", status = "invalid")) + expect_true(grepl("11 mins ago", html2)) + expect_true(grepl("text-muted", html2)) + expect_true(grepl(" Date: Fri, 24 Oct 2025 17:13:20 -0400 Subject: [PATCH 16/68] refactor: Consolidate autohide and duration into autohide_s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace separate autohide (logical) and duration (milliseconds) parameters with single autohide_s parameter in seconds: - autohide_s = 0, NA, or NULL disables auto-hiding - autohide_s > 0 enables auto-hiding after N seconds - Internally converts to milliseconds for Bootstrap Toast - Simplifies API: one parameter instead of two Updates: - toast() function signature and implementation - All examples in demo app - All tests to use new parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- R/toast.R | 19 ++++++++++-------- inst/examples-shiny/toast/app.R | 17 ++++++++-------- tests/testthat/test-toast.R | 35 +++++++++++++++++++++++---------- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/R/toast.R b/R/toast.R index c939e9fc1..048a548de 100644 --- a/R/toast.R +++ b/R/toast.R @@ -16,17 +16,16 @@ #' @param type Optional semantic type. One of `NULL`, `"primary"`, `"secondary"`, #' `"success"`, `"info"`, `"warning"`, `"danger"`, `"light"`, or `"dark"`. #' Applies appropriate Bootstrap background utility classes (`text-bg-*`). -#' @param autohide Logical. Whether to automatically hide the toast after -#' `duration` milliseconds. Default `TRUE`. -#' @param duration Numeric. Time in milliseconds before auto-hiding. Default -#' `5000` (5 seconds). Ignored if `autohide = FALSE`. +#' @param autohide_s Numeric. Number of seconds after which the toast should +#' automatically hide. Use `0`, `NA`, or `NULL` to disable auto-hiding (toast +#' will remain visible until manually dismissed). Default is `5` (5 seconds). #' @param position String. Where to position the toast container. One of #' `"top-left"`, `"top-center"`, `"top-right"` (default), `"middle-left"`, #' `"middle-center"`, `"middle-right"`, `"bottom-left"`, `"bottom-center"`, #' or `"bottom-right"`. #' @param closable Logical. Whether to include a close button. Default `TRUE`. -#' When `autohide = FALSE`, a close button is always included regardless of -#' this setting (for accessibility). +#' When `autohide_s` is disabled (0, NA, or NULL), a close button is always +#' included regardless of this setting (for accessibility). #' @param class Additional CSS classes for the toast. #' #' @return A `bslib_toast` object that can be passed to [show_toast()]. @@ -64,8 +63,7 @@ toast <- function( ..., id = NULL, type = NULL, - autohide = TRUE, - duration = 5000, + autohide_s = 5, position = "top-right", closable = TRUE, class = NULL @@ -102,6 +100,11 @@ toast <- function( ) ) + # Determine autohide behavior + # autohide_s of 0, NA, or NULL disables auto-hiding + autohide <- !is.null(autohide_s) && !is.na(autohide_s) && autohide_s > 0 + duration <- if (autohide) autohide_s * 1000 else 5000 # Convert to milliseconds + # Enforce close button for non-autohiding toasts (accessibility) if (!autohide) { closable <- TRUE diff --git a/inst/examples-shiny/toast/app.R b/inst/examples-shiny/toast/app.R index 77a307435..07f8d4ab3 100644 --- a/inst/examples-shiny/toast/app.R +++ b/inst/examples-shiny/toast/app.R @@ -72,8 +72,8 @@ ui <- page_fillable( # Auto-hide options sliderInput( - "duration", - "Duration (milliseconds)", + "autohide_s", + "Auto-hide (seconds, 0 = disabled)", min = 0, max = 25, value = 5, @@ -213,8 +213,7 @@ server <- function(input, output, session) { header = header, id = if (nzchar(input$custom_id)) input$custom_id, type = if (nzchar(input$type)) input$type, - autohide = input$duration > 0, - duration = input$duration * 1000, + autohide_s = input$autohide_s, position = input$position, closable = input$closable ) @@ -238,7 +237,7 @@ server <- function(input, output, session) { body = "This toast won't disappear automatically. Use the 'Hide' button to dismiss it.", header = "Persistent Toast", type = "info", - autohide = FALSE + autohide_s = 0 ) ) persistent_toast_id(id) @@ -256,7 +255,7 @@ server <- function(input, output, session) { body = "This toast will stay visible for 10 seconds.", header = "Long Duration", type = "primary", - duration = 10000 + autohide_s = 10 ) ) }) @@ -267,7 +266,7 @@ server <- function(input, output, session) { body = "This toast has no close button but will auto-hide in 3 seconds.", type = "secondary", closable = FALSE, - duration = 3000 + autohide_s = 3 ) ) }) @@ -304,7 +303,7 @@ server <- function(input, output, session) { ), header = "Unsaved Changes", type = "warning", - autohide = FALSE + autohide_s = 0 ) ) }) @@ -356,7 +355,7 @@ server <- function(input, output, session) { toast( body = paste("Toast at", pos), type = types[i], - duration = 4000, + autohide_s = 4, position = pos ) ) diff --git a/tests/testthat/test-toast.R b/tests/testthat/test-toast.R index 801f10476..d4111a886 100644 --- a/tests/testthat/test-toast.R +++ b/tests/testthat/test-toast.R @@ -23,18 +23,32 @@ test_that("toast() validates type argument", { expect_error(toast("Test", type = "invalid")) }) -test_that("toast() enforces closable for non-autohiding toasts", { - t <- toast("Test", autohide = FALSE, closable = FALSE) - expect_true(t$closable) +test_that("toast() autohide_s = 0 disables autohiding", { + t <- toast("Test", autohide_s = 0, closable = FALSE) + expect_false(t$autohide) + expect_true(t$closable) # Always true when autohide disabled }) -test_that("toast() preserves closable = TRUE when autohide = FALSE", { - t <- toast("Test", autohide = FALSE, closable = TRUE) - expect_true(t$closable) +test_that("toast() autohide_s = NA disables autohiding", { + t <- toast("Test", autohide_s = NA, closable = FALSE) + expect_false(t$autohide) + expect_true(t$closable) # Always true when autohide disabled }) -test_that("toast() allows closable = FALSE when autohide = TRUE", { - t <- toast("Test", autohide = TRUE, closable = FALSE) +test_that("toast() autohide_s = NULL disables autohiding", { + t <- toast("Test", autohide_s = NULL, closable = FALSE) + expect_false(t$autohide) + expect_true(t$closable) # Always true when autohide disabled +}) + +test_that("toast() autohide_s > 0 enables autohiding", { + t <- toast("Test", autohide_s = 10) + expect_true(t$autohide) + expect_equal(t$duration, 10000) # Converted to milliseconds +}) + +test_that("toast() allows closable = FALSE when autohiding", { + t <- toast("Test", autohide_s = 5, closable = FALSE) expect_false(t$closable) }) @@ -146,9 +160,10 @@ test_that("toast() stores additional attributes", { expect_true(grepl("extra-class", html)) }) -test_that("toast() with custom duration", { - t <- toast("Test", duration = 10000) +test_that("toast() with custom autohide_s converts to milliseconds", { + t <- toast("Test", autohide_s = 10) expect_equal(t$duration, 10000) + expect_true(t$autohide) }) test_that("toast() with all type options", { From 2304681912c2480abc86d01db9bf1f83347d818f Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 25 Oct 2025 07:41:38 -0400 Subject: [PATCH 17/68] feat: Add progress bar and hover pause for autohiding toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two interactive features for autohiding toasts: 1. Progress Bar Animation: - Animated bar at top of toast shows remaining time - Uses CSS transform animation for smooth performance - Gradient styling with primary color theme integration - Automatically added only to autohiding toasts 2. Hover Pause Behavior: - Mouse hover pauses the auto-hide timer - Progress bar animation pauses when hovered - Both resume when mouse leaves - Override Bootstrap's hide() to check hover state Implementation: - addProgressBar(): Creates and styles progress element - setupHoverPause(): Configures hover event handlers - CSS keyframe animation for progress bar - Toast positioned relative with overflow hidden 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- inst/components/scss/_toast.scss | 24 ++++++++++ srcts/src/components/toast.ts | 81 +++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/inst/components/scss/_toast.scss b/inst/components/scss/_toast.scss index f377e8e98..77d75ac91 100644 --- a/inst/components/scss/_toast.scss +++ b/inst/components/scss/_toast.scss @@ -5,6 +5,10 @@ // Use CSS variables for runtime theming --bslib-toast-shadow: var(--bs-box-shadow); box-shadow: var(--bslib-toast-shadow); + + // Position relative for progress bar + position: relative; + overflow: hidden; } // Ensure close button is visible on colored backgrounds @@ -17,3 +21,23 @@ .toast.text-bg-dark .toast-body .btn-close { filter: var(--bs-btn-close-white-filter); } + +// Progress bar animation for autohiding toasts +@keyframes bslib-toast-progress { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} + +.bslib-toast-progress-bar { + position: absolute; + top: 0; + left: 0; + height: 4px; + width: 100%; + pointer-events: none; + z-index: 1; +} diff --git a/srcts/src/components/toast.ts b/srcts/src/components/toast.ts index b4c646993..1a3df2f50 100644 --- a/srcts/src/components/toast.ts +++ b/srcts/src/components/toast.ts @@ -93,6 +93,75 @@ class ToastContainerManager { const containerManager = new ToastContainerManager(); +// Add animated progress bar to toast +function addProgressBar(toastEl: HTMLElement, duration: number): void { + const progressBar = document.createElement("div"); + progressBar.className = "bslib-toast-progress-bar"; + progressBar.style.cssText = ` + position: absolute; + top: 0; + left: 0; + height: 4px; + width: 100%; + background: linear-gradient(90deg, + rgba(var(--bs-primary-rgb, 13, 110, 253), 0.8), + rgba(var(--bs-primary-rgb, 13, 110, 253), 0.4) + ); + transform-origin: left; + animation: bslib-toast-progress ${duration}ms linear; + animation-play-state: running; + border-radius: inherit; + pointer-events: none; + `; + + // Insert as first child + toastEl.insertBefore(progressBar, toastEl.firstChild); + + // Store progress bar reference for hover pause + (toastEl as any)._bslibProgressBar = progressBar; +} + +// Setup hover pause behavior +function setupHoverPause( + toastEl: HTMLElement, + bsToast: typeof BootstrapToast.prototype +): void { + const progressBar = (toastEl as any)._bslibProgressBar as + | HTMLElement + | undefined; + + toastEl.addEventListener("mouseenter", () => { + // Pause the auto-hide timer + (toastEl as any)._bslibMouseover = true; + + // Pause progress bar animation + if (progressBar) { + progressBar.style.animationPlayState = "paused"; + } + }); + + toastEl.addEventListener("mouseleave", () => { + // Resume the auto-hide timer + (toastEl as any)._bslibMouseover = false; + + // Resume progress bar animation + if (progressBar) { + progressBar.style.animationPlayState = "running"; + } + }); + + // Override Bootstrap's auto-hide behavior to respect hover state + const originalHide = bsToast.hide.bind(bsToast); + bsToast.hide = function () { + if ((toastEl as any)._bslibMouseover) { + // If mouse is over, wait a bit and try again + setTimeout(() => bsToast.hide(), 100); + return; + } + originalHide(); + }; +} + // Show toast handler async function showToast(message: ShowToastMessage): Promise { const { html, deps, options, position } = message; @@ -124,8 +193,18 @@ async function showToast(message: ShowToastMessage): Promise { // Append to container container.appendChild(toastEl); + // Add progress bar for autohiding toasts + if (options.autohide) { + addProgressBar(toastEl, options.delay || 5000); + } + // Initialize Bootstrap toast - const bsToast = new bootstrapToast(toastEl, options); + const bsToast = new BootstrapToast(toastEl, options); + + // Add hover pause behavior for autohiding toasts + if (options.autohide) { + setupHoverPause(toastEl, bsToast); + } // Show the toast bsToast.show(); From 10c01b54b34dc71abac0ea1e2f340f0a9b713f1c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 25 Oct 2025 07:42:58 -0400 Subject: [PATCH 18/68] fix: Reverse progress bar animation to fill left-to-right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change progress bar animation from emptying (1 → 0) to filling (0 → 1) to better indicate time passing. Bar now grows from left to right as the toast approaches auto-hide time. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- inst/components/scss/_toast.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inst/components/scss/_toast.scss b/inst/components/scss/_toast.scss index 77d75ac91..8cc07be44 100644 --- a/inst/components/scss/_toast.scss +++ b/inst/components/scss/_toast.scss @@ -25,10 +25,10 @@ // Progress bar animation for autohiding toasts @keyframes bslib-toast-progress { from { - transform: scaleX(1); + transform: scaleX(0); } to { - transform: scaleX(0); + transform: scaleX(1); } } From 8c7e9034f3e07dead29ac3eb03d468e6cf6e762a Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 25 Oct 2025 07:44:28 -0400 Subject: [PATCH 19/68] fix function name --- srcts/src/components/toast.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/srcts/src/components/toast.ts b/srcts/src/components/toast.ts index 1a3df2f50..2d16e1ff2 100644 --- a/srcts/src/components/toast.ts +++ b/srcts/src/components/toast.ts @@ -124,7 +124,7 @@ function addProgressBar(toastEl: HTMLElement, duration: number): void { // Setup hover pause behavior function setupHoverPause( toastEl: HTMLElement, - bsToast: typeof BootstrapToast.prototype + bsToast: typeof bootstrapToast.prototype ): void { const progressBar = (toastEl as any)._bslibProgressBar as | HTMLElement @@ -199,7 +199,7 @@ async function showToast(message: ShowToastMessage): Promise { } // Initialize Bootstrap toast - const bsToast = new BootstrapToast(toastEl, options); + const bsToast = new bootstrapToast(toastEl, options); // Add hover pause behavior for autohiding toasts if (options.autohide) { From a2cf06df52a79ab3d5af4c0ec03d696c092cd267 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 25 Oct 2025 12:14:51 -0400 Subject: [PATCH 20/68] chore: move toast progress bar styles out of js --- inst/components/scss/{_toast.scss => toast.scss} | 9 ++++++++- srcts/src/components/toast.ts | 12 ------------ 2 files changed, 8 insertions(+), 13 deletions(-) rename inst/components/scss/{_toast.scss => toast.scss} (81%) diff --git a/inst/components/scss/_toast.scss b/inst/components/scss/toast.scss similarity index 81% rename from inst/components/scss/_toast.scss rename to inst/components/scss/toast.scss index 8cc07be44..4e6188677 100644 --- a/inst/components/scss/_toast.scss +++ b/inst/components/scss/toast.scss @@ -36,8 +36,15 @@ position: absolute; top: 0; left: 0; - height: 4px; + height: 2px; width: 100%; pointer-events: none; z-index: 1; + background: linear-gradient(90deg, + rgba(var(--bs-primary-rgb, 13, 110, 253), 0.8), + rgba(var(--bs-primary-rgb, 13, 110, 253), 0.4) + ); + transform-origin: left; + border-radius: inherit; + pointer-events: none; } diff --git a/srcts/src/components/toast.ts b/srcts/src/components/toast.ts index 2d16e1ff2..9980b4570 100644 --- a/srcts/src/components/toast.ts +++ b/srcts/src/components/toast.ts @@ -98,20 +98,8 @@ function addProgressBar(toastEl: HTMLElement, duration: number): void { const progressBar = document.createElement("div"); progressBar.className = "bslib-toast-progress-bar"; progressBar.style.cssText = ` - position: absolute; - top: 0; - left: 0; - height: 4px; - width: 100%; - background: linear-gradient(90deg, - rgba(var(--bs-primary-rgb, 13, 110, 253), 0.8), - rgba(var(--bs-primary-rgb, 13, 110, 253), 0.4) - ); - transform-origin: left; animation: bslib-toast-progress ${duration}ms linear; animation-play-state: running; - border-radius: inherit; - pointer-events: none; `; // Insert as first child From ff6935153b6083eace3d052f69245d52954bb651 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 25 Oct 2025 12:17:24 -0400 Subject: [PATCH 21/68] chore: use text color and put progress bar in header if there --- inst/components/scss/toast.scss | 5 +---- srcts/src/components/toast.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/inst/components/scss/toast.scss b/inst/components/scss/toast.scss index 4e6188677..f88c047b0 100644 --- a/inst/components/scss/toast.scss +++ b/inst/components/scss/toast.scss @@ -40,11 +40,8 @@ width: 100%; pointer-events: none; z-index: 1; - background: linear-gradient(90deg, - rgba(var(--bs-primary-rgb, 13, 110, 253), 0.8), - rgba(var(--bs-primary-rgb, 13, 110, 253), 0.4) - ); transform-origin: left; border-radius: inherit; pointer-events: none; + background-color: currentColor; } diff --git a/srcts/src/components/toast.ts b/srcts/src/components/toast.ts index 9980b4570..037fb7f46 100644 --- a/srcts/src/components/toast.ts +++ b/srcts/src/components/toast.ts @@ -102,8 +102,13 @@ function addProgressBar(toastEl: HTMLElement, duration: number): void { animation-play-state: running; `; - // Insert as first child - toastEl.insertBefore(progressBar, toastEl.firstChild); + // Insert as first child of toast header, or of toast container + const toastHeader = toastEl.querySelector(".toast-header"); + if (toastHeader) { + toastHeader.insertBefore(progressBar, toastHeader.firstChild); + } else { + toastEl.insertBefore(progressBar, toastEl.firstChild); + } // Store progress bar reference for hover pause (toastEl as any)._bslibProgressBar = progressBar; From d75696d8bdd19b1ed8f3264fcaa6f965a10dc3cd Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 25 Oct 2025 14:09:27 -0400 Subject: [PATCH 22/68] fix: Pause autohide delay while hovering over notification --- srcts/src/components/toast.ts | 92 +++++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 15 deletions(-) diff --git a/srcts/src/components/toast.ts b/srcts/src/components/toast.ts index 037fb7f46..6be28991a 100644 --- a/srcts/src/components/toast.ts +++ b/srcts/src/components/toast.ts @@ -98,7 +98,7 @@ function addProgressBar(toastEl: HTMLElement, duration: number): void { const progressBar = document.createElement("div"); progressBar.className = "bslib-toast-progress-bar"; progressBar.style.cssText = ` - animation: bslib-toast-progress ${duration}ms linear; + animation: bslib-toast-progress ${duration}ms linear forwards; animation-play-state: running; `; @@ -110,8 +110,12 @@ function addProgressBar(toastEl: HTMLElement, duration: number): void { toastEl.insertBefore(progressBar, toastEl.firstChild); } - // Store progress bar reference for hover pause + // Store progress bar reference and timing info for hover pause (toastEl as any)._bslibProgressBar = progressBar; + (toastEl as any)._bslibStartTime = Date.now(); + (toastEl as any)._bslibDuration = duration; + (toastEl as any)._bslibRemainingTime = duration; + (toastEl as any)._bslibElapsedBeforePause = 0; // Time elapsed before pause } // Setup hover pause behavior @@ -123,10 +127,44 @@ function setupHoverPause( | HTMLElement | undefined; + // Create a custom timeout to replace Bootstrap's internal one + let hideTimeoutId: number | null = null; + + function startHideTimeout(delay: number): void { + if (hideTimeoutId !== null) { + clearTimeout(hideTimeoutId); + } + hideTimeoutId = window.setTimeout(() => { + // Only call the original hide if not in mouseover state + if (!(toastEl as any)._bslibMouseover) { + originalHide(); + } + }, delay); + } + + // Start the initial hide timeout + if ((toastEl as any)._bslibDuration && (toastEl as any)._bslibRemainingTime) { + startHideTimeout((toastEl as any)._bslibRemainingTime); + } + toastEl.addEventListener("mouseenter", () => { + // Calculate elapsed time before pause and remaining time + const pauseTime = Date.now(); + const timeElapsedSinceStart = pauseTime - (toastEl as any)._bslibStartTime; + (toastEl as any)._bslibElapsedBeforePause = timeElapsedSinceStart; + (toastEl as any)._bslibRemainingTime = Math.max( + 0, + (toastEl as any)._bslibDuration - timeElapsedSinceStart + ); + // Pause the auto-hide timer (toastEl as any)._bslibMouseover = true; + // Clear any existing timeout + if (hideTimeoutId !== null) { + clearTimeout(hideTimeoutId); + } + // Pause progress bar animation if (progressBar) { progressBar.style.animationPlayState = "paused"; @@ -134,23 +172,38 @@ function setupHoverPause( }); toastEl.addEventListener("mouseleave", () => { - // Resume the auto-hide timer + // Resume the auto-hide timer with remaining time (toastEl as any)._bslibMouseover = false; - // Resume progress bar animation - if (progressBar) { - progressBar.style.animationPlayState = "running"; + // Update start time to now, accounting for time already elapsed + (toastEl as any)._bslibStartTime = + Date.now() - (toastEl as any)._bslibElapsedBeforePause; + + // If there's still time remaining, restart the timer + if ((toastEl as any)._bslibRemainingTime > 0) { + startHideTimeout((toastEl as any)._bslibRemainingTime); + + // Simply resume the animation without restarting it + if (progressBar) { + progressBar.style.animationPlayState = "running"; + } } }); - // Override Bootstrap's auto-hide behavior to respect hover state + // Override Bootstrap's auto-hide behavior to respect our custom timing const originalHide = bsToast.hide.bind(bsToast); bsToast.hide = function () { if ((toastEl as any)._bslibMouseover) { - // If mouse is over, wait a bit and try again - setTimeout(() => bsToast.hide(), 100); + // If mouse is over, don't hide yet return; } + + // Clear our custom timeout since we're hiding now + if (hideTimeoutId !== null) { + clearTimeout(hideTimeoutId); + hideTimeoutId = null; + } + originalHide(); }; } @@ -186,17 +239,26 @@ async function showToast(message: ShowToastMessage): Promise { // Append to container container.appendChild(toastEl); + // Initialize Bootstrap toast + let bsToast: typeof bootstrapToast.prototype; + // Add progress bar for autohiding toasts if (options.autohide) { - addProgressBar(toastEl, options.delay || 5000); - } + // Get delay with fallback to default + const delay = options.delay || 5000; + addProgressBar(toastEl, delay); - // Initialize Bootstrap toast - const bsToast = new bootstrapToast(toastEl, options); + // Create a modified options object to prevent Bootstrap's autohide from interfering + const modifiedOptions = { ...options, autohide: false }; - // Add hover pause behavior for autohiding toasts - if (options.autohide) { + // Initialize Bootstrap toast with modified options + bsToast = new bootstrapToast(toastEl, modifiedOptions); + + // Add hover pause behavior for autohiding toasts setupHoverPause(toastEl, bsToast); + } else { + // Initialize Bootstrap toast with original options + bsToast = new bootstrapToast(toastEl, options); } // Show the toast From 0e335abb963c381a2b7cbf3370ac75d01bb614aa Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Sat, 25 Oct 2025 14:10:36 -0400 Subject: [PATCH 23/68] chore: yarn build --- inst/components/dist/components.js | 219 +++++++++++++++++++++ inst/components/dist/components.js.map | 6 +- inst/components/dist/components.min.js | 9 +- inst/components/dist/components.min.js.map | 8 +- 4 files changed, 232 insertions(+), 10 deletions(-) diff --git a/inst/components/dist/components.js b/inst/components/dist/components.js index 03b007023..3da597197 100644 --- a/inst/components/dist/components.js +++ b/inst/components/dist/components.js @@ -1,7 +1,26 @@ /*! bslib 0.9.0.9000 | (c) 2012-2025 RStudio, PBC. | License: MIT + file LICENSE */ "use strict"; (() => { + var __defProp = Object.defineProperty; + var __defProps = Object.defineProperties; + var __getOwnPropDescs = Object.getOwnPropertyDescriptors; var __getOwnPropNames = Object.getOwnPropertyNames; + var __getOwnPropSymbols = Object.getOwnPropertySymbols; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __propIsEnum = Object.prototype.propertyIsEnumerable; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) + if (__hasOwnProp.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(b)) { + if (__propIsEnum.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + } + return a; + }; + var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; @@ -97,6 +116,18 @@ } }); } + function shinyRenderDependencies(deps) { + return __async(this, null, function* () { + if (!Shiny) { + throw new Error("This function must be called in a Shiny app."); + } + if (Shiny.renderDependenciesAsync) { + return yield Shiny.renderDependenciesAsync(deps); + } else { + return Shiny.renderDependencies(deps); + } + }); + } function updateLabel(labelContent, labelNode) { return __async(this, null, function* () { if (typeof labelContent === "undefined") @@ -1780,6 +1811,193 @@ } }); + // srcts/src/components/toast.ts + function addProgressBar(toastEl, duration) { + const progressBar = document.createElement("div"); + progressBar.className = "bslib-toast-progress-bar"; + progressBar.style.cssText = ` + animation: bslib-toast-progress ${duration}ms linear forwards; + animation-play-state: running; + `; + const toastHeader = toastEl.querySelector(".toast-header"); + if (toastHeader) { + toastHeader.insertBefore(progressBar, toastHeader.firstChild); + } else { + toastEl.insertBefore(progressBar, toastEl.firstChild); + } + toastEl._bslibProgressBar = progressBar; + toastEl._bslibStartTime = Date.now(); + toastEl._bslibDuration = duration; + toastEl._bslibRemainingTime = duration; + toastEl._bslibElapsedBeforePause = 0; + } + function setupHoverPause(toastEl, bsToast) { + const progressBar = toastEl._bslibProgressBar; + let hideTimeoutId = null; + function startHideTimeout(delay) { + if (hideTimeoutId !== null) { + clearTimeout(hideTimeoutId); + } + hideTimeoutId = window.setTimeout(() => { + if (!toastEl._bslibMouseover) { + originalHide(); + } + }, delay); + } + if (toastEl._bslibDuration && toastEl._bslibRemainingTime) { + startHideTimeout(toastEl._bslibRemainingTime); + } + toastEl.addEventListener("mouseenter", () => { + const pauseTime = Date.now(); + const timeElapsedSinceStart = pauseTime - toastEl._bslibStartTime; + toastEl._bslibElapsedBeforePause = timeElapsedSinceStart; + toastEl._bslibRemainingTime = Math.max( + 0, + toastEl._bslibDuration - timeElapsedSinceStart + ); + toastEl._bslibMouseover = true; + if (hideTimeoutId !== null) { + clearTimeout(hideTimeoutId); + } + if (progressBar) { + progressBar.style.animationPlayState = "paused"; + } + }); + toastEl.addEventListener("mouseleave", () => { + toastEl._bslibMouseover = false; + toastEl._bslibStartTime = Date.now() - toastEl._bslibElapsedBeforePause; + if (toastEl._bslibRemainingTime > 0) { + startHideTimeout(toastEl._bslibRemainingTime); + if (progressBar) { + progressBar.style.animationPlayState = "running"; + } + } + }); + const originalHide = bsToast.hide.bind(bsToast); + bsToast.hide = function() { + if (toastEl._bslibMouseover) { + return; + } + if (hideTimeoutId !== null) { + clearTimeout(hideTimeoutId); + hideTimeoutId = null; + } + originalHide(); + }; + } + function showToast(message) { + return __async(this, null, function* () { + const { html, deps, options, position } = message; + if (!window.bootstrap || !window.bootstrap.Toast) { + console.warn( + "Toast requires Bootstrap 5 to be available on window.bootstrap.Toast" + ); + return; + } + yield shinyRenderDependencies(deps); + const container = containerManager.getOrCreateContainer(position); + const temp = document.createElement("div"); + temp.innerHTML = html; + const toastEl = temp.firstElementChild; + if (!toastEl) { + console.error("Failed to create toast element"); + return; + } + container.appendChild(toastEl); + let bsToast; + if (options.autohide) { + const delay = options.delay || 5e3; + addProgressBar(toastEl, delay); + const modifiedOptions = __spreadProps(__spreadValues({}, options), { autohide: false }); + bsToast = new bootstrapToast(toastEl, modifiedOptions); + setupHoverPause(toastEl, bsToast); + } else { + bsToast = new bootstrapToast(toastEl, options); + } + bsToast.show(); + toastEl.addEventListener("hidden.bs.toast", () => { + toastEl.remove(); + if (container.children.length === 0) { + container.remove(); + } + }); + }); + } + function hideToast(message) { + const { id } = message; + const toastEl = document.getElementById(id); + if (!toastEl) { + console.warn(`Toast with id "${id}" not found`); + return; + } + const bsToast = bootstrapToast.getInstance(toastEl); + if (bsToast) { + bsToast.hide(); + } + } + var bootstrapToast, ToastContainerManager, containerManager; + var init_toast = __esm({ + "srcts/src/components/toast.ts"() { + "use strict"; + init_shinyAddCustomMessageHandlers(); + init_utils(); + bootstrapToast = window.bootstrap ? window.bootstrap.Toast : class { + }; + ToastContainerManager = class { + constructor() { + this.containers = /* @__PURE__ */ new Map(); + } + getOrCreateContainer(position) { + let container = this.containers.get(position); + if (!container || !document.body.contains(container)) { + container = this._createContainer(position); + this.containers.set(position, container); + } + return container; + } + _createContainer(position) { + const container = document.createElement("div"); + container.className = "toast-container position-fixed p-1 p-md-2"; + container.setAttribute("data-bslib-toast-container", position); + const positionClasses = this._getPositionClasses(position); + container.classList.add(...positionClasses); + document.body.appendChild(container); + return container; + } + _getPositionClasses(position) { + const classMap = { + // eslint-disable-next-line @typescript-eslint/naming-convention + "top-left": ["top-0", "start-0"], + // eslint-disable-next-line @typescript-eslint/naming-convention + "top-center": ["top-0", "start-50", "translate-middle-x"], + // eslint-disable-next-line @typescript-eslint/naming-convention + "top-right": ["top-0", "end-0"], + // eslint-disable-next-line @typescript-eslint/naming-convention + "middle-left": ["top-50", "start-0", "translate-middle-y"], + // eslint-disable-next-line @typescript-eslint/naming-convention + "middle-center": ["top-50", "start-50", "translate-middle"], + // eslint-disable-next-line @typescript-eslint/naming-convention + "middle-right": ["top-50", "end-0", "translate-middle-y"], + // eslint-disable-next-line @typescript-eslint/naming-convention + "bottom-left": ["bottom-0", "start-0"], + // eslint-disable-next-line @typescript-eslint/naming-convention + "bottom-center": ["bottom-0", "start-50", "translate-middle-x"], + // eslint-disable-next-line @typescript-eslint/naming-convention + "bottom-right": ["bottom-0", "end-0"] + }; + return classMap[position]; + } + }; + containerManager = new ToastContainerManager(); + shinyAddCustomMessageHandlers({ + // eslint-disable-next-line @typescript-eslint/naming-convention + "bslib.show-toast": showToast, + // eslint-disable-next-line @typescript-eslint/naming-convention + "bslib.hide-toast": hideToast + }); + } + }); + // srcts/src/components/index.ts var require_components = __commonJS({ "srcts/src/components/index.ts"(exports) { @@ -1788,6 +2006,7 @@ init_sidebar(); init_taskButton(); init_submitTextArea(); + init_toast(); init_utils(); init_shinyAddCustomMessageHandlers(); var bslibMessageHandlers = { diff --git a/inst/components/dist/components.js.map b/inst/components/dist/components.js.map index c83c7fc36..5f29e7008 100644 --- a/inst/components/dist/components.js.map +++ b/inst/components/dist/components.js.map @@ -1,7 +1,7 @@ { "version": 3, - "sources": ["../../../srcts/src/components/_utils.ts", "../../../srcts/src/components/accordion.ts", "../../../srcts/src/components/_shinyResizeObserver.ts", "../../../srcts/src/components/_shinyRemovedObserver.ts", "../../../srcts/src/components/card.ts", "../../../srcts/src/components/sidebar.ts", "../../../srcts/src/components/taskButton.ts", "../../../srcts/src/components/submitTextArea.ts", "../../../srcts/src/components/_shinyAddCustomMessageHandlers.ts", "../../../srcts/src/components/index.ts"], - "sourcesContent": ["import type { HtmlDep } from \"rstudio-shiny/srcts/types/src/shiny/render\";\n\nimport type { InputBinding as InputBindingType } from \"rstudio-shiny/srcts/types/src/bindings/input\";\n\nimport type { ShinyClass } from \"rstudio-shiny/srcts/types/src\";\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst Shiny: ShinyClass | undefined = window.Shiny;\n\n// Exclude undefined from T\ntype NotUndefined = T extends undefined ? never : T;\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst InputBinding = (\n Shiny ? Shiny.InputBinding : class {}\n) as typeof InputBindingType;\n\nfunction registerBinding(\n inputBindingClass: new () => InputBindingType,\n name: string\n): void {\n if (Shiny) {\n Shiny.inputBindings.register(new inputBindingClass(), \"bslib.\" + name);\n }\n}\n\nfunction registerBslibGlobal(name: string, value: object): void {\n (window as any).bslib = (window as any).bslib || {};\n if (!(window as any).bslib[name]) {\n (window as any).bslib[name] = value;\n } else {\n console.error(\n `[bslib] Global window.bslib.${name} was already defined, using previous definition.`\n );\n }\n}\n\ntype ShinyClientMessage = {\n message: string;\n headline?: string;\n status?: \"error\" | \"info\" | \"warning\";\n};\n\nfunction showShinyClientMessage({\n headline = \"\",\n message,\n status = \"warning\",\n}: ShinyClientMessage): void {\n document.dispatchEvent(\n new CustomEvent(\"shiny:client-message\", {\n detail: { headline: headline, message: message, status: status },\n })\n );\n}\n\n// Return true if the key exists on the object and the value is not undefined.\n//\n// This method is mainly used in input bindings' `receiveMessage` method.\n// Since we know that the values are sent by Shiny via `{jsonlite}`,\n// then we know that there are no `undefined` values. `null` is possible, but not `undefined`.\nfunction hasDefinedProperty<\n Prop extends keyof X,\n X extends { [key: string]: any }\n>(\n obj: X,\n prop: Prop\n): obj is X & { [key in NonNullable]: NotUndefined } {\n return (\n Object.prototype.hasOwnProperty.call(obj, prop) && obj[prop] !== undefined\n );\n}\n\n// TODO: Shiny should trigger resize events when the output\n// https://github.com/rstudio/shiny/pull/3682\nfunction doWindowResizeOnElementResize(el: HTMLElement): void {\n if ($(el).data(\"window-resize-observer\")) {\n return;\n }\n const resizeEvent = new Event(\"resize\");\n const ro = new ResizeObserver(() => {\n window.dispatchEvent(resizeEvent);\n });\n ro.observe(el);\n $(el).data(\"window-resize-observer\", ro);\n}\n\nfunction getAllFocusableChildren(el: HTMLElement): HTMLElement[] {\n // Cross-referenced with https://allyjs.io/data-tables/focusable.html\n const base = [\n \"a[href]\",\n \"area[href]\",\n \"button\",\n \"details summary\",\n \"input\",\n \"iframe\",\n \"select\",\n \"textarea\",\n '[contentEditable=\"\"]',\n '[contentEditable=\"true\"]',\n '[contentEditable=\"TRUE\"]',\n \"[tabindex]\",\n ];\n const modifiers = [':not([tabindex=\"-1\"])', \":not([disabled])\"];\n const selectors = base.map((b) => b + modifiers.join(\"\"));\n const focusable = el.querySelectorAll(selectors.join(\", \"));\n return Array.from(focusable) as HTMLElement[];\n}\n\nasync function shinyRenderContent(\n ...args: Parameters\n): Promise {\n if (!Shiny) {\n throw new Error(\"This function must be called in a Shiny app.\");\n }\n if (Shiny.renderContentAsync) {\n return await Shiny.renderContentAsync.apply(null, args);\n } else {\n return await Shiny.renderContent.apply(null, args);\n }\n}\n\n// Copied from shiny utils\nasync function updateLabel(\n labelContent: string | { html: string; deps: HtmlDep[] } | undefined,\n labelNode: JQuery\n): Promise {\n // Only update if label was specified in the update method\n if (typeof labelContent === \"undefined\") return;\n if (labelNode.length !== 1) {\n throw new Error(\"labelNode must be of length 1\");\n }\n\n if (typeof labelContent === \"string\") {\n labelContent = {\n html: labelContent,\n deps: [],\n };\n }\n\n if (labelContent.html === \"\") {\n labelNode.addClass(\"shiny-label-null\");\n } else {\n await shinyRenderContent(labelNode, labelContent);\n labelNode.removeClass(\"shiny-label-null\");\n }\n}\n\nexport {\n InputBinding,\n registerBinding,\n registerBslibGlobal,\n hasDefinedProperty,\n doWindowResizeOnElementResize,\n getAllFocusableChildren,\n shinyRenderContent,\n showShinyClientMessage,\n Shiny,\n updateLabel,\n};\nexport type { HtmlDep, ShinyClientMessage };\n", "import type { HtmlDep } from \"./_utils\";\nimport {\n InputBinding,\n registerBinding,\n hasDefinedProperty,\n shinyRenderContent,\n} from \"./_utils\";\n\ntype AccordionItem = {\n item: HTMLElement;\n value: string;\n isOpen: () => boolean;\n show: () => void;\n hide: () => void;\n};\n\ntype HTMLContent = {\n html: string;\n deps?: HtmlDep[];\n};\n\ntype SetMessage = {\n method: \"set\";\n values: string[];\n};\n\ntype OpenMessage = {\n method: \"open\";\n values: string[] | true;\n};\n\ntype CloseMessage = {\n method: \"close\";\n values: string[] | true;\n};\n\ntype InsertMessage = {\n method: \"insert\";\n panel: HTMLContent;\n target: string;\n position: \"after\" | \"before\";\n};\n\ntype RemoveMessage = {\n method: \"remove\";\n target: string[];\n};\n\ntype UpdateMessage = {\n method: \"update\";\n target: string;\n value: string;\n body: HTMLContent;\n title: HTMLContent;\n icon: HTMLContent;\n};\n\ntype MessageData =\n | CloseMessage\n | InsertMessage\n | OpenMessage\n | RemoveMessage\n | SetMessage\n | UpdateMessage;\n\nclass AccordionInputBinding extends InputBinding {\n find(scope: HTMLElement) {\n return $(scope).find(\".accordion.bslib-accordion-input\");\n }\n\n getValue(el: HTMLElement): string[] | null {\n const items = this._getItemInfo(el);\n const selected = items.filter((x) => x.isOpen()).map((x) => x.value);\n return selected.length === 0 ? null : selected;\n }\n\n subscribe(el: HTMLElement, callback: (x: boolean) => void) {\n $(el).on(\n \"shown.bs.collapse.accordionInputBinding hidden.bs.collapse.accordionInputBinding\",\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n function (event) {\n callback(true);\n }\n );\n }\n\n unsubscribe(el: HTMLElement) {\n $(el).off(\".accordionInputBinding\");\n }\n\n async receiveMessage(el: HTMLElement, data: MessageData) {\n const method = data.method;\n if (method === \"set\") {\n this._setItems(el, data);\n } else if (method === \"open\") {\n this._openItems(el, data);\n } else if (method === \"close\") {\n this._closeItems(el, data);\n } else if (method === \"remove\") {\n this._removeItem(el, data);\n } else if (method === \"insert\") {\n await this._insertItem(el, data);\n } else if (method === \"update\") {\n await this._updateItem(el, data);\n } else {\n throw new Error(`Method not yet implemented: ${method}`);\n }\n }\n\n protected _setItems(el: HTMLElement, data: SetMessage) {\n const items = this._getItemInfo(el);\n const vals = this._getValues(el, items, data.values);\n items.forEach((x) => {\n vals.indexOf(x.value) > -1 ? x.show() : x.hide();\n });\n }\n\n protected _openItems(el: HTMLElement, data: OpenMessage) {\n const items = this._getItemInfo(el);\n const vals = this._getValues(el, items, data.values);\n items.forEach((x) => {\n if (vals.indexOf(x.value) > -1) x.show();\n });\n }\n\n protected _closeItems(el: HTMLElement, data: CloseMessage) {\n const items = this._getItemInfo(el);\n const vals = this._getValues(el, items, data.values);\n items.forEach((x) => {\n if (vals.indexOf(x.value) > -1) x.hide();\n });\n }\n\n protected async _insertItem(el: HTMLElement, data: InsertMessage) {\n let targetItem = this._findItem(el, data.target);\n\n // If no target was specified, or the target was not found, then default\n // to the first or last item, depending on the position\n if (!targetItem) {\n targetItem = (\n data.position === \"before\" ? el.firstElementChild : el.lastElementChild\n ) as HTMLElement;\n }\n\n const panel = data.panel;\n\n // If there is still no targetItem, then there are no items in the accordion\n if (targetItem) {\n await shinyRenderContent(\n targetItem,\n panel,\n data.position === \"before\" ? \"beforeBegin\" : \"afterEnd\"\n );\n } else {\n await shinyRenderContent(el, panel);\n }\n\n // Need to add a reference to the parent id that makes autoclose to work\n if (this._isAutoClosing(el)) {\n const val = $(panel.html).attr(\"data-value\");\n $(el)\n .find(`[data-value=\"${val}\"] .accordion-collapse`)\n .attr(\"data-bs-parent\", \"#\" + el.id);\n }\n }\n\n protected _removeItem(el: HTMLElement, data: RemoveMessage) {\n const targetItems = this._getItemInfo(el).filter(\n (x) => data.target.indexOf(x.value) > -1\n );\n\n const unbindAll = window.Shiny?.unbindAll;\n\n targetItems.forEach((x) => {\n if (unbindAll) unbindAll(x.item);\n x.item.remove();\n });\n }\n\n protected async _updateItem(el: HTMLElement, data: UpdateMessage) {\n const target = this._findItem(el, data.target);\n\n if (!target) {\n throw new Error(\n `Unable to find an accordion_panel() with a value of ${data.target}`\n );\n }\n\n if (hasDefinedProperty(data, \"value\")) {\n target.dataset.value = data.value;\n }\n\n if (hasDefinedProperty(data, \"body\")) {\n const body = target.querySelector(\".accordion-body\") as HTMLElement; // always exists\n await shinyRenderContent(body, data.body);\n }\n\n const header = target.querySelector(\".accordion-header\") as HTMLElement; // always exists\n\n if (hasDefinedProperty(data, \"title\")) {\n const title = header.querySelector(\".accordion-title\") as HTMLElement; // always exists\n await shinyRenderContent(title, data.title);\n }\n\n if (hasDefinedProperty(data, \"icon\")) {\n const icon = header.querySelector(\n \".accordion-button > .accordion-icon\"\n ) as HTMLElement; // always exists\n await shinyRenderContent(icon, data.icon);\n }\n }\n\n protected _getItemInfo(el: HTMLElement): AccordionItem[] {\n const items = Array.from(\n el.querySelectorAll(\":scope > .accordion-item\")\n ) as HTMLElement[];\n return items.map((x) => this._getSingleItemInfo(x));\n }\n\n protected _getSingleItemInfo(x: HTMLElement): AccordionItem {\n const collapse = x.querySelector(\".accordion-collapse\") as HTMLElement;\n const isOpen = () => $(collapse).hasClass(\"show\");\n return {\n item: x,\n value: x.dataset.value as string,\n isOpen: isOpen,\n show: () => {\n if (!isOpen()) $(collapse).collapse(\"show\");\n },\n hide: () => {\n if (isOpen()) $(collapse).collapse(\"hide\");\n },\n };\n }\n\n protected _getValues(\n el: HTMLElement,\n items: AccordionItem[],\n values: string[] | true\n ): string[] {\n let vals = values !== true ? values : items.map((x) => x.value);\n const autoclose = this._isAutoClosing(el);\n if (autoclose) {\n vals = vals.slice(vals.length - 1, vals.length);\n }\n return vals;\n }\n\n protected _findItem(el: HTMLElement, value: string): HTMLElement | null {\n return el.querySelector(`[data-value=\"${value}\"]`);\n }\n\n protected _isAutoClosing(el: HTMLElement): boolean {\n return el.classList.contains(\"autoclose\");\n }\n}\n\nregisterBinding(AccordionInputBinding, \"accordion\");\n", "/**\n * A resize observer that ensures Shiny outputs resize during or just after\n * their parent container size changes. Useful, in particular, for sidebar\n * transitions or for full-screen card transitions.\n *\n * @class ShinyResizeObserver\n * @typedef {ShinyResizeObserver}\n */\nclass ShinyResizeObserver {\n /**\n * The actual ResizeObserver instance.\n * @private\n * @type {ResizeObserver}\n */\n private resizeObserver: ResizeObserver;\n /**\n * An array of elements that are currently being watched by the Resize\n * Observer.\n *\n * @details\n * We don't currently have lifecycle hooks that allow us to unobserve elements\n * when they are removed from the DOM. As a result, we need to manually check\n * that the elements we're watching still exist in the DOM. This array keeps\n * track of the elements we're watching so that we can check them later.\n * @private\n * @type {HTMLElement[]}\n */\n private resizeObserverEntries: HTMLElement[];\n\n /**\n * Watch containers for size changes and ensure that Shiny outputs and\n * htmlwidgets within resize appropriately.\n *\n * @details\n * The ShinyResizeObserver is used to watch the containers, such as Sidebars\n * and Cards for size changes, in particular when the sidebar state is toggled\n * or the card body is expanded full screen. It performs two primary tasks:\n *\n * 1. Dispatches a `resize` event on the window object. This is necessary to\n * ensure that Shiny outputs resize appropriately. In general, the window\n * resizing is throttled and the output update occurs when the transition\n * is complete.\n * 2. If an output with a resize method on the output binding is detected, we\n * directly call the `.onResize()` method of the binding. This ensures that\n * htmlwidgets transition smoothly. In static mode, htmlwidgets does this\n * already.\n *\n * @note\n * This resize observer also handles race conditions in some complex\n * fill-based layouts with multiple outputs (e.g., plotly), where shiny\n * initializes with the correct sizing, but in-between the 1st and last\n * renderValue(), the size of the output containers can change, meaning every\n * output but the 1st gets initialized with the wrong size during their\n * renderValue(). Then, after the render phase, shiny won't know to trigger a\n * resize since all the widgets will return to their original size (and thus,\n * Shiny thinks there isn't any resizing to do). The resize observer works\n * around this by ensuring that the output is resized whenever its container\n * size changes.\n * @constructor\n */\n constructor() {\n this.resizeObserverEntries = [];\n this.resizeObserver = new ResizeObserver((entries) => {\n const resizeEvent = new Event(\"resize\");\n window.dispatchEvent(resizeEvent);\n\n // the rest of this callback is only relevant in Shiny apps\n if (!window.Shiny) return;\n\n const resized = [] as HTMLElement[];\n\n for (const entry of entries) {\n if (!(entry.target instanceof HTMLElement)) continue;\n if (!entry.target.querySelector(\".shiny-bound-output\")) continue;\n\n entry.target\n .querySelectorAll(\".shiny-bound-output\")\n .forEach((el) => {\n if (resized.includes(el)) return;\n\n const { binding, onResize } = $(el).data(\"shinyOutputBinding\");\n if (!binding || !binding.resize) return;\n\n // if this output is owned by another observer, skip it\n const owner = (el as any).shinyResizeObserver;\n if (owner && owner !== this) return;\n // mark this output as owned by this shinyResizeObserver instance\n if (!owner) (el as any).shinyResizeObserver = this;\n\n // trigger immediate resizing of outputs with a resize method\n onResize(el);\n // only once per output and resize event\n resized.push(el);\n\n // set plot images to 100% width temporarily during the transition\n if (!el.classList.contains(\"shiny-plot-output\")) return;\n const img = el.querySelector(\n 'img:not([width=\"100%\"])'\n );\n if (img) img.setAttribute(\"width\", \"100%\");\n });\n }\n });\n }\n\n /**\n * Observe an element for size changes.\n * @param {HTMLElement} el - The element to observe.\n */\n observe(el: HTMLElement): void {\n this.resizeObserver.observe(el);\n this.resizeObserverEntries.push(el);\n }\n\n /**\n * Stop observing an element for size changes.\n * @param {HTMLElement} el - The element to stop observing.\n */\n unobserve(el: HTMLElement): void {\n const idxEl = this.resizeObserverEntries.indexOf(el);\n if (idxEl < 0) return;\n\n this.resizeObserver.unobserve(el);\n this.resizeObserverEntries.splice(idxEl, 1);\n }\n\n /**\n * This method checks that we're not continuing to watch elements that no\n * longer exist in the DOM. If any are found, we stop observing them and\n * remove them from our array of observed elements.\n *\n * @private\n * @static\n */\n flush(): void {\n this.resizeObserverEntries.forEach((el) => {\n if (!document.body.contains(el)) this.unobserve(el);\n });\n }\n}\n\nexport { ShinyResizeObserver };\n", "type Callback = (el: T) => void;\n\n/**\n * Watch for the removal of specific elements from regions of the page.\n */\nexport class ShinyRemovedObserver {\n private observer: MutationObserver;\n private watching: Set;\n\n /**\n * Creates a new instance of the `ShinyRemovedObserver` class to watch for the\n * removal of specific elements from part of the DOM.\n *\n * @param selector A CSS selector to identify elements to watch for removal.\n * @param callback The function to be called on a matching element when it\n * is removed.\n */\n constructor(selector: string, callback: Callback) {\n this.watching = new Set();\n this.observer = new MutationObserver((mutations) => {\n const found = new Set();\n for (const { type, removedNodes } of mutations) {\n if (type !== \"childList\") continue;\n if (removedNodes.length === 0) continue;\n\n for (const node of removedNodes) {\n if (!(node instanceof HTMLElement)) continue;\n if (node.matches(selector)) {\n found.add(node);\n }\n if (node.querySelector(selector)) {\n node\n .querySelectorAll(selector)\n .forEach((el) => found.add(el));\n }\n }\n }\n if (found.size === 0) return;\n for (const el of found) {\n try {\n callback(el);\n } catch (e) {\n console.error(e);\n }\n }\n });\n }\n\n /**\n * Starts observing the specified element for removal of its children. If the\n * element is already being observed, no change is made to the mutation\n * observer.\n * @param el The element to observe.\n */\n observe(el: HTMLElement): void {\n const changed = this._flush();\n if (this.watching.has(el)) {\n if (!changed) return;\n } else {\n this.watching.add(el);\n }\n\n if (changed) {\n this._restartObserver();\n } else {\n this.observer.observe(el, { childList: true, subtree: true });\n }\n }\n\n /**\n * Stops observing the specified element for removal.\n * @param el The element to unobserve.\n */\n unobserve(el: HTMLElement): void {\n if (!this.watching.has(el)) return;\n // MutationObserver doesn't have an \"unobserve\" method, so we have to\n // disconnect and re-observe all elements that are still being watched.\n this.watching.delete(el);\n this._flush();\n this._restartObserver();\n }\n\n /**\n * Restarts the mutation observer, observing all elements in the `watching`\n * and implicitly unobserving any elements that are no longer in the\n * watchlist.\n * @private\n */\n private _restartObserver(): void {\n this.observer.disconnect();\n for (const el of this.watching) {\n this.observer.observe(el, { childList: true, subtree: true });\n }\n }\n\n /**\n * Flushes the set of watched elements, removing any elements that are no\n * longer in the DOM, but it does not modify the mutation observer.\n * @private\n * @returns A boolean indicating whether the watched elements have changed.\n */\n private _flush(): boolean {\n let watchedChanged = false;\n const watched = Array.from(this.watching);\n for (const el of watched) {\n if (document.body.contains(el)) continue;\n this.watching.delete(el);\n watchedChanged = true;\n }\n return watchedChanged;\n }\n}\n", "import { getAllFocusableChildren, registerBslibGlobal, Shiny } from \"./_utils\";\nimport { ShinyResizeObserver } from \"./_shinyResizeObserver\";\nimport { ShinyRemovedObserver } from \"./_shinyRemovedObserver\";\n\n/**\n * The overlay element that is placed behind the card when expanded full screen.\n *\n * @interface CardFullScreenOverlay\n * @typedef {CardFullScreenOverlay}\n */\ninterface CardFullScreenOverlay {\n /**\n * The full screen overlay container.\n * @type {HTMLDivElement}\n */\n container: HTMLDivElement;\n /**\n * The anchor element used to close the full screen overlay.\n * @type {HTMLAnchorElement}\n */\n anchor: HTMLAnchorElement;\n}\n\n/**\n * The bslib card component class.\n *\n * @class Card\n * @typedef {Card}\n */\nclass Card {\n /**\n * The card container element.\n * @private\n * @type {HTMLElement}\n */\n private card: HTMLElement;\n /**\n * The card's full screen overlay element. We create this element once and add\n * and remove it from the DOM as needed (this simplifies focus management\n * while in full screen mode).\n * @private\n * @type {CardFullScreenOverlay}\n */\n private overlay: CardFullScreenOverlay;\n\n /**\n * Key bslib-specific classes and attributes used by the card component.\n * @private\n * @static\n */\n private static attr = {\n // eslint-disable-next-line @typescript-eslint/naming-convention\n ATTR_INIT: \"data-bslib-card-init\",\n // eslint-disable-next-line @typescript-eslint/naming-convention\n CLASS_CARD: \"bslib-card\",\n // eslint-disable-next-line @typescript-eslint/naming-convention\n ATTR_FULL_SCREEN: \"data-full-screen\",\n // eslint-disable-next-line @typescript-eslint/naming-convention\n CLASS_HAS_FULL_SCREEN: \"bslib-has-full-screen\",\n // eslint-disable-next-line @typescript-eslint/naming-convention\n CLASS_FULL_SCREEN_ENTER: \"bslib-full-screen-enter\",\n // eslint-disable-next-line @typescript-eslint/naming-convention\n CLASS_FULL_SCREEN_EXIT: \"bslib-full-screen-exit\",\n // eslint-disable-next-line @typescript-eslint/naming-convention\n ID_FULL_SCREEN_OVERLAY: \"bslib-full-screen-overlay\",\n // eslint-disable-next-line @typescript-eslint/naming-convention\n CLASS_SHINY_INPUT: \"bslib-card-input\",\n };\n\n /**\n * A Shiny-specific resize observer that ensures Shiny outputs in within the\n * card resize appropriately.\n * @private\n * @type {ShinyResizeObserver}\n * @static\n */\n private static shinyResizeObserver = new ShinyResizeObserver();\n\n /**\n * Watch card parent containers for removal and exit full screen mode if a\n * full screen card is removed from the DOM.\n *\n * @private\n * @type {ShinyRemovedObserver}\n * @static\n */\n private static cardRemovedObserver = new ShinyRemovedObserver(\n `.${Card.attr.CLASS_CARD}`,\n (el) => {\n const card = Card.getInstance(el);\n if (!card) return;\n if (card.card.getAttribute(Card.attr.ATTR_FULL_SCREEN) === \"true\") {\n card.exitFullScreen();\n }\n }\n );\n\n /**\n * Creates an instance of a bslib Card component.\n *\n * @constructor\n * @param {HTMLElement} card\n */\n constructor(card: HTMLElement) {\n // remove initialization attribute and script\n card.removeAttribute(Card.attr.ATTR_INIT);\n card\n .querySelector(`script[${Card.attr.ATTR_INIT}]`)\n ?.remove();\n\n this.card = card;\n Card.instanceMap.set(card, this);\n\n // Let Shiny know to trigger resize when the card size changes\n // TODO: shiny could/should do this itself (rstudio/shiny#3682)\n Card.shinyResizeObserver.observe(this.card);\n Card.cardRemovedObserver.observe(document.body);\n\n this._addEventListeners();\n this.overlay = this._createOverlay();\n this._setShinyInput();\n\n // bind event handler methods to this card instance\n this._exitFullScreenOnEscape = this._exitFullScreenOnEscape.bind(this);\n this._trapFocusExit = this._trapFocusExit.bind(this);\n }\n\n /**\n * Enter the card's full screen mode, either programmatically or via an event\n * handler. Full screen mode is activated by adding a class to the card that\n * positions it absolutely and expands it to fill the viewport. In addition,\n * we add a full screen overlay element behind the card and we trap focus in\n * the expanded card while in full screen mode.\n *\n * @param {?Event} [event]\n */\n enterFullScreen(event?: Event): void {\n if (event) event.preventDefault();\n\n // Update close anchor to control current expanded card\n if (this.card.id) {\n this.overlay.anchor.setAttribute(\"aria-controls\", this.card.id);\n }\n\n document.addEventListener(\"keydown\", this._exitFullScreenOnEscape, false);\n\n // trap focus in the fullscreen container, listening for Tab key on the\n // capture phase so we have the best chance of preventing other handlers\n document.addEventListener(\"keydown\", this._trapFocusExit, true);\n\n this.card.setAttribute(Card.attr.ATTR_FULL_SCREEN, \"true\");\n document.body.classList.add(Card.attr.CLASS_HAS_FULL_SCREEN);\n this.card.insertAdjacentElement(\"beforebegin\", this.overlay.container);\n\n // Set initial focus on the card, if not already\n if (\n !this.card.contains(document.activeElement) ||\n document.activeElement?.classList.contains(\n Card.attr.CLASS_FULL_SCREEN_ENTER\n )\n ) {\n this.card.setAttribute(\"tabindex\", \"-1\");\n this.card.focus();\n }\n\n this._emitFullScreenEvent(true);\n this._setShinyInput();\n }\n\n /**\n * Exit full screen mode. This removes the full screen overlay element,\n * removes the full screen class from the card, and removes the keyboard event\n * listeners that were added when entering full screen mode.\n */\n exitFullScreen(): void {\n document.removeEventListener(\n \"keydown\",\n this._exitFullScreenOnEscape,\n false\n );\n document.removeEventListener(\"keydown\", this._trapFocusExit, true);\n\n // Remove overlay and remove full screen classes from card\n this.overlay.container.remove();\n this.card.setAttribute(Card.attr.ATTR_FULL_SCREEN, \"false\");\n this.card.removeAttribute(\"tabindex\");\n document.body.classList.remove(Card.attr.CLASS_HAS_FULL_SCREEN);\n\n this._emitFullScreenEvent(false);\n this._setShinyInput();\n }\n\n private _setShinyInput(): void {\n if (!this.card.classList.contains(Card.attr.CLASS_SHINY_INPUT)) return;\n if (!Shiny) return;\n if (!Shiny.setInputValue) {\n // Shiny isn't ready yet, so we'll try to set the input value again later,\n // (but it might not be ready then either, so we'll keep trying).\n setTimeout(() => this._setShinyInput(), 0);\n return;\n }\n const fsAttr = this.card.getAttribute(Card.attr.ATTR_FULL_SCREEN);\n Shiny.setInputValue(this.card.id + \"_full_screen\", fsAttr === \"true\");\n }\n\n /**\n * Emits a custom event to communicate the card's full screen state change.\n * @private\n * @param {boolean} fullScreen\n */\n private _emitFullScreenEvent(fullScreen: boolean): void {\n const event = new CustomEvent(\"bslib.card\", {\n bubbles: true,\n detail: { fullScreen },\n });\n this.card.dispatchEvent(event);\n }\n\n /**\n * Adds general card-specific event listeners.\n * @private\n */\n private _addEventListeners(): void {\n const btnFullScreen = this.card.querySelector(\n `:scope > * > .${Card.attr.CLASS_FULL_SCREEN_ENTER}`\n );\n if (!btnFullScreen) return;\n btnFullScreen.addEventListener(\"click\", (ev) => this.enterFullScreen(ev));\n }\n\n /**\n * An event handler to exit full screen mode when the Escape key is pressed.\n * @private\n * @param {KeyboardEvent} event\n */\n private _exitFullScreenOnEscape(event: KeyboardEvent): void {\n if (!(event.target instanceof HTMLElement)) return;\n // If the user is in the middle of a select input choice, don't exit\n const selOpenSelectInput = [\"select[open]\", \"input[aria-expanded='true']\"];\n if (event.target.matches(selOpenSelectInput.join(\", \"))) return;\n\n if (event.key === \"Escape\") {\n this.exitFullScreen();\n }\n }\n\n /**\n * An event handler to trap focus within the card when in full screen mode.\n *\n * @description\n * This keyboard event handler ensures that tab focus stays within the card\n * when in full screen mode. When the card is first expanded,\n * we move focus to the card element itself. If focus somehow leaves the card,\n * we returns focus to the card container.\n *\n * Within the card, we handle only tabbing from the close anchor or the last\n * focusable element and only when tab focus would have otherwise left the\n * card. In those cases, we cycle focus to the last focusable element or back\n * to the anchor. If the card doesn't have any focusable elements, we move\n * focus to the close anchor.\n *\n * @note\n * Because the card contents may change, we check for focusable elements\n * every time the handler is called.\n *\n * @private\n * @param {KeyboardEvent} event\n */\n private _trapFocusExit(event: KeyboardEvent): void {\n if (!(event instanceof KeyboardEvent)) return;\n if (event.key !== \"Tab\") return;\n\n const isFocusedContainer = event.target === this.card;\n const isFocusedAnchor = event.target === this.overlay.anchor;\n const isFocusedWithin = this.card.contains(event.target as Node);\n\n const stopEvent = () => {\n event.preventDefault();\n event.stopImmediatePropagation();\n };\n\n if (!(isFocusedWithin || isFocusedContainer || isFocusedAnchor)) {\n // If focus is outside the card, return to the card\n stopEvent();\n this.card.focus();\n return;\n }\n\n // Check focusables every time because the card contents may have changed\n // but exclude the full screen enter button from this list of elements\n const focusableElements = getAllFocusableChildren(this.card).filter(\n (el) => !el.classList.contains(Card.attr.CLASS_FULL_SCREEN_ENTER)\n );\n const hasFocusableElements = focusableElements.length > 0;\n\n // We need to handle five cases:\n // 1. The card has no focusable elements --> focus the anchor\n // 2. Focus is on the card container (do nothing, natural tab order)\n // 3. Focus is on the anchor and the user pressed Tab + Shift (backwards)\n // -> Move to the last focusable element (end of card)\n // 4. Focus is on the last focusable element and the user pressed Tab\n // (forwards) -> Move to the anchor (top of card)\n // 5. otherwise we don't interfere\n\n if (!hasFocusableElements) {\n // case 1\n stopEvent();\n this.overlay.anchor.focus();\n return;\n }\n\n // case 2\n if (isFocusedContainer) return;\n\n const lastFocusable = focusableElements[focusableElements.length - 1];\n const isFocusedLast = event.target === lastFocusable;\n\n if (isFocusedAnchor && event.shiftKey) {\n stopEvent();\n lastFocusable.focus();\n return;\n }\n\n if (isFocusedLast && !event.shiftKey) {\n stopEvent();\n this.overlay.anchor.focus();\n return;\n }\n }\n\n /**\n * Creates the full screen overlay.\n * @private\n * @returns {CardFullScreenOverlay}\n */\n private _createOverlay(): CardFullScreenOverlay {\n const container = document.createElement(\"div\");\n container.id = Card.attr.ID_FULL_SCREEN_OVERLAY;\n container.onclick = this.exitFullScreen.bind(this);\n\n const anchor = this._createOverlayCloseAnchor();\n container.appendChild(anchor);\n\n return { container, anchor };\n }\n\n /**\n * Creates the anchor element used to exit the full screen mode.\n * @private\n * @returns {CardFullScreenOverlay[\"anchor\"]}\n */\n private _createOverlayCloseAnchor(): CardFullScreenOverlay[\"anchor\"] {\n const anchor = document.createElement(\"a\");\n anchor.classList.add(Card.attr.CLASS_FULL_SCREEN_EXIT);\n anchor.tabIndex = 0;\n anchor.setAttribute(\"aria-expanded\", \"true\");\n anchor.setAttribute(\"aria-label\", \"Close card\");\n anchor.setAttribute(\"role\", \"button\");\n anchor.onclick = (ev) => {\n this.exitFullScreen();\n ev.stopPropagation();\n };\n anchor.onkeydown = (ev) => {\n if (ev.key === \"Enter\" || ev.key === \" \") {\n this.exitFullScreen();\n }\n };\n anchor.innerHTML = this._overlayCloseHtml();\n\n return anchor;\n }\n\n /**\n * Returns the HTML for the close icon.\n * @private\n * @returns {string}\n */\n private _overlayCloseHtml(): string {\n return (\n \"Close \" +\n \"\" +\n \"\"\n );\n }\n\n /**\n * The registry of card instances and their associated DOM elements.\n * @private\n * @static\n * @type {WeakMap}\n */\n private static instanceMap: WeakMap = new WeakMap();\n\n /**\n * Returns the card instance associated with the given element, if any.\n * @public\n * @static\n * @param {HTMLElement} el\n * @returns {(Card | undefined)}\n */\n public static getInstance(el: HTMLElement): Card | undefined {\n return Card.instanceMap.get(el);\n }\n\n /**\n * If cards are initialized before the DOM is ready, we re-schedule the\n * initialization to occur on DOMContentLoaded.\n * @private\n * @static\n * @type {boolean}\n */\n private static onReadyScheduled = false;\n\n /**\n * Initializes all cards that require initialization on the page, or schedules\n * initialization if the DOM is not yet ready.\n * @public\n * @static\n * @param {boolean} [flushResizeObserver=true]\n */\n public static initializeAllCards(flushResizeObserver = true): void {\n if (document.readyState === \"loading\") {\n if (!Card.onReadyScheduled) {\n Card.onReadyScheduled = true;\n document.addEventListener(\"DOMContentLoaded\", () => {\n Card.initializeAllCards(false);\n });\n }\n return;\n }\n\n if (flushResizeObserver) {\n // Trigger a recheck of observed cards to unobserve non-existent cards\n Card.shinyResizeObserver.flush();\n }\n\n const initSelector = `.${Card.attr.CLASS_CARD}[${Card.attr.ATTR_INIT}]`;\n if (!document.querySelector(initSelector)) {\n // no cards to initialize\n return;\n }\n\n const cards = document.querySelectorAll(initSelector);\n cards.forEach((card) => new Card(card as HTMLElement));\n }\n}\n\n// attach Sidebar class to window for global usage\nregisterBslibGlobal(\"Card\", Card);\n\nexport { Card };\n", "import { InputBinding, registerBinding, registerBslibGlobal } from \"./_utils\";\nimport { ShinyResizeObserver } from \"./_shinyResizeObserver\";\n\n/**\n * Methods for programmatically toggling the state of the sidebar. These methods\n * describe the desired state of the sidebar: `\"close\"` and `\"open\"` transition\n * the sidebar to the desired state, unless the sidebar is already in that\n * state. `\"toggle\"` transitions the sidebar to the state opposite of its\n * current state.\n * @typedef {SidebarToggleMethod}\n */\ntype SidebarToggleMethod = \"close\" | \"closed\" | \"open\" | \"toggle\";\n\n/**\n * Data received by the input binding's `receiveMessage` method.\n * @typedef {SidebarMessageData}\n */\ntype SidebarMessageData = {\n method: SidebarToggleMethod;\n};\n\n/**\n * Represents the size of the sidebar window either: \"desktop\" or \"mobile\".\n */\ntype SidebarWindowSize = \"desktop\" | \"mobile\";\n\n/**\n * The DOM elements that make up the sidebar. `main`, `sidebar`, and `toggle`\n * are all direct children of `container` (in that order).\n * @interface SidebarComponents\n * @typedef {SidebarComponents}\n */\ninterface SidebarComponents {\n /**\n * The `layout_sidebar()` parent container, with class\n * `Sidebar.classes.LAYOUT`.\n * @type {HTMLElement}\n */\n container: HTMLElement;\n /**\n * The main content area of the sidebar layout.\n * @type {HTMLElement}\n */\n main: HTMLElement;\n /**\n * The sidebar container of the sidebar layout.\n * @type {HTMLElement}\n */\n sidebar: HTMLElement;\n /**\n * The toggle button that is used to toggle the sidebar state.\n * @type {HTMLElement}\n */\n toggle: HTMLElement;\n /**\n * The resize handle for resizing the sidebar (optional).\n * @type {HTMLElement | null}\n */\n resizeHandle?: HTMLElement | null;\n}\n\n/**\n * The bslib sidebar component class. This class is only used for collapsible\n * sidebars.\n *\n * @class Sidebar\n * @typedef {Sidebar}\n */\nclass Sidebar {\n /**\n * The DOM elements that make up the sidebar, see `SidebarComponents`.\n * @private\n * @type {SidebarComponents}\n */\n private layout: SidebarComponents;\n\n /**\n * A Shiny-specific resize observer that ensures Shiny outputs in the main\n * content areas of the sidebar resize appropriately.\n * @private\n * @type {ShinyResizeObserver}\n * @static\n */\n private static shinyResizeObserver = new ShinyResizeObserver();\n\n /**\n * Resize state tracking\n * @private\n */\n private resizeState = {\n isResizing: false,\n startX: 0,\n startWidth: 0,\n minWidth: 150,\n maxWidth: () => window.innerWidth - 50,\n constrainedWidth: (width: number): number => {\n return Math.max(\n this.resizeState.minWidth,\n Math.min(this.resizeState.maxWidth(), width)\n );\n },\n };\n\n /**\n * Creates an instance of a collapsible bslib Sidebar.\n * @constructor\n * @param {HTMLElement} container\n */\n constructor(container: HTMLElement) {\n Sidebar.instanceMap.set(container, this);\n this.layout = {\n container,\n main: container.querySelector(\":scope > .main\") as HTMLElement,\n sidebar: container.querySelector(\":scope > .sidebar\") as HTMLElement,\n toggle: container.querySelector(\n \":scope > .collapse-toggle\"\n ) as HTMLElement,\n } as SidebarComponents;\n\n const sideAccordion = this.layout.sidebar.querySelector(\n \":scope > .sidebar-content > .accordion\"\n );\n if (sideAccordion) {\n // Add `.has-accordion` class to `.sidebar-content` container\n sideAccordion?.parentElement?.classList.add(\"has-accordion\");\n sideAccordion.classList.add(\"accordion-flush\");\n }\n\n this._initSidebarCounters();\n this._initSidebarState();\n\n if (this._isCollapsible(\"desktop\") || this._isCollapsible(\"mobile\")) {\n this._initEventListeners();\n }\n\n // Initialize resize functionality\n this._initResizeHandle();\n\n // Start watching the main content area for size changes to ensure Shiny\n // outputs resize appropriately during sidebar transitions.\n Sidebar.shinyResizeObserver.observe(this.layout.main);\n\n container.removeAttribute(\"data-bslib-sidebar-init\");\n const initScript = container.querySelector(\n \":scope > script[data-bslib-sidebar-init]\"\n );\n if (initScript) {\n container.removeChild(initScript);\n }\n }\n\n /**\n * Read the current state of the sidebar. Note that, when calling this method,\n * the sidebar may be transitioning into the state returned by this method.\n *\n * @description\n * The sidebar state works as follows, starting from the open state. When the\n * sidebar is closed:\n * 1. We add both the `COLLAPSE` and `TRANSITIONING` classes to the sidebar.\n * 2. The sidebar collapse begins to animate. In general, where it is\n * supported, we transition the `grid-template-columns` property of the\n * sidebar layout. We also rotate the collapse icon and we use this\n * rotation to determine when the transition is complete.\n * 3. If another sidebar state toggle is requested while closing the sidebar,\n * we remove the `COLLAPSE` class and the animation immediately starts to\n * reverse.\n * 4. When the `transition` is complete, we remove the `TRANSITIONING` class.\n * @readonly\n * @type {boolean}\n */\n get isClosed(): boolean {\n return this.layout.container.classList.contains(Sidebar.classes.COLLAPSE);\n }\n\n /**\n * Static classes related to the sidebar layout or state.\n * @public\n * @static\n * @readonly\n * @type {{ LAYOUT: string; COLLAPSE: string; TRANSITIONING: string; }}\n */\n public static readonly classes = {\n // eslint-disable-next-line @typescript-eslint/naming-convention\n LAYOUT: \"bslib-sidebar-layout\",\n // eslint-disable-next-line @typescript-eslint/naming-convention\n COLLAPSE: \"sidebar-collapsed\",\n // eslint-disable-next-line @typescript-eslint/naming-convention\n TRANSITIONING: \"transitioning\",\n // eslint-disable-next-line @typescript-eslint/naming-convention\n RESIZE_HANDLE: \"bslib-sidebar-resize-handle\",\n // eslint-disable-next-line @typescript-eslint/naming-convention\n RESIZING: \"sidebar-resizing\",\n };\n\n /**\n * If sidebars are initialized before the DOM is ready, we re-schedule the\n * initialization to occur on DOMContentLoaded.\n * @private\n * @static\n * @type {boolean}\n */\n private static onReadyScheduled = false;\n /**\n * A map of initialized sidebars to their respective Sidebar instances.\n * @private\n * @static\n * @type {WeakMap}\n */\n private static instanceMap: WeakMap = new WeakMap();\n\n /**\n * Given a sidebar container, return the Sidebar instance associated with it.\n * @public\n * @static\n * @param {HTMLElement} el\n * @returns {(Sidebar | undefined)}\n */\n public static getInstance(el: HTMLElement): Sidebar | undefined {\n return Sidebar.instanceMap.get(el);\n }\n\n /**\n * Determine whether the sidebar is collapsible at a given screen size.\n * @private\n * @param {SidebarWindowSize} [size=\"desktop\"]\n * @returns {boolean}\n */\n private _isCollapsible(size: SidebarWindowSize = \"desktop\"): boolean {\n const { container } = this.layout;\n\n const attr =\n size === \"desktop\" ? \"collapsibleDesktop\" : \"collapsibleMobile\";\n\n const isCollapsible = container.dataset[attr];\n\n if (isCollapsible === undefined) {\n return true;\n }\n\n return isCollapsible.trim().toLowerCase() !== \"false\";\n }\n\n /**\n * Initialize all collapsible sidebars on the page.\n * @public\n * @static\n * @param {boolean} [flushResizeObserver=true] When `true`, we remove\n * non-existent elements from the ResizeObserver. This is required\n * periodically to prevent memory leaks. To avoid over-checking, we only flush\n * the ResizeObserver when initializing sidebars after page load.\n */\n public static initCollapsibleAll(flushResizeObserver = true): void {\n if (document.readyState === \"loading\") {\n if (!Sidebar.onReadyScheduled) {\n Sidebar.onReadyScheduled = true;\n document.addEventListener(\"DOMContentLoaded\", () => {\n Sidebar.initCollapsibleAll(false);\n });\n }\n return;\n }\n\n const initSelector = `.${Sidebar.classes.LAYOUT}[data-bslib-sidebar-init]`;\n if (!document.querySelector(initSelector)) {\n // no sidebars to initialize\n return;\n }\n\n if (flushResizeObserver) Sidebar.shinyResizeObserver.flush();\n\n const containers = document.querySelectorAll(initSelector);\n containers.forEach((container) => new Sidebar(container as HTMLElement));\n }\n\n /**\n * Initialize sidebar resize functionality.\n * @private\n */\n private _initResizeHandle(): void {\n if (!this.layout.resizeHandle) {\n const handle = this._createResizeHandle();\n // Insert handle into the layout container\n this.layout.container.appendChild(handle);\n this.layout.resizeHandle = handle;\n\n this._attachResizeEventListeners(handle);\n }\n this._updateResizeAvailability();\n }\n\n /**\n * Create the resize handle element.\n * @private\n */\n private _createResizeHandle(): HTMLDivElement {\n const handle = document.createElement(\"div\");\n handle.className = Sidebar.classes.RESIZE_HANDLE;\n handle.setAttribute(\"role\", \"separator\");\n handle.setAttribute(\"aria-orientation\", \"vertical\");\n handle.setAttribute(\"aria-label\", \"Resize sidebar\");\n handle.setAttribute(\"tabindex\", \"0\");\n handle.setAttribute(\"aria-keyshortcuts\", \"ArrowLeft ArrowRight Home End\");\n handle.title = \"Drag to resize sidebar\";\n\n const indicator = document.createElement(\"div\");\n indicator.className = \"resize-indicator\";\n handle.appendChild(indicator);\n\n const instructions = document.createElement(\"div\");\n instructions.className = \"visually-hidden\";\n instructions.textContent =\n \"Use arrow keys to resize the sidebar, Shift for larger steps, Home/End for min/max width.\";\n handle.appendChild(instructions);\n\n return handle;\n }\n\n /**\n * Attach event listeners for resize functionality.\n * @private\n */\n private _attachResizeEventListeners(handle: HTMLDivElement): void {\n // Mouse events\n handle.addEventListener(\"mousedown\", this._onResizeStart.bind(this));\n document.addEventListener(\"mousemove\", this._onResizeMove.bind(this));\n document.addEventListener(\"mouseup\", this._onResizeEnd.bind(this));\n\n // Touch events for mobile devices\n handle.addEventListener(\"touchstart\", this._onResizeStart.bind(this), {\n passive: false,\n });\n document.addEventListener(\"touchmove\", this._onResizeMove.bind(this), {\n passive: false,\n });\n document.addEventListener(\"touchend\", this._onResizeEnd.bind(this));\n\n // Keyboard events for accessibility\n handle.addEventListener(\"keydown\", this._onResizeKeyDown.bind(this));\n\n window.addEventListener(\n \"resize\",\n whenChangedCallback(\n () => this._getWindowSize(),\n () => this._updateResizeAvailability()\n )\n );\n }\n\n /**\n * Check if the sidebar should be resizable in the current state.\n * @private\n * @returns {boolean}\n */\n private _shouldEnableResize(): boolean {\n const isDesktop = this._getWindowSize() === \"desktop\";\n const notTransitioning = !this.layout.container.classList.contains(\n Sidebar.classes.TRANSITIONING\n );\n const notClosed = !this.isClosed;\n\n return (\n // Allow resizing only when the sidebar...\n isDesktop && notTransitioning && notClosed\n );\n }\n\n /**\n * Handle resize start (mouse/touch down).\n * @private\n * @param {MouseEvent | TouchEvent} event\n */\n private _onResizeStart(event: MouseEvent | TouchEvent): void {\n if (!this._shouldEnableResize()) return;\n\n event.preventDefault();\n\n const clientX =\n \"touches\" in event ? event.touches[0].clientX : event.clientX;\n\n this.resizeState.isResizing = true;\n this.resizeState.startX = clientX;\n this.resizeState.startWidth = this._getCurrentSidebarWidth();\n\n // Disable transitions during resize for smooth interaction\n this.layout.container.style.setProperty(\"--_transition-duration\", \"0ms\");\n this.layout.container.classList.add(Sidebar.classes.RESIZING);\n\n document.documentElement.setAttribute(\n `data-bslib-${Sidebar.classes.RESIZING}`,\n \"true\"\n );\n\n this._dispatchResizeEvent(\"start\", this.resizeState.startWidth);\n }\n\n /**\n * Handle resize move (mouse/touch move).\n * @private\n * @param {MouseEvent | TouchEvent} event\n */\n private _onResizeMove(event: MouseEvent | TouchEvent): void {\n if (!this.resizeState.isResizing) return;\n\n event.preventDefault();\n\n const clientX =\n \"touches\" in event ? event.touches[0].clientX : event.clientX;\n const deltaX = clientX - this.resizeState.startX;\n\n // Calculate new width based on sidebar position\n const isRight = this._isRightSidebar();\n const newWidth = isRight\n ? this.resizeState.startWidth - deltaX\n : this.resizeState.startWidth + deltaX;\n\n // Constrain within bounds\n const constrainedWidth = this.resizeState.constrainedWidth(newWidth);\n\n this._updateSidebarWidth(constrainedWidth);\n this._dispatchResizeEvent(\"move\", constrainedWidth);\n }\n\n /**\n * Handle resize end (mouse/touch up).\n * @private\n */\n private _onResizeEnd(): void {\n if (!this.resizeState.isResizing) return;\n\n this.resizeState.isResizing = false;\n\n // Re-enable transitions\n this.layout.container.style.removeProperty(\"--_transition-duration\");\n this.layout.container.classList.remove(Sidebar.classes.RESIZING);\n\n // Reset cursor and text selection resizing changes\n document.documentElement.removeAttribute(\n `data-bslib-${Sidebar.classes.RESIZING}`\n );\n\n // Dispatch resize end event\n Sidebar.shinyResizeObserver.flush();\n this._dispatchResizeEvent(\"end\", this._getCurrentSidebarWidth());\n }\n\n /**\n * Handle keyboard events for resize accessibility.\n * @private\n * @param {KeyboardEvent} event\n */\n private _onResizeKeyDown(event: KeyboardEvent): void {\n if (!this._shouldEnableResize()) return;\n\n const step = event.shiftKey ? 50 : 10; // Larger steps with Shift\n let newWidth = this._getCurrentSidebarWidth();\n\n switch (event.key) {\n case \"ArrowLeft\":\n newWidth = this._isRightSidebar() ? newWidth + step : newWidth - step;\n break;\n case \"ArrowRight\":\n newWidth = this._isRightSidebar() ? newWidth - step : newWidth + step;\n break;\n case \"Home\":\n newWidth = this.resizeState.minWidth;\n break;\n case \"End\":\n newWidth = this.resizeState.maxWidth();\n break;\n default:\n return; // Don't prevent default for other keys\n }\n\n event.preventDefault();\n\n // Constrain within bounds\n newWidth = this.resizeState.constrainedWidth(newWidth);\n\n this._updateSidebarWidth(newWidth);\n Sidebar.shinyResizeObserver.flush();\n this._dispatchResizeEvent(\"keyboard\", newWidth);\n }\n\n /**\n * Get the current sidebar width in pixels.\n * @private\n * @returns {number}\n */\n private _getCurrentSidebarWidth(): number {\n const sidebarWidth = this.layout.sidebar.getBoundingClientRect().width;\n return sidebarWidth || 250;\n }\n\n /**\n * Update the sidebar width.\n * @private\n * @param {number} newWidth\n */\n private _updateSidebarWidth(newWidth: number): void {\n const { container, resizeHandle } = this.layout;\n\n container.style.setProperty(\"--_sidebar-width\", `${newWidth}px`);\n\n // Update min, max and current width attributes on the resize handle\n if (resizeHandle) {\n resizeHandle.setAttribute(\"aria-valuenow\", newWidth.toString());\n resizeHandle.setAttribute(\n \"aria-valuemin\",\n this.resizeState.minWidth.toString()\n );\n resizeHandle.setAttribute(\n \"aria-valuemax\",\n this.resizeState.maxWidth().toString()\n );\n }\n }\n\n /**\n * Check if this is a right-aligned sidebar.\n * @private\n * @returns {boolean}\n */\n private _isRightSidebar(): boolean {\n return this.layout.container.classList.contains(\"sidebar-right\");\n }\n\n /**\n * Update resize handle availability based on current state.\n * @private\n */\n private _updateResizeAvailability(): void {\n if (!this.layout.resizeHandle) return;\n\n const shouldEnable = this._shouldEnableResize();\n\n this.layout.resizeHandle.style.display = shouldEnable ? \"\" : \"none\";\n this.layout.resizeHandle.setAttribute(\n \"aria-hidden\",\n shouldEnable ? \"false\" : \"true\"\n );\n\n if (shouldEnable) {\n this.layout.resizeHandle.setAttribute(\"tabindex\", \"0\");\n } else {\n this.layout.resizeHandle.removeAttribute(\"tabindex\");\n }\n }\n\n /**\n * Dispatch a custom resize event.\n * @private\n * @param {string} phase The phase of the resize event lifecycle, e.g.\n * \"start\", \"move\", \"end\", or \"keyboard\".\n * @param {number} width The new width of the sidebar in pixels.\n */\n private _dispatchResizeEvent(phase: string, width: number): void {\n const event = new CustomEvent(\"bslib.sidebar.resize\", {\n bubbles: true,\n detail: { phase, width, sidebar: this },\n });\n this.layout.sidebar.dispatchEvent(event);\n }\n\n /**\n * Initialize event listeners for the sidebar toggle button.\n * @private\n */\n private _initEventListeners(): void {\n const { toggle } = this.layout;\n\n toggle.addEventListener(\"click\", (ev) => {\n ev.preventDefault();\n this.toggle(\"toggle\");\n });\n\n // Remove the transitioning class when the transition ends. We watch the\n // collapse toggle icon because it's guaranteed to transition, whereas not\n // all browsers support animating grid-template-columns.\n toggle\n .querySelector(\".collapse-icon\")\n ?.addEventListener(\"transitionend\", () => {\n this._finalizeState();\n });\n\n if (this._isCollapsible(\"desktop\") && this._isCollapsible(\"mobile\")) {\n return;\n }\n\n // The sidebar is *sometimes* collapsible, so we need to handle window\n // resize events to ensure visibility and expected behavior.\n window.addEventListener(\n \"resize\",\n whenChangedCallback(\n () => this._getWindowSize(),\n () => this._initSidebarState()\n )\n );\n }\n\n /**\n * Initialize nested sidebar counters.\n *\n * @description\n * This function walks up the DOM tree, adding CSS variables to each direct\n * parent sidebar layout that count the layout's position in the stack of\n * nested layouts. We use these counters to keep the collapse toggles from\n * overlapping. Note that always-open sidebars that don't have collapse\n * toggles break the chain of nesting.\n * @private\n */\n private _initSidebarCounters(): void {\n const { container } = this.layout;\n\n const selectorChildLayouts =\n `.${Sidebar.classes.LAYOUT}` +\n \"> .main > \" +\n `.${Sidebar.classes.LAYOUT}:not([data-bslib-sidebar-open=\"always\"])`;\n\n const isInnermostLayout =\n container.querySelector(selectorChildLayouts) === null;\n\n if (!isInnermostLayout) {\n // There are sidebar layouts nested within this layout; defer to children\n return;\n }\n\n function nextSidebarParent(el: HTMLElement | null): HTMLElement | null {\n el = el ? el.parentElement : null;\n if (el && el.classList.contains(\"main\")) {\n // .bslib-sidebar-layout > .main > .bslib-sidebar-layout\n el = el.parentElement;\n }\n if (el && el.classList.contains(Sidebar.classes.LAYOUT)) {\n return el;\n }\n return null;\n }\n\n const layouts = [container];\n let parent = nextSidebarParent(container);\n\n while (parent) {\n // Add parent to front of layouts array, so we sort outer -> inner\n layouts.unshift(parent);\n parent = nextSidebarParent(parent);\n }\n\n const count = { left: 0, right: 0 };\n layouts.forEach(function (x: HTMLElement): void {\n const isRight = x.classList.contains(\"sidebar-right\");\n const thisCount = isRight ? count.right++ : count.left++;\n x.style.setProperty(\"--_js-toggle-count-this-side\", thisCount.toString());\n x.style.setProperty(\n \"--_js-toggle-count-max-side\",\n Math.max(count.right, count.left).toString()\n );\n });\n }\n\n /**\n * Retrieves the current window size by reading a CSS variable whose value is\n * toggled via media queries.\n * @returns The window size as `\"desktop\"` or `\"mobile\"`, or `\"\"` if not\n * available.\n */\n private _getWindowSize(): SidebarWindowSize | \"\" {\n const { container } = this.layout;\n\n return window\n .getComputedStyle(container)\n .getPropertyValue(\"--bslib-sidebar-js-window-size\")\n .trim() as SidebarWindowSize | \"\";\n }\n\n /**\n * Determine the initial toggle state of the sidebar at a current screen size.\n * It always returns whether we should `\"open\"` or `\"close\"` the sidebar.\n *\n * @private\n * @returns {(\"close\" | \"open\")}\n */\n private _initialToggleState(): \"close\" | \"open\" {\n const { container } = this.layout;\n\n const attr = this.windowSize === \"desktop\" ? \"openDesktop\" : \"openMobile\";\n\n const initState = container.dataset[attr]?.trim()?.toLowerCase();\n\n if (initState === undefined) {\n return \"open\";\n }\n\n if ([\"open\", \"always\"].includes(initState)) {\n return \"open\";\n }\n\n if ([\"close\", \"closed\"].includes(initState)) {\n return \"close\";\n }\n\n return \"open\";\n }\n\n /**\n * Initialize the sidebar's initial state when `open = \"desktop\"`.\n * @private\n */\n private _initSidebarState(): void {\n // Check the CSS variable to find out which mode we're in right now\n this.windowSize = this._getWindowSize();\n\n const initState = this._initialToggleState();\n this.toggle(initState, true);\n }\n\n /**\n * The current window size, either `\"desktop\"` or `\"mobile\"`.\n * @private\n * @type {SidebarWindowSize | \"\"}\n */\n private windowSize: SidebarWindowSize | \"\" = \"\";\n\n /**\n * Toggle the sidebar's open/closed state.\n * @public\n * @param {SidebarToggleMethod | undefined} method Whether to `\"open\"`,\n * `\"close\"` or `\"toggle\"` the sidebar. If `.toggle()` is called without an\n * argument, it will toggle the sidebar's state.\n * @param {boolean} [immediate=false] If `true`, the sidebar state will be\n * set immediately, without a transition. This is primarily used when the\n * sidebar is initialized.\n */\n public toggle(\n method: SidebarToggleMethod | undefined,\n immediate = false\n ): void {\n if (typeof method === \"undefined\") {\n method = \"toggle\";\n } else if (method === \"closed\") {\n method = \"close\";\n }\n\n const { container, sidebar } = this.layout;\n const isClosed = this.isClosed;\n\n if ([\"open\", \"close\", \"toggle\"].indexOf(method) === -1) {\n throw new Error(`Unknown method ${method}`);\n }\n\n if (method === \"toggle\") {\n method = isClosed ? \"open\" : \"close\";\n }\n\n if ((isClosed && method === \"close\") || (!isClosed && method === \"open\")) {\n // nothing to do, sidebar is already in the desired state\n if (immediate) this._finalizeState();\n return;\n }\n\n if (method === \"open\") {\n // unhide sidebar immediately when opening,\n // otherwise the sidebar is hidden on transitionend\n sidebar.hidden = false;\n }\n\n // If not immediate, add the .transitioning class to the sidebar for smooth\n // transitions. This class is removed when the transition ends.\n container.classList.toggle(Sidebar.classes.TRANSITIONING, !immediate);\n container.classList.toggle(Sidebar.classes.COLLAPSE);\n\n if (immediate) {\n // When transitioning, state is finalized on transitionend, otherwise we\n // need to manually and immediately finalize the state.\n this._finalizeState();\n }\n }\n\n /**\n * When the sidebar open/close transition ends, finalize the sidebar's state.\n * @private\n */\n private _finalizeState(): void {\n const { container, sidebar, toggle } = this.layout;\n container.classList.remove(Sidebar.classes.TRANSITIONING);\n sidebar.hidden = this.isClosed;\n toggle.setAttribute(\"aria-expanded\", this.isClosed ? \"false\" : \"true\");\n\n // Update resize handle availability\n this._updateResizeAvailability();\n\n // Send browser-native event with updated sidebar state\n const event = new CustomEvent(\"bslib.sidebar\", {\n bubbles: true,\n detail: { open: !this.isClosed },\n });\n sidebar.dispatchEvent(event);\n\n // Trigger Shiny input and output binding events\n $(sidebar).trigger(\"toggleCollapse.sidebarInputBinding\");\n $(sidebar).trigger(this.isClosed ? \"hidden\" : \"shown\");\n }\n}\n\nfunction whenChangedCallback(\n watchFn: () => unknown,\n callback: () => void\n): () => void {\n let lastValue = watchFn();\n\n return () => {\n const currentValue = watchFn();\n\n if (currentValue !== lastValue) {\n callback();\n }\n\n lastValue = currentValue;\n };\n}\n\n/**\n * A Shiny input binding for a sidebar.\n * @class SidebarInputBinding\n * @typedef {SidebarInputBinding}\n * @extends {InputBinding}\n */\nclass SidebarInputBinding extends InputBinding {\n find(scope: HTMLElement) {\n return $(scope).find(`.${Sidebar.classes.LAYOUT} > .bslib-sidebar-input`);\n }\n\n getValue(el: HTMLElement): boolean {\n const sb = Sidebar.getInstance(el.parentElement as HTMLElement);\n if (!sb) return false;\n return !sb.isClosed;\n }\n\n setValue(el: HTMLElement, value: boolean): void {\n const method = value ? \"open\" : \"close\";\n this.receiveMessage(el, { method });\n }\n\n subscribe(el: HTMLElement, callback: (x: boolean) => void) {\n $(el).on(\n \"toggleCollapse.sidebarInputBinding\",\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n function (event) {\n callback(true);\n }\n );\n }\n\n unsubscribe(el: HTMLElement) {\n $(el).off(\".sidebarInputBinding\");\n }\n\n receiveMessage(el: HTMLElement, data: SidebarMessageData) {\n const sb = Sidebar.getInstance(el.parentElement as HTMLElement);\n if (sb) sb.toggle(data.method);\n }\n}\n\nregisterBinding(SidebarInputBinding, \"sidebar\");\n// attach Sidebar class to window for global usage\nregisterBslibGlobal(\"Sidebar\", Sidebar);\n", "import { InputBinding, registerBinding } from \"./_utils\";\nimport type { BslibSwitchInline } from \"./webcomponents/switch\";\n\ntype TaskButtonMessage = {\n state: string;\n};\n\n/**\n * This is a Shiny input binding for `bslib::input_task_button()`. It is not a\n * web component, though one of its children is . The\n * reason it is not a web component is because it is primarily a button, and I\n * wanted to use the native + +
+ + +# as.tags.bslib_toast respects accessibility attributes + + Code + cat(format(as.tags(t_danger))) + Output + + +--- + + Code + cat(format(as.tags(t_info))) + Output +
+
+
Info message
+ +
+
+ +--- + + Code + cat(format(as.tags(t_default))) + Output +
+
+
Default message
+ +
+
+ +# as.tags.bslib_toast includes close button appropriately + + Code + cat(format(as.tags(t_header))) + Output +
+
+ Title + +
+
Message
+
+ +--- + + Code + cat(format(as.tags(t_no_header))) + Output +
+
+
Message
+ +
+
+ +--- + + Code + cat(format(as.tags(t_non_closable))) + Output +
+
Message
+
+ +# normalize_toast_position() errors on invalid input + + Code + normalize_toast_position("top") + Condition + Error in `normalize_toast_position()`: + ! Invalid toast position: 'top'. Must specify one vertical position (top, middle, bottom) and one horizontal position (left, center, right). + Code + normalize_toast_position("left") + Condition + Error in `normalize_toast_position()`: + ! Invalid toast position: 'left'. Must specify one vertical position (top, middle, bottom) and one horizontal position (left, center, right). + Code + normalize_toast_position("top bottom left") + Condition + Error in `normalize_toast_position()`: + ! Invalid toast position: 'top bottom left'. Must specify one vertical position (top, middle, bottom) and one horizontal position (left, center, right). + Code + normalize_toast_position(c("top", "bottom", "left")) + Condition + Error in `normalize_toast_position()`: + ! Invalid toast position: 'top bottom left'. Must specify one vertical position (top, middle, bottom) and one horizontal position (left, center, right). + Code + normalize_toast_position("top invalid") + Condition + Error in `normalize_toast_position()`: + ! Invalid toast position: 'top invalid'. Must specify one vertical position (top, middle, bottom) and one horizontal position (left, center, right). + Code + normalize_toast_position("foo bar") + Condition + Error in `normalize_toast_position()`: + ! Invalid toast position: 'foo bar'. Must specify one vertical position (top, middle, bottom) and one horizontal position (left, center, right). + # show_toast() and hide_toast() warn if nothing to show/hide Code diff --git a/tests/testthat/test-toast.R b/tests/testthat/test-toast.R index 80b437c13..119332f17 100644 --- a/tests/testthat/test-toast.R +++ b/tests/testthat/test-toast.R @@ -1,11 +1,14 @@ -test_that("toast() creates bslib_toast object", { +# toast() constructor tests ---- + +test_that("toast() creates bslib_toast object with defaults", { t <- toast("Test message") expect_s3_class(t, "bslib_toast") - expect_equal(t$body, list("Test message")) expect_null(t$id) expect_true(t$autohide) - expect_equal(t$duration, 5000) + expect_equal(t$duration, 5000) # Default 5 seconds in milliseconds + expect_true(t$closable) + expect_null(t$header) expect_equal(t$position, "top-right") }) @@ -13,38 +16,47 @@ test_that("toast() validates position argument", { expect_no_error(toast("Test", position = "bottom-left")) expect_no_error(toast("Test", position = "top-center")) expect_no_error(toast("Test", position = "middle-center")) - expect_error(toast("Test", position = "invalid")) + expect_snapshot(error = TRUE, toast("Test", position = "invalid")) }) test_that("toast() validates type argument", { expect_no_error(toast("Test", type = "success")) expect_no_error(toast("Test", type = "danger")) expect_no_error(toast("Test", type = "info")) - expect_error(toast("Test", type = "invalid")) + expect_snapshot(error = TRUE, toast("Test", type = "invalid")) }) -test_that("toast() autohide_s = 0 disables autohiding", { - t <- toast("Test", autohide_s = 0, closable = FALSE) - expect_false(t$autohide) - expect_true(t$closable) # Always true when autohide disabled +test_that("toast() type 'error' is aliased to 'danger'", { + t <- toast("Test", type = "error") + expect_equal(t$type, "danger") }) -test_that("toast() autohide_s = NA disables autohiding", { - t <- toast("Test", autohide_s = NA, closable = FALSE) - expect_false(t$autohide) - expect_true(t$closable) # Always true when autohide disabled -}) +test_that("toast() autohide disabled (0, NA, NULL)", { + # When autohide is disabled, closable is forced to TRUE for accessibility + t1 <- toast("Test", autohide_s = 0, closable = FALSE) + expect_false(t1$autohide) + expect_true(t1$closable) # Always true when autohide disabled -test_that("toast() autohide_s = NULL disables autohiding", { - t <- toast("Test", autohide_s = NULL, closable = FALSE) - expect_false(t$autohide) - expect_true(t$closable) # Always true when autohide disabled + t2 <- toast("Test", autohide_s = NA, closable = FALSE) + expect_false(t2$autohide) + expect_true(t2$closable) + + t3 <- toast("Test", autohide_s = NULL, closable = FALSE) + expect_false(t3$autohide) + expect_true(t3$closable) }) -test_that("toast() autohide_s > 0 enables autohiding", { - t <- toast("Test", autohide_s = 10) - expect_true(t$autohide) - expect_equal(t$duration, 10000) # Converted to milliseconds +test_that("toast() `closable` when autohide enabled", { + # When autohide is enabled, user can control closable + t_closable <- toast("Test", autohide_s = 10, closable = TRUE) + expect_true(t_closable$autohide) + expect_equal(t_closable$duration, 10000) # Converted to milliseconds + expect_true(t_closable$closable) + + t_not_closable <- toast("Test", autohide_s = 5, closable = FALSE) + expect_true(t_not_closable$autohide) + expect_equal(t_not_closable$duration, 5000) + expect_false(t_not_closable$closable) }) test_that("toast() autohide_s throws for invalid values", { @@ -55,10 +67,8 @@ test_that("toast() autohide_s throws for invalid values", { }) }) -test_that("toast() allows closable = FALSE when autohiding", { - t <- toast("Test", autohide_s = 5, closable = FALSE) - expect_false(t$closable) -}) + +# toast() rendering tests ---- test_that("as.tags.bslib_toast creates proper HTML structure", { t <- toast( @@ -69,12 +79,8 @@ test_that("as.tags.bslib_toast creates proper HTML structure", { ) tag <- as.tags(t) - expect_s3_class(tag, "shiny.tag") - html_str <- as.character(tag) - expect_true(grepl("toast", html_str)) - expect_true(grepl("text-bg-success", html_str)) - expect_true(grepl('id="test-toast"', html_str)) + expect_snapshot(cat(format(tag))) }) test_that("as.tags.bslib_toast generates ID if not provided", { @@ -82,54 +88,59 @@ test_that("as.tags.bslib_toast generates ID if not provided", { tag <- as.tags(t) html_str <- as.character(tag) - expect_true(grepl('id="bslib-toast-', html_str)) + # Verify an auto-generated ID is present + expect_match(html_str, 'id="bslib-toast-[0-9]+"') }) test_that("as.tags.bslib_toast respects accessibility attributes", { - # Danger type gets assertive - t_danger <- toast("Error message", type = "danger") - tag_danger <- as.tags(t_danger) - html_danger <- as.character(tag_danger) - expect_true(grepl('aria-live="assertive"', html_danger)) - expect_true(grepl('role="alert"', html_danger)) - - # Info type gets polite - t_info <- toast("Info message", type = "info") - tag_info <- as.tags(t_info) - html_info <- as.character(tag_info) - expect_true(grepl('aria-live="polite"', html_info)) - expect_true(grepl('role="status"', html_info)) - - # NULL type (default) gets polite - t_default <- toast("Default message") - tag_default <- as.tags(t_default) - html_default <- as.character(tag_default) - expect_true(grepl('aria-live="polite"', html_default)) - expect_true(grepl('role="status"', html_default)) + # Danger type gets assertive role + t_danger <- toast("Error message", type = "danger", id = "danger-toast") + html_danger <- as.character(as.tags(t_danger)) + expect_match(html_danger, 'role="alert"') + expect_match(html_danger, 'aria-live="assertive"') + expect_snapshot(cat(format(as.tags(t_danger)))) + + # Info type gets polite role + t_info <- toast("Info message", type = "info", id = "info-toast") + html_info <- as.character(as.tags(t_info)) + expect_match(html_info, 'role="status"') + expect_match(html_info, 'aria-live="polite"') + expect_snapshot(cat(format(as.tags(t_info)))) + + # NULL type (default) gets polite role + t_default <- toast("Default message", id = "default-toast") + html_default <- as.character(as.tags(t_default)) + expect_match(html_default, 'role="status"') + expect_match(html_default, 'aria-live="polite"') + expect_snapshot(cat(format(as.tags(t_default)))) }) test_that("as.tags.bslib_toast includes close button appropriately", { # With header, closable - t_header <- toast("Message", header = "Title", closable = TRUE) - tag_header <- as.tags(t_header) - html_header <- as.character(tag_header) - expect_true(grepl("btn-close", html_header)) - expect_true(grepl("toast-header", html_header)) + t_header <- toast( + "Message", + header = "Title", + closable = TRUE, + id = "header-toast" + ) + expect_snapshot(cat(format(as.tags(t_header)))) # Without header, closable - t_no_header <- toast("Message", closable = TRUE) - tag_no_header <- as.tags(t_no_header) - html_no_header <- as.character(tag_no_header) - expect_true(grepl("btn-close", html_no_header)) - expect_false(grepl("toast-header", html_no_header)) + t_no_header <- toast("Message", closable = TRUE, id = "no-header-toast") + expect_snapshot(cat(format(as.tags(t_no_header)))) # Non-closable with autohide - t_non_closable <- toast("Message", closable = FALSE, autohide = TRUE) - tag_non_closable <- as.tags(t_non_closable) - html_non_closable <- as.character(tag_non_closable) - expect_false(grepl("btn-close", html_non_closable)) + t_non_closable <- toast( + "Message", + closable = FALSE, + autohide_s = 5, + id = "non-closable-toast" + ) + expect_snapshot(cat(format(as.tags(t_non_closable)))) }) +# toast_header() tests ---- + test_that("toast_header() creates structured header data", { # Simple header with just title h1 <- toast_header("My Title") @@ -146,7 +157,6 @@ test_that("toast_header() creates structured header data", { }) test_that("toast_header() works with icons", { - # Mock icon (just a simple span for testing) icon <- span(class = "test-icon") h <- toast_header("Title", icon = icon) @@ -168,53 +178,36 @@ test_that("toast() stores additional attributes", { expect_true(grepl('class="toast[^"]+extra-class"', html)) }) -test_that("toast() with custom autohide_s converts to milliseconds", { - t <- toast("Test", autohide_s = 10) - expect_equal(t$duration, 10000) - expect_true(t$autohide) + +test_that("toast() type is reflected in rendered HTML", { + t_success <- toast("Test", type = "success") + expect_equal(t_success$type, "success") + + tag_success <- as.tags(t_success) + html_success <- as.character(tag_success) + expect_true(grepl("text-bg-success", html_success)) + + t_danger <- toast("Test", type = "danger") + expect_equal(t_danger$type, "danger") + + tag_danger <- as.tags(t_danger) + html_danger <- as.character(tag_danger) + expect_true(grepl("text-bg-danger", html_danger)) }) -test_that("toast() with all type options", { - types <- c( - "primary", - "secondary", - "success", - "info", - "warning", - "danger", - "light", - "dark" - ) +test_that("toast() position is stored correctly", { + t1 <- toast("Test", position = "top-left") + expect_equal(t1$position, "top-left") - for (type in types) { - t <- toast("Test", type = type) - expect_equal(t$type, type) - - tag <- as.tags(t) - html <- as.character(tag) - expect_true(grepl(paste0("text-bg-", type), html)) - } -}) - -test_that("toast() with all position options", { - positions <- c( - "top-left", - "top-center", - "top-right", - "middle-left", - "middle-center", - "middle-right", - "bottom-left", - "bottom-center", - "bottom-right" - ) + t2 <- toast("Test", position = "middle-center") + expect_equal(t2$position, "middle-center") - for (pos in positions) { - t <- toast("Test", position = pos) - expect_equal(t$position, pos) - } + t3 <- toast("Test", position = "bottom-right") + expect_equal(t3$position, "bottom-right") }) +# toast() header integration tests ---- + test_that("toast with character header", { t <- toast("Body", header = "Simple Header") tag <- as.tags(t) @@ -294,7 +287,8 @@ test_that("toast header can be replaced with list pattern", { expect_false(grepl("Simple", html)) }) -# Tests for normalize_toast_position() helper +# normalize_toast_position() helper tests ---- + test_that("normalize_toast_position() handles standard kebab-case format", { expect_equal(normalize_toast_position("top-left"), "top-left") expect_equal(normalize_toast_position("bottom-right"), "bottom-right") @@ -354,77 +348,36 @@ test_that("normalize_toast_position() defaults to bottom-right when unspecified" }) test_that("normalize_toast_position() handles all valid combinations", { - vertical <- c("top", "middle", "bottom") - horizontal <- c("left", "center", "right") - - for (v in vertical) { - for (h in horizontal) { - expected <- paste0(v, "-", h) - # Space-separated - expect_equal( - normalize_toast_position(paste(v, h)), - expected, - label = paste("space-separated:", v, h) - ) - # Reversed order - expect_equal( - normalize_toast_position(paste(h, v)), - expected, - label = paste("reversed:", h, v) - ) - # Vector - expect_equal( - normalize_toast_position(c(v, h)), - expected, - label = paste("vector:", v, h) - ) - } - } -}) - -test_that("normalize_toast_position() errors on missing components", { - expect_error( - normalize_toast_position("top"), - "Must specify one vertical position.*and.*one horizontal position" - ) - expect_error( - normalize_toast_position("left"), - "Must specify one vertical position.*and.*one horizontal position" - ) - expect_error( - normalize_toast_position("center"), - "Must specify one vertical position.*and.*one horizontal position" - ) -}) + # Space-separated + expect_equal(normalize_toast_position("top left"), "top-left") + expect_equal(normalize_toast_position("middle center"), "middle-center") + expect_equal(normalize_toast_position("bottom right"), "bottom-right") -test_that("normalize_toast_position() errors on duplicate components", { - expect_error( - normalize_toast_position("top bottom left"), - "Invalid toast position" - ) - expect_error( - normalize_toast_position("top left right"), - "Invalid toast position" - ) - expect_error( - normalize_toast_position(c("top", "bottom", "left")), - "Invalid toast position" - ) + # Reversed order + expect_equal(normalize_toast_position("left top"), "top-left") + expect_equal(normalize_toast_position("center middle"), "middle-center") + expect_equal(normalize_toast_position("right bottom"), "bottom-right") + + # Vector input + expect_equal(normalize_toast_position(c("top", "left")), "top-left") + expect_equal(normalize_toast_position(c("center", "middle")), "middle-center") + expect_equal(normalize_toast_position(c("bottom", "right")), "bottom-right") }) -test_that("normalize_toast_position() errors on invalid components", { - expect_error( - normalize_toast_position("top invalid"), - "Invalid toast position.+?'top invalid'" - ) - expect_error( - normalize_toast_position("foo bar"), - "Invalid toast position.+?'foo bar'" - ) - expect_error( - normalize_toast_position("top-left-extra"), - "Invalid toast position.+?'top-left-extra'" - ) +test_that("normalize_toast_position() errors on invalid input", { + expect_snapshot(error = TRUE, { + # Missing components + normalize_toast_position("top") + normalize_toast_position("left") + + # Duplicate components + normalize_toast_position("top bottom left") + normalize_toast_position(c("top", "bottom", "left")) + + # Invalid components + normalize_toast_position("top invalid") + normalize_toast_position("foo bar") + }) }) test_that("normalize_toast_position() handles extra whitespace", { @@ -435,7 +388,9 @@ test_that("normalize_toast_position() handles extra whitespace", { ) }) -test_that("toast() works with new position formats", { +# show_toast() and hide_toast() tests ---- + +test_that("toast() works with flexible position formats", { # Space-separated t1 <- toast("Test", position = "top left") expect_equal(t1$position, "top-left") @@ -470,6 +425,24 @@ test_that("show_toast() returns the toast id", { expect_equal(toast_id2, exp_toast_id) }) +test_that("show_toast() converts string to toast automatically", { + local_mocked_bindings( + toast_random_id = function() "bslib-toast-auto" + ) + + message_sent <- FALSE + session <- list(sendCustomMessage = function(type, message) { + expect_equal(type, "bslib.show-toast") + expect_equal(message$id, "bslib-toast-auto") + message_sent <<- TRUE + }) + + # Pass a plain string instead of a toast object + toast_id <- show_toast("Simple message", session = session) + expect_true(message_sent) + expect_equal(toast_id, "bslib-toast-auto") +}) + test_that("show_toast() and hide_toast() warn if nothing to show/hide", { session <- list(sendCustomMessage = function(type, message) { stop("sendCustomMessage should not be called") From 6d93f2485c904aae3f921c4362b6f32d7b72fd14 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 28 Oct 2025 08:29:10 -0400 Subject: [PATCH 55/68] tests: no coverage on print method and that's okay --- R/toast.R | 3 +++ 1 file changed, 3 insertions(+) diff --git a/R/toast.R b/R/toast.R index c302e50c1..6114e5138 100644 --- a/R/toast.R +++ b/R/toast.R @@ -164,14 +164,17 @@ as.tags.bslib_toast <- function(x, ...) { ) } +# nocov start #' @export print.bslib_toast <- function(x, ...) { x_tags <- x + # Add "show" class to make toast visible when printed x_tags$attribs <- c(x_tags$attribs, list(class = "show")) x_tags <- as.tags(x_tags) print(as_fragment(x_tags)) invisible(x) } +# nocov end #' Show or hide a toast notification #' From 3201c13eb6c114786fe8a427c515fefb1dcfa0ca Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 28 Oct 2025 08:29:29 -0400 Subject: [PATCH 56/68] tests: Fill in test for toast header pass-through --- tests/testthat/test-toast.R | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/testthat/test-toast.R b/tests/testthat/test-toast.R index 119332f17..a858f9203 100644 --- a/tests/testthat/test-toast.R +++ b/tests/testthat/test-toast.R @@ -208,7 +208,7 @@ test_that("toast() position is stored correctly", { # toast() header integration tests ---- -test_that("toast with character header", { +test_that("toast header with character", { t <- toast("Body", header = "Simple Header") tag <- as.tags(t) html <- as.character(tag) @@ -218,7 +218,7 @@ test_that("toast with character header", { expect_true(grepl("me-auto", html)) }) -test_that("toast with toast_header() result", { +test_that("toast header with toast_header()", { t <- toast( "Body", header = toast_header("Title", status = "just now") @@ -232,7 +232,7 @@ test_that("toast with toast_header() result", { expect_true(grepl("text-muted", html)) }) -test_that("toast with list(title = ...) pattern", { +test_that("toast header with list(title = ...) pattern", { # Bare list with title should work like toast_header() t <- toast( "Body", @@ -247,6 +247,20 @@ test_that("toast with list(title = ...) pattern", { expect_true(grepl("text-muted", html)) }) +test_that("toast header with custom tag", { + t <- toast( + "Body", + header = div(class = "custom-header", "My Header") + ) + + tag <- as.tags(t) + expect_equal(tag$children[[1]]$attribs$class, "toast-header") + expect_equal( + format(tag$children[[1]]$children[[1]]), + '
My Header
' + ) +}) + test_that("toast header can be modified after creation", { # Create toast with toast_header() t <- toast( From da3d6218ef009731b3e2b31232d4e17c54dba7b9 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 28 Oct 2025 08:37:00 -0400 Subject: [PATCH 57/68] chore(hide_toast): Include ignored dots for consistency with `show_toast()` --- R/toast.R | 4 +++- man/show_toast.Rd | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/R/toast.R b/R/toast.R index 6114e5138..45d8dbdeb 100644 --- a/R/toast.R +++ b/R/toast.R @@ -262,7 +262,9 @@ show_toast <- function( #' @describeIn show_toast Hide a toast notification by ID. #' @export -hide_toast <- function(id, session = shiny::getDefaultReactiveDomain()) { +hide_toast <- function(id, ..., session = shiny::getDefaultReactiveDomain()) { + rlang::check_dots_empty() + if (inherits(id, "bslib_toast")) { if (is.null(id$id)) { rlang::abort("Cannot hide a toast without an ID. Provide the toast ID.") diff --git a/man/show_toast.Rd b/man/show_toast.Rd index 63c06e922..a74a535f2 100644 --- a/man/show_toast.Rd +++ b/man/show_toast.Rd @@ -7,7 +7,7 @@ \usage{ show_toast(toast, ..., session = shiny::getDefaultReactiveDomain()) -hide_toast(id, session = shiny::getDefaultReactiveDomain()) +hide_toast(id, ..., session = shiny::getDefaultReactiveDomain()) } \arguments{ \item{toast}{A \code{\link[=toast]{toast()}}, or a string that will be automatically converted to From 71f5e289f7f7b08bd686f930ece4d6630b896c57 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 28 Oct 2025 10:08:17 -0400 Subject: [PATCH 58/68] chore: bump dev version again For testing a deployment snag with Connect --- DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 93f4b7a56..4182de340 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: bslib Title: Custom 'Bootstrap' 'Sass' Themes for 'shiny' and 'rmarkdown' -Version: 0.9.0.9001 +Version: 0.9.0.9002 Authors@R: c( person("Carson", "Sievert", , "carson@posit.co", role = c("aut", "cre"), comment = c(ORCID = "0000-0002-4958-2844")), From a6f08d05e45c34039261679e168edd18f95b6154 Mon Sep 17 00:00:00 2001 From: gadenbuie Date: Tue, 28 Oct 2025 14:16:26 +0000 Subject: [PATCH 59/68] `yarn build` (GitHub Actions) --- inst/components/dist/components.js | 2 +- inst/components/dist/components.min.js | 2 +- inst/components/dist/web-components.js | 2 +- inst/components/dist/web-components.min.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/inst/components/dist/components.js b/inst/components/dist/components.js index 3fac8f2d1..3e86f9008 100644 --- a/inst/components/dist/components.js +++ b/inst/components/dist/components.js @@ -1,4 +1,4 @@ -/*! bslib 0.9.0.9001 | (c) 2012-2025 RStudio, PBC. | License: MIT + file LICENSE */ +/*! bslib 0.9.0.9002 | (c) 2012-2025 RStudio, PBC. | License: MIT + file LICENSE */ "use strict"; (() => { var __defProp = Object.defineProperty; diff --git a/inst/components/dist/components.min.js b/inst/components/dist/components.min.js index 5f31dcb28..066af53aa 100644 --- a/inst/components/dist/components.min.js +++ b/inst/components/dist/components.min.js @@ -1,4 +1,4 @@ -/*! bslib 0.9.0.9001 | (c) 2012-2025 RStudio, PBC. | License: MIT + file LICENSE */ +/*! bslib 0.9.0.9002 | (c) 2012-2025 RStudio, PBC. | License: MIT + file LICENSE */ "use strict";(()=>{var ve=Object.defineProperty,Ee=Object.defineProperties;var ye=Object.getOwnPropertyDescriptors;var J=Object.getOwnPropertySymbols;var Se=Object.prototype.hasOwnProperty,Le=Object.prototype.propertyIsEnumerable;var Q=(s,e,t)=>e in s?ve(s,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):s[e]=t,ee=(s,e)=>{for(var t in e||(e={}))Se.call(e,t)&&Q(s,t,e[t]);if(J)for(var t of J(e))Le.call(e,t)&&Q(s,t,e[t]);return s},te=(s,e)=>Ee(s,ye(e));var p=(s,e)=>()=>(s&&(e=s(s=0)),e);var Te=(s,e)=>()=>(e||s((e={exports:{}}).exports,e),e.exports);var ie=(s,e,t)=>{if(!e.has(s))throw TypeError("Cannot "+t)};var w=(s,e,t)=>(ie(s,e,"read from private field"),t?t.call(s):e.get(s)),R=(s,e,t)=>{if(e.has(s))throw TypeError("Cannot add the same private member more than once");e instanceof WeakSet?e.add(s):e.set(s,t)};var F=(s,e,t)=>(ie(s,e,"access private method"),t);var u=(s,e,t)=>new Promise((i,n)=>{var r=a=>{try{c(t.next(a))}catch(h){n(h)}},o=a=>{try{c(t.throw(a))}catch(h){n(h)}},c=a=>a.done?i(a.value):Promise.resolve(a.value).then(r,o);c((t=t.apply(s,e)).next())});function E(s,e){b&&b.inputBindings.register(new s,"bslib."+e)}function x(s,e){window.bslib=window.bslib||{},window.bslib[s]?console.error(`[bslib] Global window.bslib.${s} was already defined, using previous definition.`):window.bslib[s]=e}function O({headline:s="",message:e,status:t="warning"}){document.dispatchEvent(new CustomEvent("shiny:client-message",{detail:{headline:s,message:e,status:t}}))}function A(s,e){return Object.prototype.hasOwnProperty.call(s,e)&&s[e]!==void 0}function se(s){let e=["a[href]","area[href]","button","details summary","input","iframe","select","textarea",'[contentEditable=""]','[contentEditable="true"]','[contentEditable="TRUE"]',"[tabindex]"],t=[':not([tabindex="-1"])',":not([disabled])"],i=e.map(r=>r+t.join("")),n=s.querySelectorAll(i.join(", "));return Array.from(n)}function g(...s){return u(this,null,function*(){if(!b)throw new Error("This function must be called in a Shiny app.");return b.renderContentAsync?yield b.renderContentAsync.apply(null,s):yield b.renderContent.apply(null,s)})}function ne(s,e){return u(this,null,function*(){if(typeof s!="undefined"){if(e.length!==1)throw new Error("labelNode must be of length 1");typeof s=="string"&&(s={html:s,deps:[]}),s.html===""?e.addClass("shiny-label-null"):(yield g(e,s),e.removeClass("shiny-label-null"))}})}var b,m,y=p(()=>{"use strict";b=window.Shiny,m=b?b.InputBinding:class{}});var D,re=p(()=>{"use strict";y();D=class extends m{find(e){return $(e).find(".accordion.bslib-accordion-input")}getValue(e){let i=this._getItemInfo(e).filter(n=>n.isOpen()).map(n=>n.value);return i.length===0?null:i}subscribe(e,t){$(e).on("shown.bs.collapse.accordionInputBinding hidden.bs.collapse.accordionInputBinding",function(i){t(!0)})}unsubscribe(e){$(e).off(".accordionInputBinding")}receiveMessage(e,t){return u(this,null,function*(){let i=t.method;if(i==="set")this._setItems(e,t);else if(i==="open")this._openItems(e,t);else if(i==="close")this._closeItems(e,t);else if(i==="remove")this._removeItem(e,t);else if(i==="insert")yield this._insertItem(e,t);else if(i==="update")yield this._updateItem(e,t);else throw new Error(`Method not yet implemented: ${i}`)})}_setItems(e,t){let i=this._getItemInfo(e),n=this._getValues(e,i,t.values);i.forEach(r=>{n.indexOf(r.value)>-1?r.show():r.hide()})}_openItems(e,t){let i=this._getItemInfo(e),n=this._getValues(e,i,t.values);i.forEach(r=>{n.indexOf(r.value)>-1&&r.show()})}_closeItems(e,t){let i=this._getItemInfo(e),n=this._getValues(e,i,t.values);i.forEach(r=>{n.indexOf(r.value)>-1&&r.hide()})}_insertItem(e,t){return u(this,null,function*(){let i=this._findItem(e,t.target);i||(i=t.position==="before"?e.firstElementChild:e.lastElementChild);let n=t.panel;if(i?yield g(i,n,t.position==="before"?"beforeBegin":"afterEnd"):yield g(e,n),this._isAutoClosing(e)){let r=$(n.html).attr("data-value");$(e).find(`[data-value="${r}"] .accordion-collapse`).attr("data-bs-parent","#"+e.id)}})}_removeItem(e,t){var r;let i=this._getItemInfo(e).filter(o=>t.target.indexOf(o.value)>-1),n=(r=window.Shiny)==null?void 0:r.unbindAll;i.forEach(o=>{n&&n(o.item),o.item.remove()})}_updateItem(e,t){return u(this,null,function*(){let i=this._findItem(e,t.target);if(!i)throw new Error(`Unable to find an accordion_panel() with a value of ${t.target}`);if(A(t,"value")&&(i.dataset.value=t.value),A(t,"body")){let r=i.querySelector(".accordion-body");yield g(r,t.body)}let n=i.querySelector(".accordion-header");if(A(t,"title")){let r=n.querySelector(".accordion-title");yield g(r,t.title)}if(A(t,"icon")){let r=n.querySelector(".accordion-button > .accordion-icon");yield g(r,t.icon)}})}_getItemInfo(e){return Array.from(e.querySelectorAll(":scope > .accordion-item")).map(i=>this._getSingleItemInfo(i))}_getSingleItemInfo(e){let t=e.querySelector(".accordion-collapse"),i=()=>$(t).hasClass("show");return{item:e,value:e.dataset.value,isOpen:i,show:()=>{i()||$(t).collapse("show")},hide:()=>{i()&&$(t).collapse("hide")}}}_getValues(e,t,i){let n=i!==!0?i:t.map(o=>o.value);return this._isAutoClosing(e)&&(n=n.slice(n.length-1,n.length)),n}_findItem(e,t){return e.querySelector(`[data-value="${t}"]`)}_isAutoClosing(e){return e.classList.contains("autoclose")}};E(D,"accordion")});var H,W=p(()=>{"use strict";H=class{constructor(){this.resizeObserverEntries=[],this.resizeObserver=new ResizeObserver(e=>{let t=new Event("resize");if(window.dispatchEvent(t),!window.Shiny)return;let i=[];for(let n of e)n.target instanceof HTMLElement&&n.target.querySelector(".shiny-bound-output")&&n.target.querySelectorAll(".shiny-bound-output").forEach(r=>{if(i.includes(r))return;let{binding:o,onResize:c}=$(r).data("shinyOutputBinding");if(!o||!o.resize)return;let a=r.shinyResizeObserver;if(a&&a!==this||(a||(r.shinyResizeObserver=this),c(r),i.push(r),!r.classList.contains("shiny-plot-output")))return;let h=r.querySelector('img:not([width="100%"])');h&&h.setAttribute("width","100%")})})}observe(e){this.resizeObserver.observe(e),this.resizeObserverEntries.push(e)}unobserve(e){let t=this.resizeObserverEntries.indexOf(e);t<0||(this.resizeObserver.unobserve(e),this.resizeObserverEntries.splice(t,1))}flush(){this.resizeObserverEntries.forEach(e=>{document.body.contains(e)||this.unobserve(e)})}}});var k,oe=p(()=>{"use strict";k=class{constructor(e,t){this.watching=new Set,this.observer=new MutationObserver(i=>{let n=new Set;for(let{type:r,removedNodes:o}of i)if(r==="childList"&&o.length!==0)for(let c of o)c instanceof HTMLElement&&(c.matches(e)&&n.add(c),c.querySelector(e)&&c.querySelectorAll(e).forEach(a=>n.add(a)));if(n.size!==0)for(let r of n)try{t(r)}catch(o){console.error(o)}})}observe(e){let t=this._flush();if(this.watching.has(e)){if(!t)return}else this.watching.add(e);t?this._restartObserver():this.observer.observe(e,{childList:!0,subtree:!0})}unobserve(e){this.watching.has(e)&&(this.watching.delete(e),this._flush(),this._restartObserver())}_restartObserver(){this.observer.disconnect();for(let e of this.watching)this.observer.observe(e,{childList:!0,subtree:!0})}_flush(){let e=!1,t=Array.from(this.watching);for(let i of t)document.body.contains(i)||(this.watching.delete(i),e=!0);return e}}});var l,S,ae=p(()=>{"use strict";y();W();oe();l=class{constructor(e){var t;e.removeAttribute(l.attr.ATTR_INIT),(t=e.querySelector(`script[${l.attr.ATTR_INIT}]`))==null||t.remove(),this.card=e,l.instanceMap.set(e,this),l.shinyResizeObserver.observe(this.card),l.cardRemovedObserver.observe(document.body),this._addEventListeners(),this.overlay=this._createOverlay(),this._setShinyInput(),this._exitFullScreenOnEscape=this._exitFullScreenOnEscape.bind(this),this._trapFocusExit=this._trapFocusExit.bind(this)}enterFullScreen(e){var t;e&&e.preventDefault(),this.card.id&&this.overlay.anchor.setAttribute("aria-controls",this.card.id),document.addEventListener("keydown",this._exitFullScreenOnEscape,!1),document.addEventListener("keydown",this._trapFocusExit,!0),this.card.setAttribute(l.attr.ATTR_FULL_SCREEN,"true"),document.body.classList.add(l.attr.CLASS_HAS_FULL_SCREEN),this.card.insertAdjacentElement("beforebegin",this.overlay.container),(!this.card.contains(document.activeElement)||(t=document.activeElement)!=null&&t.classList.contains(l.attr.CLASS_FULL_SCREEN_ENTER))&&(this.card.setAttribute("tabindex","-1"),this.card.focus()),this._emitFullScreenEvent(!0),this._setShinyInput()}exitFullScreen(){document.removeEventListener("keydown",this._exitFullScreenOnEscape,!1),document.removeEventListener("keydown",this._trapFocusExit,!0),this.overlay.container.remove(),this.card.setAttribute(l.attr.ATTR_FULL_SCREEN,"false"),this.card.removeAttribute("tabindex"),document.body.classList.remove(l.attr.CLASS_HAS_FULL_SCREEN),this._emitFullScreenEvent(!1),this._setShinyInput()}_setShinyInput(){if(!this.card.classList.contains(l.attr.CLASS_SHINY_INPUT)||!b)return;if(!b.setInputValue){setTimeout(()=>this._setShinyInput(),0);return}let e=this.card.getAttribute(l.attr.ATTR_FULL_SCREEN);b.setInputValue(this.card.id+"_full_screen",e==="true")}_emitFullScreenEvent(e){let t=new CustomEvent("bslib.card",{bubbles:!0,detail:{fullScreen:e}});this.card.dispatchEvent(t)}_addEventListeners(){let e=this.card.querySelector(`:scope > * > .${l.attr.CLASS_FULL_SCREEN_ENTER}`);e&&e.addEventListener("click",t=>this.enterFullScreen(t))}_exitFullScreenOnEscape(e){if(!(e.target instanceof HTMLElement))return;let t=["select[open]","input[aria-expanded='true']"];e.target.matches(t.join(", "))||e.key==="Escape"&&this.exitFullScreen()}_trapFocusExit(e){if(!(e instanceof KeyboardEvent)||e.key!=="Tab")return;let t=e.target===this.card,i=e.target===this.overlay.anchor,n=this.card.contains(e.target),r=()=>{e.preventDefault(),e.stopImmediatePropagation()};if(!(n||t||i)){r(),this.card.focus();return}let o=se(this.card).filter(v=>!v.classList.contains(l.attr.CLASS_FULL_SCREEN_ENTER));if(!(o.length>0)){r(),this.overlay.anchor.focus();return}if(t)return;let a=o[o.length-1],h=e.target===a;if(i&&e.shiftKey){r(),a.focus();return}if(h&&!e.shiftKey){r(),this.overlay.anchor.focus();return}}_createOverlay(){let e=document.createElement("div");e.id=l.attr.ID_FULL_SCREEN_OVERLAY,e.onclick=this.exitFullScreen.bind(this);let t=this._createOverlayCloseAnchor();return e.appendChild(t),{container:e,anchor:t}}_createOverlayCloseAnchor(){let e=document.createElement("a");return e.classList.add(l.attr.CLASS_FULL_SCREEN_EXIT),e.tabIndex=0,e.setAttribute("aria-expanded","true"),e.setAttribute("aria-label","Close card"),e.setAttribute("role","button"),e.onclick=t=>{this.exitFullScreen(),t.stopPropagation()},e.onkeydown=t=>{(t.key==="Enter"||t.key===" ")&&this.exitFullScreen()},e.innerHTML=this._overlayCloseHtml(),e}_overlayCloseHtml(){return"Close "}static getInstance(e){return l.instanceMap.get(e)}static initializeAllCards(e=!0){if(document.readyState==="loading"){l.onReadyScheduled||(l.onReadyScheduled=!0,document.addEventListener("DOMContentLoaded",()=>{l.initializeAllCards(!1)}));return}e&&l.shinyResizeObserver.flush();let t=`.${l.attr.CLASS_CARD}[${l.attr.ATTR_INIT}]`;if(!document.querySelector(t))return;document.querySelectorAll(t).forEach(n=>new l(n))}},S=l;S.attr={ATTR_INIT:"data-bslib-card-init",CLASS_CARD:"bslib-card",ATTR_FULL_SCREEN:"data-full-screen",CLASS_HAS_FULL_SCREEN:"bslib-has-full-screen",CLASS_FULL_SCREEN_ENTER:"bslib-full-screen-enter",CLASS_FULL_SCREEN_EXIT:"bslib-full-screen-exit",ID_FULL_SCREEN_OVERLAY:"bslib-full-screen-overlay",CLASS_SHINY_INPUT:"bslib-card-input"},S.shinyResizeObserver=new H,S.cardRemovedObserver=new k(`.${l.attr.CLASS_CARD}`,e=>{let t=l.getInstance(e);t&&t.card.getAttribute(l.attr.ATTR_FULL_SCREEN)==="true"&&t.exitFullScreen()}),S.instanceMap=new WeakMap,S.onReadyScheduled=!1;x("Card",S)});function le(s,e){let t=s();return()=>{let i=s();i!==t&&e(),t=i}}var d,f,P,de=p(()=>{"use strict";y();W();d=class{constructor(e){this.resizeState={isResizing:!1,startX:0,startWidth:0,minWidth:150,maxWidth:()=>window.innerWidth-50,constrainedWidth:e=>Math.max(this.resizeState.minWidth,Math.min(this.resizeState.maxWidth(),e))};this.windowSize="";var n;d.instanceMap.set(e,this),this.layout={container:e,main:e.querySelector(":scope > .main"),sidebar:e.querySelector(":scope > .sidebar"),toggle:e.querySelector(":scope > .collapse-toggle")};let t=this.layout.sidebar.querySelector(":scope > .sidebar-content > .accordion");t&&((n=t==null?void 0:t.parentElement)==null||n.classList.add("has-accordion"),t.classList.add("accordion-flush")),this._initSidebarCounters(),this._initSidebarState(),(this._isCollapsible("desktop")||this._isCollapsible("mobile"))&&this._initEventListeners(),this._initResizeHandle(),d.shinyResizeObserver.observe(this.layout.main),e.removeAttribute("data-bslib-sidebar-init");let i=e.querySelector(":scope > script[data-bslib-sidebar-init]");i&&e.removeChild(i)}get isClosed(){return this.layout.container.classList.contains(d.classes.COLLAPSE)}static getInstance(e){return d.instanceMap.get(e)}_isCollapsible(e="desktop"){let{container:t}=this.layout,i=e==="desktop"?"collapsibleDesktop":"collapsibleMobile",n=t.dataset[i];return n===void 0?!0:n.trim().toLowerCase()!=="false"}static initCollapsibleAll(e=!0){if(document.readyState==="loading"){d.onReadyScheduled||(d.onReadyScheduled=!0,document.addEventListener("DOMContentLoaded",()=>{d.initCollapsibleAll(!1)}));return}let t=`.${d.classes.LAYOUT}[data-bslib-sidebar-init]`;if(!document.querySelector(t))return;e&&d.shinyResizeObserver.flush(),document.querySelectorAll(t).forEach(n=>new d(n))}_initResizeHandle(){if(!this.layout.resizeHandle){let e=this._createResizeHandle();this.layout.container.appendChild(e),this.layout.resizeHandle=e,this._attachResizeEventListeners(e)}this._updateResizeAvailability()}_createResizeHandle(){let e=document.createElement("div");e.className=d.classes.RESIZE_HANDLE,e.setAttribute("role","separator"),e.setAttribute("aria-orientation","vertical"),e.setAttribute("aria-label","Resize sidebar"),e.setAttribute("tabindex","0"),e.setAttribute("aria-keyshortcuts","ArrowLeft ArrowRight Home End"),e.title="Drag to resize sidebar";let t=document.createElement("div");t.className="resize-indicator",e.appendChild(t);let i=document.createElement("div");return i.className="visually-hidden",i.textContent="Use arrow keys to resize the sidebar, Shift for larger steps, Home/End for min/max width.",e.appendChild(i),e}_attachResizeEventListeners(e){e.addEventListener("mousedown",this._onResizeStart.bind(this)),document.addEventListener("mousemove",this._onResizeMove.bind(this)),document.addEventListener("mouseup",this._onResizeEnd.bind(this)),e.addEventListener("touchstart",this._onResizeStart.bind(this),{passive:!1}),document.addEventListener("touchmove",this._onResizeMove.bind(this),{passive:!1}),document.addEventListener("touchend",this._onResizeEnd.bind(this)),e.addEventListener("keydown",this._onResizeKeyDown.bind(this)),window.addEventListener("resize",le(()=>this._getWindowSize(),()=>this._updateResizeAvailability()))}_shouldEnableResize(){let e=this._getWindowSize()==="desktop",t=!this.layout.container.classList.contains(d.classes.TRANSITIONING),i=!this.isClosed;return e&&t&&i}_onResizeStart(e){if(!this._shouldEnableResize())return;e.preventDefault();let t="touches"in e?e.touches[0].clientX:e.clientX;this.resizeState.isResizing=!0,this.resizeState.startX=t,this.resizeState.startWidth=this._getCurrentSidebarWidth(),this.layout.container.style.setProperty("--_transition-duration","0ms"),this.layout.container.classList.add(d.classes.RESIZING),document.documentElement.setAttribute(`data-bslib-${d.classes.RESIZING}`,"true"),this._dispatchResizeEvent("start",this.resizeState.startWidth)}_onResizeMove(e){if(!this.resizeState.isResizing)return;e.preventDefault();let i=("touches"in e?e.touches[0].clientX:e.clientX)-this.resizeState.startX,r=this._isRightSidebar()?this.resizeState.startWidth-i:this.resizeState.startWidth+i,o=this.resizeState.constrainedWidth(r);this._updateSidebarWidth(o),this._dispatchResizeEvent("move",o)}_onResizeEnd(){this.resizeState.isResizing&&(this.resizeState.isResizing=!1,this.layout.container.style.removeProperty("--_transition-duration"),this.layout.container.classList.remove(d.classes.RESIZING),document.documentElement.removeAttribute(`data-bslib-${d.classes.RESIZING}`),d.shinyResizeObserver.flush(),this._dispatchResizeEvent("end",this._getCurrentSidebarWidth()))}_onResizeKeyDown(e){if(!this._shouldEnableResize())return;let t=e.shiftKey?50:10,i=this._getCurrentSidebarWidth();switch(e.key){case"ArrowLeft":i=this._isRightSidebar()?i+t:i-t;break;case"ArrowRight":i=this._isRightSidebar()?i-t:i+t;break;case"Home":i=this.resizeState.minWidth;break;case"End":i=this.resizeState.maxWidth();break;default:return}e.preventDefault(),i=this.resizeState.constrainedWidth(i),this._updateSidebarWidth(i),d.shinyResizeObserver.flush(),this._dispatchResizeEvent("keyboard",i)}_getCurrentSidebarWidth(){return this.layout.sidebar.getBoundingClientRect().width||250}_updateSidebarWidth(e){let{container:t,resizeHandle:i}=this.layout;t.style.setProperty("--_sidebar-width",`${e}px`),i&&(i.setAttribute("aria-valuenow",e.toString()),i.setAttribute("aria-valuemin",this.resizeState.minWidth.toString()),i.setAttribute("aria-valuemax",this.resizeState.maxWidth().toString()))}_isRightSidebar(){return this.layout.container.classList.contains("sidebar-right")}_updateResizeAvailability(){if(!this.layout.resizeHandle)return;let e=this._shouldEnableResize();this.layout.resizeHandle.style.display=e?"":"none",this.layout.resizeHandle.setAttribute("aria-hidden",e?"false":"true"),e?this.layout.resizeHandle.setAttribute("tabindex","0"):this.layout.resizeHandle.removeAttribute("tabindex")}_dispatchResizeEvent(e,t){let i=new CustomEvent("bslib.sidebar.resize",{bubbles:!0,detail:{phase:e,width:t,sidebar:this}});this.layout.sidebar.dispatchEvent(i)}_initEventListeners(){var t;let{toggle:e}=this.layout;e.addEventListener("click",i=>{i.preventDefault(),this.toggle("toggle")}),(t=e.querySelector(".collapse-icon"))==null||t.addEventListener("transitionend",()=>{this._finalizeState()}),!(this._isCollapsible("desktop")&&this._isCollapsible("mobile"))&&window.addEventListener("resize",le(()=>this._getWindowSize(),()=>this._initSidebarState()))}_initSidebarCounters(){let{container:e}=this.layout,t=`.${d.classes.LAYOUT}> .main > .${d.classes.LAYOUT}:not([data-bslib-sidebar-open="always"])`;if(!(e.querySelector(t)===null))return;function n(a){return a=a?a.parentElement:null,a&&a.classList.contains("main")&&(a=a.parentElement),a&&a.classList.contains(d.classes.LAYOUT)?a:null}let r=[e],o=n(e);for(;o;)r.unshift(o),o=n(o);let c={left:0,right:0};r.forEach(function(a){let v=a.classList.contains("sidebar-right")?c.right++:c.left++;a.style.setProperty("--_js-toggle-count-this-side",v.toString()),a.style.setProperty("--_js-toggle-count-max-side",Math.max(c.right,c.left).toString())})}_getWindowSize(){let{container:e}=this.layout;return window.getComputedStyle(e).getPropertyValue("--bslib-sidebar-js-window-size").trim()}_initialToggleState(){var n,r;let{container:e}=this.layout,t=this.windowSize==="desktop"?"openDesktop":"openMobile",i=(r=(n=e.dataset[t])==null?void 0:n.trim())==null?void 0:r.toLowerCase();return i===void 0||["open","always"].includes(i)?"open":["close","closed"].includes(i)?"close":"open"}_initSidebarState(){this.windowSize=this._getWindowSize();let e=this._initialToggleState();this.toggle(e,!0)}toggle(e,t=!1){typeof e=="undefined"?e="toggle":e==="closed"&&(e="close");let{container:i,sidebar:n}=this.layout,r=this.isClosed;if(["open","close","toggle"].indexOf(e)===-1)throw new Error(`Unknown method ${e}`);if(e==="toggle"&&(e=r?"open":"close"),r&&e==="close"||!r&&e==="open"){t&&this._finalizeState();return}e==="open"&&(n.hidden=!1),i.classList.toggle(d.classes.TRANSITIONING,!t),i.classList.toggle(d.classes.COLLAPSE),t&&this._finalizeState()}_finalizeState(){let{container:e,sidebar:t,toggle:i}=this.layout;e.classList.remove(d.classes.TRANSITIONING),t.hidden=this.isClosed,i.setAttribute("aria-expanded",this.isClosed?"false":"true"),this._updateResizeAvailability();let n=new CustomEvent("bslib.sidebar",{bubbles:!0,detail:{open:!this.isClosed}});t.dispatchEvent(n),$(t).trigger("toggleCollapse.sidebarInputBinding"),$(t).trigger(this.isClosed?"hidden":"shown")}},f=d;f.shinyResizeObserver=new H,f.classes={LAYOUT:"bslib-sidebar-layout",COLLAPSE:"sidebar-collapsed",TRANSITIONING:"transitioning",RESIZE_HANDLE:"bslib-sidebar-resize-handle",RESIZING:"sidebar-resizing"},f.onReadyScheduled=!1,f.instanceMap=new WeakMap;P=class extends m{find(e){return $(e).find(`.${f.classes.LAYOUT} > .bslib-sidebar-input`)}getValue(e){let t=f.getInstance(e.parentElement);return t?!t.isClosed:!1}setValue(e,t){let i=t?"open":"close";this.receiveMessage(e,{method:i})}subscribe(e,t){$(e).on("toggleCollapse.sidebarInputBinding",function(i){t(!0)})}unsubscribe(e){$(e).off(".sidebarInputBinding")}receiveMessage(e,t){let i=f.getInstance(e.parentElement);i&&i.toggle(t.method)}};E(P,"sidebar");x("Sidebar",f)});var _,C,I,q,U,ce=p(()=>{"use strict";y();U=class extends m{constructor(){super(...arguments);R(this,I);R(this,_,new WeakMap);R(this,C,new WeakMap)}find(t){return $(t).find(".bslib-task-button")}getValue(t){var i;return{value:(i=w(this,_).get(t))!=null?i:0,autoReset:t.hasAttribute("data-auto-reset")}}getType(){return"bslib.taskbutton"}subscribe(t,i){w(this,C).has(t)&&this.unsubscribe(t);let n=()=>{var r;w(this,_).set(t,((r=w(this,_).get(t))!=null?r:0)+1),i(!0),F(this,I,q).call(this,t,"busy")};w(this,C).set(t,n),t.addEventListener("click",n)}unsubscribe(t){let i=w(this,C).get(t);i&&t.removeEventListener("click",i)}receiveMessage(n,r){return u(this,arguments,function*(t,{state:i}){F(this,I,q).call(this,t,i)})}};_=new WeakMap,C=new WeakMap,I=new WeakSet,q=function(t,i){t.disabled=i==="busy";let n=t.querySelector("bslib-switch-inline");n&&(n.case=i)};E(U,"task-button")});function he(s){let e=N(s),t=!s.value;e.classList.toggle("disabled",t),e.setAttribute("aria-disabled",t.toString()),t?e.setAttribute("tabindex","-1"):e.removeAttribute("tabindex")}function K(s){s.scrollHeight!==0&&(s.style.height="auto",s.style.height=s.scrollHeight+"px")}function Me(s){if(!s.hasAttribute("data-needs-modifier"))return;let e=N(s);if(!e.querySelector(`.${T.submitKey}`))return;let t=navigator.userAgent.indexOf("Mac")!==-1;e.querySelectorAll(`.${T.submitKey}`).forEach(r=>{let o=t?"\u2318":"Ctrl";r.textContent=`${o} \u23CE`});let i=t?"Command":"Ctrl";e.title=e.title.replace("Press Enter",`Press ${i}+Enter`);let n=e.getAttribute("aria-label");n&&e.setAttribute("aria-label",n.replace("Press Enter",`Press ${i}+Enter`))}function N(s){var t;let e=(t=s.parentElement)==null?void 0:t.querySelector(`.${T.button}`);if(e instanceof HTMLButtonElement)return e;throw new Error("Expected input_submit_textarea()'s container to have a button with class of 'bslib-submit-textarea-btn'")}function we(s){let e=s.selectionStart,t=s.selectionEnd;s.value=s.value.substring(0,e)+` `+s.value.substring(t),s.selectionStart=s.selectionEnd=e+1,s.dispatchEvent(new Event("input",{bubbles:!0}))}var L,T,ue,V,be=p(()=>{"use strict";y();L="textSubmitInputBinding",T={input:"bslib-input-submit-textarea",container:"bslib-submit-textarea-container",button:"bslib-submit-textarea-btn",submitKey:"bslib-submit-key"},ue=new IntersectionObserver(s=>{s.forEach(e=>{e.isIntersecting&&K(e.target)})}),V=class extends m{find(e){return $(e).find(`.${T.input} textarea`)}initialize(e){he(e),K(e),Me(e)}getValue(e){return $(e).data("val")}setValue(e,t){e.value=t}subscribe(e,t){function i(){$(e).data("val",e.value),e.value="",e.dispatchEvent(new Event("input",{bubbles:!0})),t("event")}let n=N(e);n.classList.contains("shiny-bound-input")?$(n).on(`shiny:inputchanged.${L}`,i):$(n).on(`click.${L}`,i),$(e).on(`input.${L}`,function(){he(e),K(e)}),$(e).on(`keydown.${L}`,function(o){if(o.key!=="Enter")return;if(!e.value){o.preventDefault();return}if(o.shiftKey)return;if(o.altKey){o.preventDefault(),we(e);return}let c=e.hasAttribute("data-needs-modifier");if(!c){o.preventDefault(),n.click();return}let a=o.ctrlKey||o.metaKey;if(c&&a){o.preventDefault(),n.click();return}});let r=e.closest(`.${T.container}`);$(r).on(`click.${L}`,o=>{o.target.classList.contains(T.container)&&e.focus()}),ue.observe(e)}unsubscribe(e){$(e).off(`.${L}`);let t=e.nextElementSibling;$(t).off(`.${L}`);let i=e.closest(`.${T.container}`);$(i).off(`.${L}`),ue.unobserve(e)}receiveMessage(e,t){return u(this,null,function*(){let i=e.value;if(t.value!==void 0&&(e.value=t.value,e.dispatchEvent(new Event("input",{bubbles:!0}))),t.placeholder!==void 0&&(e.placeholder=t.placeholder),t.label!==void 0){let n=$(e).closest(`.${T.input}`).find("label");yield ne(t.label,n)}t.submit&&(N(e).click(),e.value=i),t.focus&&e.focus()})}};E(V,"submit-text-area")});function B(s){if(window.Shiny)for(let[e,t]of Object.entries(s))window.Shiny.addCustomMessageHandler(e,t)}var X=p(()=>{"use strict"});function _e(s){return u(this,null,function*(){var v,Y;let{html:e,deps:t,options:i,position:n,id:r}=s;if(!window.bootstrap||!window.bootstrap.Toast){O({headline:"Bootstrap 5 Required",message:"Toast notifications require Bootstrap 5.",status:"error"});return}let o=document.getElementById(r);if(o){let M=z.get(o);M&&(M.hide(),z.delete(o)),(Y=(v=window==null?void 0:window.Shiny)==null?void 0:v.unbindAll)==null||Y.call(v,o),o.remove()}let c=He.getOrCreateContainer(n);yield g(c,{html:e,deps:t},"beforeEnd");let a=document.getElementById(r);if(!a){O({headline:"Toast Creation Failed",message:`Failed to create toast with id "${r}".`,status:"error"});return}let h=new j(a,i);z.set(a,h),h.show(),a.addEventListener("hidden.bs.toast",()=>{var M,Z;(Z=(M=window==null?void 0:window.Shiny)==null?void 0:M.unbindAll)==null||Z.call(M,a),a.remove(),z.delete(a),c.children.length===0&&c.remove()})})}function Ce(s){let{id:e}=s,t=document.getElementById(e);if(!t){O({headline:"Toast Not Found",message:`No toast with id "${e}" was found.`,status:"warning"});return}let i=z.get(t);i&&i.hide()}var pe,G,He,j,z,me=p(()=>{"use strict";X();y();pe=window.bootstrap?window.bootstrap.Toast:class{},G=class{constructor(){this.containers=new Map}getOrCreateContainer(e){let t=this.containers.get(e);return(!t||!document.body.contains(t))&&(t=this._createContainer(e),this.containers.set(e,t)),t}_createContainer(e){let t=document.createElement("div");t.className="toast-container position-fixed p-1 p-md-2",t.setAttribute("data-bslib-toast-container",e);let i=this._getPositionClasses(e);return t.classList.add(...i),document.body.appendChild(t),t}_getPositionClasses(e){return{"top-left":["top-0","start-0"],"top-center":["top-0","start-50","translate-middle-x"],"top-right":["top-0","end-0"],"middle-left":["top-50","start-0","translate-middle-y"],"middle-center":["top-50","start-50","translate-middle"],"middle-right":["top-50","end-0","translate-middle-y"],"bottom-left":["bottom-0","start-0"],"bottom-center":["bottom-0","start-50","translate-middle-x"],"bottom-right":["bottom-0","end-0"]}[e]}},He=new G,j=class{constructor(e,t){this.progressBar=null;this.startTime=0;this.duration=0;this.hideTimeoutId=null;if(this.element=e,t.autohide){let i=t.delay||5e3;this.duration=i,this._addProgressBar(i);let n=te(ee({},t),{autohide:!1});this.bsToast=new pe(e,n),this._setupHoverPause()}else this.bsToast=new pe(e,t)}show(){this.bsToast.show()}hide(){this.hideTimeoutId!==null&&(clearTimeout(this.hideTimeoutId),this.hideTimeoutId=null),this.bsToast.hide()}_addProgressBar(e){this.progressBar=document.createElement("div"),this.progressBar.className="bslib-toast-progress-bar",this.progressBar.style.cssText=` animation: bslib-toast-progress ${e}ms linear forwards; diff --git a/inst/components/dist/web-components.js b/inst/components/dist/web-components.js index da13091b1..d3102d902 100644 --- a/inst/components/dist/web-components.js +++ b/inst/components/dist/web-components.js @@ -1,4 +1,4 @@ -/*! bslib 0.9.0.9001 | (c) 2012-2025 RStudio, PBC. | License: MIT + file LICENSE */ +/*! bslib 0.9.0.9002 | (c) 2012-2025 RStudio, PBC. | License: MIT + file LICENSE */ "use strict"; (() => { var __defProp = Object.defineProperty; diff --git a/inst/components/dist/web-components.min.js b/inst/components/dist/web-components.min.js index 000f66100..fa7691f97 100644 --- a/inst/components/dist/web-components.min.js +++ b/inst/components/dist/web-components.min.js @@ -1,4 +1,4 @@ -/*! bslib 0.9.0.9001 | (c) 2012-2025 RStudio, PBC. | License: MIT + file LICENSE */ +/*! bslib 0.9.0.9002 | (c) 2012-2025 RStudio, PBC. | License: MIT + file LICENSE */ "use strict";(()=>{var He=Object.defineProperty,Ze=Object.defineProperties,et=Object.getOwnPropertyDescriptor,tt=Object.getOwnPropertyDescriptors;var xe=Object.getOwnPropertySymbols;var it=Object.prototype.hasOwnProperty,st=Object.prototype.propertyIsEnumerable;var Me=(n,t,e)=>t in n?He(n,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):n[t]=e,k=(n,t)=>{for(var e in t||(t={}))it.call(t,e)&&Me(n,e,t[e]);if(xe)for(var e of xe(t))st.call(t,e)&&Me(n,e,t[e]);return n},Q=(n,t)=>Ze(n,tt(t));var y=(n,t,e,i)=>{for(var s=i>1?void 0:i?et(t,e):t,r=n.length-1,o;r>=0;r--)(o=n[r])&&(s=(i?o(t,e,s):o(s))||s);return i&&s&&He(t,e,s),s};var oe=(n,t,e)=>new Promise((i,s)=>{var r=a=>{try{c(e.next(a))}catch(l){s(l)}},o=a=>{try{c(e.throw(a))}catch(l){s(l)}},c=a=>a.done?i(a.value):Promise.resolve(a.value).then(r,o);c((e=e.apply(n,t)).next())});var nt=(n,t)=>t.kind==="method"&&t.descriptor&&!("value"in t.descriptor)?Q(k({},t),{finisher(e){e.createProperty(t.key,n)}}):{kind:"field",key:Symbol(),placement:"own",descriptor:{},originalKey:t.key,initializer(){typeof t.initializer=="function"&&(this[t.key]=t.initializer.call(this))},finisher(e){e.createProperty(t.key,n)}},rt=(n,t,e)=>{t.constructor.createProperty(e,n)};function g(n){return(t,e)=>e!==void 0?rt(n,t,e):nt(n,t)}var ae,Nt=((ae=window.HTMLSlotElement)===null||ae===void 0?void 0:ae.prototype.assignedElements)!=null?(n,t)=>n.assignedElements(t):(n,t)=>n.assignedNodes(t).filter(e=>e.nodeType===Node.ELEMENT_NODE);var X=window,Y=X.ShadowRoot&&(X.ShadyCSS===void 0||X.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,le=Symbol(),Le=new WeakMap,j=class{constructor(t,e,i){if(this._$cssResult$=!0,i!==le)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o,e=this.t;if(Y&&t===void 0){let i=e!==void 0&&e.length===1;i&&(t=Le.get(e)),t===void 0&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),i&&Le.set(e,t))}return t}toString(){return this.cssText}},Oe=n=>new j(typeof n=="string"?n:n+"",void 0,le),b=(n,...t)=>{let e=n.length===1?n[0]:t.reduce((i,s,r)=>i+(o=>{if(o._$cssResult$===!0)return o.cssText;if(typeof o=="number")return o;throw Error("Value passed to 'css' function must be a 'css' function result: "+o+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+n[r+1],n[0]);return new j(e,n,le)},he=(n,t)=>{Y?n.adoptedStyleSheets=t.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet):t.forEach(e=>{let i=document.createElement("style"),s=X.litNonce;s!==void 0&&i.setAttribute("nonce",s),i.textContent=e.cssText,n.appendChild(i)})},Z=Y?n=>n:n=>n instanceof CSSStyleSheet?(t=>{let e="";for(let i of t.cssRules)e+=i.cssText;return Oe(e)})(n):n;var de,ee=window,Pe=ee.trustedTypes,ot=Pe?Pe.emptyScript:"",Ne=ee.reactiveElementPolyfillSupport,pe={toAttribute(n,t){switch(t){case Boolean:n=n?ot:null;break;case Object:case Array:n=n==null?n:JSON.stringify(n)}return n},fromAttribute(n,t){let e=n;switch(t){case Boolean:e=n!==null;break;case Number:e=n===null?null:Number(n);break;case Object:case Array:try{e=JSON.parse(n)}catch(i){e=null}}return e}},ze=(n,t)=>t!==n&&(t==t||n==n),ce={attribute:!0,type:String,converter:pe,reflect:!1,hasChanged:ze},ue="finalized",E=class extends HTMLElement{constructor(){super(),this._$Ei=new Map,this.isUpdatePending=!1,this.hasUpdated=!1,this._$El=null,this.u()}static addInitializer(t){var e;this.finalize(),((e=this.h)!==null&&e!==void 0?e:this.h=[]).push(t)}static get observedAttributes(){this.finalize();let t=[];return this.elementProperties.forEach((e,i)=>{let s=this._$Ep(i,e);s!==void 0&&(this._$Ev.set(s,i),t.push(s))}),t}static createProperty(t,e=ce){if(e.state&&(e.attribute=!1),this.finalize(),this.elementProperties.set(t,e),!e.noAccessor&&!this.prototype.hasOwnProperty(t)){let i=typeof t=="symbol"?Symbol():"__"+t,s=this.getPropertyDescriptor(t,i,e);s!==void 0&&Object.defineProperty(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){return{get(){return this[e]},set(s){let r=this[t];this[e]=s,this.requestUpdate(t,r,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)||ce}static finalize(){if(this.hasOwnProperty(ue))return!1;this[ue]=!0;let t=Object.getPrototypeOf(this);if(t.finalize(),t.h!==void 0&&(this.h=[...t.h]),this.elementProperties=new Map(t.elementProperties),this._$Ev=new Map,this.hasOwnProperty("properties")){let e=this.properties,i=[...Object.getOwnPropertyNames(e),...Object.getOwnPropertySymbols(e)];for(let s of i)this.createProperty(s,e[s])}return this.elementStyles=this.finalizeStyles(this.styles),!0}static finalizeStyles(t){let e=[];if(Array.isArray(t)){let i=new Set(t.flat(1/0).reverse());for(let s of i)e.unshift(Z(s))}else t!==void 0&&e.push(Z(t));return e}static _$Ep(t,e){let i=e.attribute;return i===!1?void 0:typeof i=="string"?i:typeof t=="string"?t.toLowerCase():void 0}u(){var t;this._$E_=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$Eg(),this.requestUpdate(),(t=this.constructor.h)===null||t===void 0||t.forEach(e=>e(this))}addController(t){var e,i;((e=this._$ES)!==null&&e!==void 0?e:this._$ES=[]).push(t),this.renderRoot!==void 0&&this.isConnected&&((i=t.hostConnected)===null||i===void 0||i.call(t))}removeController(t){var e;(e=this._$ES)===null||e===void 0||e.splice(this._$ES.indexOf(t)>>>0,1)}_$Eg(){this.constructor.elementProperties.forEach((t,e)=>{this.hasOwnProperty(e)&&(this._$Ei.set(e,this[e]),delete this[e])})}createRenderRoot(){var t;let e=(t=this.shadowRoot)!==null&&t!==void 0?t:this.attachShadow(this.constructor.shadowRootOptions);return he(e,this.constructor.elementStyles),e}connectedCallback(){var t;this.renderRoot===void 0&&(this.renderRoot=this.createRenderRoot()),this.enableUpdating(!0),(t=this._$ES)===null||t===void 0||t.forEach(e=>{var i;return(i=e.hostConnected)===null||i===void 0?void 0:i.call(e)})}enableUpdating(t){}disconnectedCallback(){var t;(t=this._$ES)===null||t===void 0||t.forEach(e=>{var i;return(i=e.hostDisconnected)===null||i===void 0?void 0:i.call(e)})}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$EO(t,e,i=ce){var s;let r=this.constructor._$Ep(t,i);if(r!==void 0&&i.reflect===!0){let o=(((s=i.converter)===null||s===void 0?void 0:s.toAttribute)!==void 0?i.converter:pe).toAttribute(e,i.type);this._$El=t,o==null?this.removeAttribute(r):this.setAttribute(r,o),this._$El=null}}_$AK(t,e){var i;let s=this.constructor,r=s._$Ev.get(t);if(r!==void 0&&this._$El!==r){let o=s.getPropertyOptions(r),c=typeof o.converter=="function"?{fromAttribute:o.converter}:((i=o.converter)===null||i===void 0?void 0:i.fromAttribute)!==void 0?o.converter:pe;this._$El=r,this[r]=c.fromAttribute(e,o.type),this._$El=null}}requestUpdate(t,e,i){let s=!0;t!==void 0&&(((i=i||this.constructor.getPropertyOptions(t)).hasChanged||ze)(this[t],e)?(this._$AL.has(t)||this._$AL.set(t,e),i.reflect===!0&&this._$El!==t&&(this._$EC===void 0&&(this._$EC=new Map),this._$EC.set(t,i))):s=!1),!this.isUpdatePending&&s&&(this._$E_=this._$Ej())}_$Ej(){return oe(this,null,function*(){this.isUpdatePending=!0;try{yield this._$E_}catch(e){Promise.reject(e)}let t=this.scheduleUpdate();return t!=null&&(yield t),!this.isUpdatePending})}scheduleUpdate(){return this.performUpdate()}performUpdate(){var t;if(!this.isUpdatePending)return;this.hasUpdated,this._$Ei&&(this._$Ei.forEach((s,r)=>this[r]=s),this._$Ei=void 0);let e=!1,i=this._$AL;try{e=this.shouldUpdate(i),e?(this.willUpdate(i),(t=this._$ES)===null||t===void 0||t.forEach(s=>{var r;return(r=s.hostUpdate)===null||r===void 0?void 0:r.call(s)}),this.update(i)):this._$Ek()}catch(s){throw e=!1,this._$Ek(),s}e&&this._$AE(i)}willUpdate(t){}_$AE(t){var e;(e=this._$ES)===null||e===void 0||e.forEach(i=>{var s;return(s=i.hostUpdated)===null||s===void 0?void 0:s.call(i)}),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$Ek(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$E_}shouldUpdate(t){return!0}update(t){this._$EC!==void 0&&(this._$EC.forEach((e,i)=>this._$EO(i,this[i],e)),this._$EC=void 0),this._$Ek()}updated(t){}firstUpdated(t){}};E[ue]=!0,E.elementProperties=new Map,E.elementStyles=[],E.shadowRootOptions={mode:"open"},Ne==null||Ne({ReactiveElement:E}),((de=ee.reactiveElementVersions)!==null&&de!==void 0?de:ee.reactiveElementVersions=[]).push("1.6.2");var me,te=window,U=te.trustedTypes,Re=U?U.createPolicy("lit-html",{createHTML:n=>n}):void 0,fe="$lit$",x=`lit$${(Math.random()+"").slice(9)}$`,qe="?"+x,at=`<${qe}>`,O=document,F=()=>O.createComment(""),G=n=>n===null||typeof n!="object"&&typeof n!="function",je=Array.isArray,lt=n=>je(n)||typeof(n==null?void 0:n[Symbol.iterator])=="function",ve=`[ \f\r]`,K=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,Ue=/-->/g,Ie=/>/g,H=RegExp(`>|${ve}(?:([^\\s"'>=/]+)(${ve}*=${ve}*(?:[^ \f\r"'\`<>=]|("|')|))|$)`,"g"),Be=/'/g,De=/"/g,Ke=/^(?:script|style|textarea|title)$/i,Fe=n=>(t,...e)=>({_$litType$:n,strings:t,values:e}),_=Fe(1),ii=Fe(2),P=Symbol.for("lit-noChange"),m=Symbol.for("lit-nothing"),We=new WeakMap,L=O.createTreeWalker(O,129,null,!1);function Ge(n,t){if(!Array.isArray(n)||!n.hasOwnProperty("raw"))throw Error("invalid template strings array");return Re!==void 0?Re.createHTML(t):t}var ht=(n,t)=>{let e=n.length-1,i=[],s,r=t===2?"":"",o=K;for(let c=0;c"?(o=s!=null?s:K,p=-1):h[1]===void 0?p=-2:(p=o.lastIndex-h[2].length,l=h[1],o=h[3]===void 0?H:h[3]==='"'?De:Be):o===De||o===Be?o=H:o===Ue||o===Ie?o=K:(o=H,s=void 0);let u=o===H&&n[c+1].startsWith("/>")?" ":"";r+=o===K?a+at:p>=0?(i.push(l),a.slice(0,p)+fe+a.slice(p)+x+u):a+x+(p===-2?(i.push(void 0),c):u)}return[Ge(n,r+(n[e]||"")+(t===2?"":"")),i]},N=class{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let r=0,o=0,c=t.length-1,a=this.parts,[l,h]=ht(t,e);if(this.el=N.createElement(l,i),L.currentNode=this.el.content,e===2){let p=this.el.content,d=p.firstChild;d.remove(),p.append(...d.childNodes)}for(;(s=L.nextNode())!==null&&a.length0){s.textContent=U?U.emptyScript:"";for(let u=0;u2||i[0]!==""||i[1]!==""?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=m}get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}_$AI(t,e=this,i,s){let r=this.strings,o=!1;if(r===void 0)t=I(this,t,e,0),o=!G(t)||t!==this._$AH&&t!==P,o&&(this._$AH=t);else{let c=t,a,l;for(t=r[0],a=0;a{var i,s;let r=(i=e==null?void 0:e.renderBefore)!==null&&i!==void 0?i:t,o=r._$litPart$;if(o===void 0){let c=(s=e==null?void 0:e.renderBefore)!==null&&s!==void 0?s:null;r._$litPart$=o=new z(t.insertBefore(F(),c),c,void 0,e!=null?e:{})}return o._$AI(n),o};var $e,we;var v=class extends E{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){var t,e;let i=super.createRenderRoot();return(t=(e=this.renderOptions).renderBefore)!==null&&t!==void 0||(e.renderBefore=i.firstChild),i}update(t){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=J(e,this.renderRoot,this.renderOptions)}connectedCallback(){var t;super.connectedCallback(),(t=this._$Do)===null||t===void 0||t.setConnected(!0)}disconnectedCallback(){var t;super.disconnectedCallback(),(t=this._$Do)===null||t===void 0||t.setConnected(!1)}render(){return P}};v.finalized=!0,v._$litElement$=!0,($e=globalThis.litElementHydrateSupport)===null||$e===void 0||$e.call(globalThis,{LitElement:v});var Je=globalThis.litElementPolyfillSupport;Je==null||Je({LitElement:v});((we=globalThis.litElementVersions)!==null&&we!==void 0?we:globalThis.litElementVersions=[]).push("3.3.2");var M=class extends v{connectedCallback(){this.maybeCarryFill(),super.connectedCallback()}render(){return _``}maybeCarryFill(){this.isFillCarrier?(this.classList.add("html-fill-container"),this.classList.add("html-fill-item")):(this.classList.remove("html-fill-container"),this.classList.remove("html-fill-item"))}get isFillCarrier(){if(!this.parentElement)return!1;let t=this.parentElement.classList.contains("html-fill-container"),e=Array.from(this.children).some(i=>i.classList.contains("html-fill-item"));return t&&e}};M.isShinyInput=!1,M.styles=b` From deb9c16c95bf8e6f7dee12d19dffc2a5ad38ffa2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 28 Oct 2025 10:25:05 -0400 Subject: [PATCH 60/68] feat(toast): Allow disabling close button when auto-hide disabled --- R/toast.R | 11 ++++------- man/toast.Rd | 5 +++-- tests/testthat/_snaps/toast.md | 9 +++++++++ tests/testthat/test-toast.R | 23 +++++++++++++++++++---- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/R/toast.R b/R/toast.R index 45d8dbdeb..6c40b0e46 100644 --- a/R/toast.R +++ b/R/toast.R @@ -78,8 +78,10 @@ #' horizontal positions are `"left"`, `"center"`, or `"right"`. Input is #' case-insensitive. Default is `"bottom-right"`. #' @param closable Logical. Whether to include a close button. Defaults to -#' `TRUE` and is only relevant when auto-hiding is enabled. If auto-hiding is -#' disabled, a close button is always included for accessibility. +#' `TRUE`. When both `autohide_s = NA` (or `0` or `NULL`) and `closable = +#' FALSE`, the toast will remain visible until manually hidden via +#' [hide_toast()]. This is useful when the toast contains interactive Shiny UI +#' elements and you want to manage the toast display programmatically. #' #' @return A `bslib_toast` object that can be passed to [show_toast()]. #' @@ -129,11 +131,6 @@ toast <- function( duration <- if (autohide) autohide_s * 1000 # milliseconds - # Enforce close button for non-autohiding toasts (accessibility) - if (!autohide) { - closable <- TRUE - } - structure( list( body = dots$children, diff --git a/man/toast.Rd b/man/toast.Rd index 3c03eb24c..e37680db5 100644 --- a/man/toast.Rd +++ b/man/toast.Rd @@ -55,8 +55,9 @@ horizontal positions are \code{"left"}, \code{"center"}, or \code{"right"}. Inpu case-insensitive. Default is \code{"bottom-right"}.} \item{closable}{Logical. Whether to include a close button. Defaults to -\code{TRUE} and is only relevant when auto-hiding is enabled. If auto-hiding is -disabled, a close button is always included for accessibility.} +\code{TRUE}. When both \code{autohide_s = NA} (or \code{0} or \code{NULL}) and \code{closable = FALSE}, the toast will remain visible until manually hidden via +\code{\link[=hide_toast]{hide_toast()}}. This is useful when the toast contains interactive Shiny UI +elements and you want to manage the toast display programmatically.} \item{title}{Header text (required).} diff --git a/tests/testthat/_snaps/toast.md b/tests/testthat/_snaps/toast.md index 2ec2289ec..ed4e04399 100644 --- a/tests/testthat/_snaps/toast.md +++ b/tests/testthat/_snaps/toast.md @@ -115,6 +115,15 @@
Message
+--- + + Code + cat(format(as.tags(t_manual))) + Output +
+
Message
+
+ # normalize_toast_position() errors on invalid input Code diff --git a/tests/testthat/test-toast.R b/tests/testthat/test-toast.R index a858f9203..57a7c3497 100644 --- a/tests/testthat/test-toast.R +++ b/tests/testthat/test-toast.R @@ -32,18 +32,24 @@ test_that("toast() type 'error' is aliased to 'danger'", { }) test_that("toast() autohide disabled (0, NA, NULL)", { - # When autohide is disabled, closable is forced to TRUE for accessibility + # When autohide is disabled, closable can be set to FALSE + # This allows app authors to manage toast display manually t1 <- toast("Test", autohide_s = 0, closable = FALSE) expect_false(t1$autohide) - expect_true(t1$closable) # Always true when autohide disabled + expect_false(t1$closable) t2 <- toast("Test", autohide_s = NA, closable = FALSE) expect_false(t2$autohide) - expect_true(t2$closable) + expect_false(t2$closable) t3 <- toast("Test", autohide_s = NULL, closable = FALSE) expect_false(t3$autohide) - expect_true(t3$closable) + expect_false(t3$closable) + + # closable can also be TRUE when autohide is disabled + t4 <- toast("Test", autohide_s = NA, closable = TRUE) + expect_false(t4$autohide) + expect_true(t4$closable) }) test_that("toast() `closable` when autohide enabled", { @@ -137,6 +143,15 @@ test_that("as.tags.bslib_toast includes close button appropriately", { id = "non-closable-toast" ) expect_snapshot(cat(format(as.tags(t_non_closable)))) + + # Non-closable with autohide disabled (for manual management) + t_manual <- toast( + "Message", + closable = FALSE, + autohide_s = NA, + id = "manual-toast" + ) + expect_snapshot(cat(format(as.tags(t_manual)))) }) # toast_header() tests ---- From a2539a84bf27bb9922f8e7a1f120bb661fbb4022 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 28 Oct 2025 13:12:25 -0400 Subject: [PATCH 61/68] refactor: Rename `autohide_s` -> `duration_s` --- R/toast.R | 18 +++++++++--------- inst/examples-shiny/toast/app.R | 18 +++++++++--------- man/toast.Rd | 6 +++--- tests/testthat/_snaps/toast.md | 14 +++++++------- tests/testthat/test-toast.R | 24 ++++++++++++------------ 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/R/toast.R b/R/toast.R index 6c40b0e46..ac67da34e 100644 --- a/R/toast.R +++ b/R/toast.R @@ -64,7 +64,7 @@ #' `"secondary"`, `"success"`, `"info"`, `"warning"`, `"danger"`, `"light"`, #' or `"dark"`. Applies appropriate Bootstrap background utility classes #' (`text-bg-*`). -#' @param autohide_s Numeric. Number of seconds after which the toast should +#' @param duration_s Numeric. Number of seconds after which the toast should #' automatically hide. Use `0`, or `NA` to disable auto-hiding (toast will #' remain visible until manually dismissed). Default is `5` (5 seconds). #' @param position String or character vector specifying where to position the @@ -78,7 +78,7 @@ #' horizontal positions are `"left"`, `"center"`, or `"right"`. Input is #' case-insensitive. Default is `"bottom-right"`. #' @param closable Logical. Whether to include a close button. Defaults to -#' `TRUE`. When both `autohide_s = NA` (or `0` or `NULL`) and `closable = +#' `TRUE`. When both `duration_s = NA` (or `0` or `NULL`) and `closable = #' FALSE`, the toast will remain visible until manually hidden via #' [hide_toast()]. This is useful when the toast contains interactive Shiny UI #' elements and you want to manage the toast display programmatically. @@ -95,7 +95,7 @@ toast <- function( header = NULL, id = NULL, type = NULL, - autohide_s = 5, + duration_s = 5, position = "top-right", closable = TRUE ) { @@ -117,19 +117,19 @@ toast <- function( position <- normalize_toast_position(position) - # autohide_s of 0 or NA (or NULL) disables auto-hiding - if (is.null(autohide_s) || (length(autohide_s) == 1 && is.na(autohide_s))) { + # duration_s of 0 or NA (or NULL) disables auto-hiding + if (is.null(duration_s) || (length(duration_s) == 1 && is.na(duration_s))) { autohide <- FALSE } else { - if (!is.numeric(autohide_s) || length(autohide_s) != 1 || autohide_s < 0) { + if (!is.numeric(duration_s) || length(duration_s) != 1 || duration_s < 0) { rlang::abort( - "`autohide_s` must be a single non-negative number or NA." + "`duration_s` must be a single non-negative number or NA." ) } - autohide <- autohide_s != 0 + autohide <- duration_s != 0 } - duration <- if (autohide) autohide_s * 1000 # milliseconds + duration <- if (autohide) duration_s * 1000 # milliseconds structure( list( diff --git a/inst/examples-shiny/toast/app.R b/inst/examples-shiny/toast/app.R index 8a32d6541..8ba2d1658 100644 --- a/inst/examples-shiny/toast/app.R +++ b/inst/examples-shiny/toast/app.R @@ -72,8 +72,8 @@ ui <- page_fillable( # Auto-hide options sliderInput( - "autohide_s", - "Auto-hide (seconds, 0 = disabled)", + "duration_s", + "Duration (seconds, 0 = disabled)", min = 0, max = 25, value = 5, @@ -256,7 +256,7 @@ server <- function(input, output, session) { header = header, id = if (nzchar(input$custom_id)) input$custom_id, type = if (nzchar(input$type)) input$type, - autohide_s = input$autohide_s, + duration_s = input$duration_s, position = input$position, closable = input$closable ) @@ -280,7 +280,7 @@ server <- function(input, output, session) { "This toast won't disappear automatically. Use the 'Hide' button to dismiss it.", header = "Persistent Toast", type = "info", - autohide_s = 0 + duration_s = 0 ) ) persistent_toast_id(id) @@ -298,7 +298,7 @@ server <- function(input, output, session) { "This toast will stay visible for 10 seconds.", header = "Long Duration", type = "primary", - autohide_s = 10 + duration_s = 10 ) ) }) @@ -309,7 +309,7 @@ server <- function(input, output, session) { "This toast has no close button but will auto-hide in 3 seconds.", type = "secondary", closable = FALSE, - autohide_s = 3 + duration_s = 3 ) ) }) @@ -345,7 +345,7 @@ server <- function(input, output, session) { ), header = "Unsaved Changes", type = "warning", - autohide_s = 0 + duration_s = 0 ) ) }) @@ -399,7 +399,7 @@ server <- function(input, output, session) { toast( paste("Toast at", pos), type = types[i], - autohide_s = 4, + duration_s = 4, position = pos ) ) @@ -430,7 +430,7 @@ server <- function(input, output, session) { status = textOutput("toast_status", inline = TRUE) ), type = "light", - autohide_s = 0 + duration_s = 0 ) ) inserted_time(Sys.time()) diff --git a/man/toast.Rd b/man/toast.Rd index e37680db5..1a18bede3 100644 --- a/man/toast.Rd +++ b/man/toast.Rd @@ -10,7 +10,7 @@ toast( header = NULL, id = NULL, type = NULL, - autohide_s = 5, + duration_s = 5, position = "top-right", closable = TRUE ) @@ -37,7 +37,7 @@ shown at once.} or \code{"dark"}. Applies appropriate Bootstrap background utility classes (\verb{text-bg-*}).} -\item{autohide_s}{Numeric. Number of seconds after which the toast should +\item{duration_s}{Numeric. Number of seconds after which the toast should automatically hide. Use \code{0}, or \code{NA} to disable auto-hiding (toast will remain visible until manually dismissed). Default is \code{5} (5 seconds).} @@ -55,7 +55,7 @@ horizontal positions are \code{"left"}, \code{"center"}, or \code{"right"}. Inpu case-insensitive. Default is \code{"bottom-right"}.} \item{closable}{Logical. Whether to include a close button. Defaults to -\code{TRUE}. When both \code{autohide_s = NA} (or \code{0} or \code{NULL}) and \code{closable = FALSE}, the toast will remain visible until manually hidden via +\code{TRUE}. When both \code{duration_s = NA} (or \code{0} or \code{NULL}) and \code{closable = FALSE}, the toast will remain visible until manually hidden via \code{\link[=hide_toast]{hide_toast()}}. This is useful when the toast contains interactive Shiny UI elements and you want to manage the toast display programmatically.} diff --git a/tests/testthat/_snaps/toast.md b/tests/testthat/_snaps/toast.md index ed4e04399..619f33bde 100644 --- a/tests/testthat/_snaps/toast.md +++ b/tests/testthat/_snaps/toast.md @@ -14,23 +14,23 @@ Error in `toast()`: ! `type` must be one of "primary", "secondary", "success", "info", "warning", "danger", "error", "light", or "dark", not "invalid". -# toast() autohide_s throws for invalid values +# toast() duration_s throws for invalid values Code - toast("Test", autohide_s = -5) + toast("Test", duration_s = -5) Condition Error in `toast()`: - ! `autohide_s` must be a single non-negative number or NA. + ! `duration_s` must be a single non-negative number or NA. Code - toast("Test", autohide_s = "invalid") + toast("Test", duration_s = "invalid") Condition Error in `toast()`: - ! `autohide_s` must be a single non-negative number or NA. + ! `duration_s` must be a single non-negative number or NA. Code - toast("Test", autohide_s = c(5, 10)) + toast("Test", duration_s = c(5, 10)) Condition Error in `toast()`: - ! `autohide_s` must be a single non-negative number or NA. + ! `duration_s` must be a single non-negative number or NA. # as.tags.bslib_toast creates proper HTML structure diff --git a/tests/testthat/test-toast.R b/tests/testthat/test-toast.R index 57a7c3497..b468f10f9 100644 --- a/tests/testthat/test-toast.R +++ b/tests/testthat/test-toast.R @@ -34,42 +34,42 @@ test_that("toast() type 'error' is aliased to 'danger'", { test_that("toast() autohide disabled (0, NA, NULL)", { # When autohide is disabled, closable can be set to FALSE # This allows app authors to manage toast display manually - t1 <- toast("Test", autohide_s = 0, closable = FALSE) + t1 <- toast("Test", duration_s = 0, closable = FALSE) expect_false(t1$autohide) expect_false(t1$closable) - t2 <- toast("Test", autohide_s = NA, closable = FALSE) + t2 <- toast("Test", duration_s = NA, closable = FALSE) expect_false(t2$autohide) expect_false(t2$closable) - t3 <- toast("Test", autohide_s = NULL, closable = FALSE) + t3 <- toast("Test", duration_s = NULL, closable = FALSE) expect_false(t3$autohide) expect_false(t3$closable) # closable can also be TRUE when autohide is disabled - t4 <- toast("Test", autohide_s = NA, closable = TRUE) + t4 <- toast("Test", duration_s = NA, closable = TRUE) expect_false(t4$autohide) expect_true(t4$closable) }) test_that("toast() `closable` when autohide enabled", { # When autohide is enabled, user can control closable - t_closable <- toast("Test", autohide_s = 10, closable = TRUE) + t_closable <- toast("Test", duration_s = 10, closable = TRUE) expect_true(t_closable$autohide) expect_equal(t_closable$duration, 10000) # Converted to milliseconds expect_true(t_closable$closable) - t_not_closable <- toast("Test", autohide_s = 5, closable = FALSE) + t_not_closable <- toast("Test", duration_s = 5, closable = FALSE) expect_true(t_not_closable$autohide) expect_equal(t_not_closable$duration, 5000) expect_false(t_not_closable$closable) }) -test_that("toast() autohide_s throws for invalid values", { +test_that("toast() duration_s throws for invalid values", { expect_snapshot(error = TRUE, { - toast("Test", autohide_s = -5) - toast("Test", autohide_s = "invalid") - toast("Test", autohide_s = c(5, 10)) + toast("Test", duration_s = -5) + toast("Test", duration_s = "invalid") + toast("Test", duration_s = c(5, 10)) }) }) @@ -139,7 +139,7 @@ test_that("as.tags.bslib_toast includes close button appropriately", { t_non_closable <- toast( "Message", closable = FALSE, - autohide_s = 5, + duration_s = 5, id = "non-closable-toast" ) expect_snapshot(cat(format(as.tags(t_non_closable)))) @@ -148,7 +148,7 @@ test_that("as.tags.bslib_toast includes close button appropriately", { t_manual <- toast( "Message", closable = FALSE, - autohide_s = NA, + duration_s = NA, id = "manual-toast" ) expect_snapshot(cat(format(as.tags(t_manual)))) From 77528fb8815d9edaf24f2993c9dc8115adccc6fa Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 28 Oct 2025 13:37:10 -0400 Subject: [PATCH 62/68] demo: Make action button toast not closable --- inst/examples-shiny/toast/app.R | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/inst/examples-shiny/toast/app.R b/inst/examples-shiny/toast/app.R index 8ba2d1658..aac185d3c 100644 --- a/inst/examples-shiny/toast/app.R +++ b/inst/examples-shiny/toast/app.R @@ -345,7 +345,8 @@ server <- function(input, output, session) { ), header = "Unsaved Changes", type = "warning", - duration_s = 0 + duration_s = 0, + closable = FALSE ) ) }) From 5471659cbc54f6b6c0876c3bfac8104d976bc4f0 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 29 Oct 2025 07:34:38 -0400 Subject: [PATCH 63/68] chore: move `toast_header()` closer to `toast()` --- R/toast.R | 56 +++++++++++++++++++++++++++---------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/R/toast.R b/R/toast.R index ac67da34e..a6d613eac 100644 --- a/R/toast.R +++ b/R/toast.R @@ -147,6 +147,34 @@ toast <- function( ) } +#' @describeIn toast Create a structured toast header with optional icon and +#' status indicator. Returns a data structure that can be passed to the +#' `header` argument of `toast()`. +#' +#' @param title Header text (required). +#' @param icon Optional icon element, for example from [shiny::icon()], +#' [bsicons::bs_icon()] or [fontawesome::fa()]. +#' @param status Optional status text that appears as small, muted text on the +#' right side of the header. +#' +#' @return For `toast_header()`: a toast header object that can be used with the +#' `header` argument of `toast()`. +#' +#' @export +toast_header <- function(title, ..., icon = NULL, status = NULL) { + dots <- separate_arguments(...) + + structure( + list( + title = tagList(title, !!!dots$children), + icon = icon, + status = status, + attribs = dots$attribs + ), + class = "bslib_toast_header" + ) +} + #' @export as.tags.bslib_toast <- function(x, ...) { id <- x$id %||% toast_random_id() @@ -276,34 +304,6 @@ hide_toast <- function(id, ..., session = shiny::getDefaultReactiveDomain()) { invisible(id) } -#' @describeIn toast Create a structured toast header with optional icon and -#' status indicator. Returns a data structure that can be passed to the -#' `header` argument of `toast()`. -#' -#' @param title Header text (required). -#' @param icon Optional icon element, for example from [shiny::icon()], -#' [bsicons::bs_icon()] or [fontawesome::fa()]. -#' @param status Optional status text that appears as small, muted text on the -#' right side of the header. -#' -#' @return For `toast_header()`: a toast header object that can be used with the -#' `header` argument of `toast()`. -#' -#' @export -toast_header <- function(title, ..., icon = NULL, status = NULL) { - dots <- separate_arguments(...) - - structure( - list( - title = tagList(title, !!!dots$children), - icon = icon, - status = status, - attribs = dots$attribs - ), - class = "bslib_toast_header" - ) -} - toast_component_header <- function(x) { # Status text (small muted text) status_text <- if (!is.null(x$status)) { From 75da1251aa20e0fd54d17e0f6722f8fcfa7f0150 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 29 Oct 2025 09:20:56 -0400 Subject: [PATCH 64/68] feat: Add `icon` argument to `toast()` --- R/toast.R | 28 ++--- inst/examples-shiny/toast/app.R | 8 +- tests/testthat/test-toast.R | 193 ++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 19 deletions(-) diff --git a/R/toast.R b/R/toast.R index a6d613eac..8fb4de688 100644 --- a/R/toast.R +++ b/R/toast.R @@ -93,6 +93,7 @@ toast <- function( ..., header = NULL, + icon = NULL, id = NULL, type = NULL, duration_s = 5, @@ -135,6 +136,7 @@ toast <- function( list( body = dots$children, header = header, + icon = icon, id = id, type = type, autohide = autohide, @@ -182,6 +184,7 @@ as.tags.bslib_toast <- function(x, ...) { toast_component( body = x$body, header = x$header, + icon = x$icon, type = x$type, closable = x$closable, id = id, @@ -325,6 +328,7 @@ toast_component_header <- function(x) { toast_component <- function( body, header = NULL, + icon = NULL, type = NULL, closable = TRUE, id = NULL, @@ -376,24 +380,16 @@ toast_component <- function( # Body with optional close button # * If header exists, close button goes in header # * If no header, close button goes in body (if closable) - body_tag <- if (!is.null(header)) { + body_has_close_btn <- is.null(header) && closable + body_tag <- if (!body_has_close_btn && is.null(icon)) { div(class = "toast-body", body) } else { - if (closable) { - div( - class = "toast-body d-flex", - div(class = "flex-grow-1", body), - tags$button( - type = "button", - class = "btn-close", - `data-bs-dismiss` = "toast", - `aria-label` = "Close" - ) - ) - } else { - # No close button needed - div(class = "toast-body", body) - } + div( + class = "toast-body d-flex gap-2", + if (!is.null(icon)) span(class = "toast-body-icon", icon), + div(class = "toast-body-content flex-grow-1", body), + if (body_has_close_btn) close_button + ) } toast <- div( diff --git a/inst/examples-shiny/toast/app.R b/inst/examples-shiny/toast/app.R index aac185d3c..da378ae48 100644 --- a/inst/examples-shiny/toast/app.R +++ b/inst/examples-shiny/toast/app.R @@ -247,13 +247,15 @@ server <- function(input, output, session) { ) } + body_icon <- if (!input$use_header && nzchar(input$icon_body)) { + bsicons::bs_icon(input$icon_body) + } + # Build toast toast_obj <- toast( - if (!input$use_header && nzchar(input$icon_body)) { - bsicons::bs_icon(input$icon_body, class = "me-2") - }, input$body, header = header, + icon = body_icon, id = if (nzchar(input$custom_id)) input$custom_id, type = if (nzchar(input$type)) input$type, duration_s = input$duration_s, diff --git a/tests/testthat/test-toast.R b/tests/testthat/test-toast.R index b468f10f9..6692dcce5 100644 --- a/tests/testthat/test-toast.R +++ b/tests/testthat/test-toast.R @@ -9,6 +9,7 @@ test_that("toast() creates bslib_toast object with defaults", { expect_equal(t$duration, 5000) # Default 5 seconds in milliseconds expect_true(t$closable) expect_null(t$header) + expect_null(t$icon) expect_equal(t$position, "top-right") }) @@ -73,6 +74,22 @@ test_that("toast() duration_s throws for invalid values", { }) }) +test_that("toast() stores icon argument", { + icon_elem <- span(class = "test-icon", "★") + + t <- toast("Test message", icon = icon_elem) + + expect_s3_class(t, "bslib_toast") + expect_s3_class(t$icon, "shiny.tag") + expect_equal(t$icon$attribs$class, "test-icon") + expect_equal(t$icon$children[[1]], "★") +}) + +test_that("toast() icon is NULL by default", { + t <- toast("Test message") + expect_null(t$icon) +}) + # toast() rendering tests ---- @@ -154,6 +171,82 @@ test_that("as.tags.bslib_toast includes close button appropriately", { expect_snapshot(cat(format(as.tags(t_manual)))) }) +test_that("toast() icon renders in body without header", { + icon_elem <- span(class = "my-icon", "★") + t <- toast("You have new messages", icon = icon_elem, id = "icon-toast") + + tag <- as.tags(t) + html <- as.character(tag) + + # Icon should be in toast-body with special wrapper + expect_match(html, 'class="toast-body d-flex gap-2"') + expect_match(html, 'class="toast-body-icon"') + expect_match(html, 'class="my-icon"') + expect_match(html, "★") + expect_match(html, 'class="toast-body-content flex-grow-1"') + expect_snapshot(cat(format(tag))) +}) + +test_that("toast() icon renders in body with header", { + icon_elem <- span(class = "header-icon", "★") + t <- toast( + "Message content", + header = "New Mail", + icon = icon_elem, + id = "icon-header-toast" + ) + + tag <- as.tags(t) + html <- as.character(tag) + + # Icon should still be in body when header is present + expect_match(html, 'class="toast-body d-flex gap-2"') + expect_match(html, 'class="toast-body-icon"') + expect_match(html, 'class="header-icon"') + expect_match(html, "★") + expect_snapshot(cat(format(tag))) +}) + +test_that("toast() icon works with closable button in body", { + icon_elem <- span(class = "alert-icon", "★") + t <- toast( + "Warning message", + icon = icon_elem, + closable = TRUE, + id = "icon-closable-toast" + ) + + tag <- as.tags(t) + html <- as.character(tag) + + # Should have both icon and close button in body + expect_match(html, 'class="toast-body d-flex gap-2"') + expect_match(html, 'class="toast-body-icon"') + expect_match(html, 'class="alert-icon"') + expect_match(html, "★") + expect_match(html, 'class="btn-close"') + expect_snapshot(cat(format(tag))) +}) + +test_that("toast() without icon or close button has simple body", { + t <- toast( + "Simple message", + header = "Header", + closable = FALSE, + id = "simple-body-toast" + ) + + tag <- as.tags(t) + html <- as.character(tag) + + # Should have simple toast-body (no d-flex gap-2) + expect_match(html, 'class="toast-body"') + expect_false(grepl('class="toast-body d-flex gap-2"', html)) + expect_false(grepl('toast-body-icon', html)) + expect_false(grepl('toast-body-content', html)) +}) + + # toast_header() tests ---- test_that("toast_header() creates structured header data", { @@ -181,6 +274,40 @@ test_that("toast_header() works with icons", { expect_equal(h$icon$attribs$class, "test-icon") }) +test_that("toast_header() icon renders in header", { + icon_elem <- span(class = "header-test-icon", "★") + h <- toast_header("Notification", icon = icon_elem, status = "now") + + t <- toast("Body content", header = h, id = "header-icon-toast") + tag <- as.tags(t) + html <- as.character(tag) + + # Icon should be in toast-header with wrapper + expect_match(html, 'class="toast-header"') + expect_match(html, 'class="toast-header-icon"') + expect_match(html, 'class="header-test-icon"') + expect_match(html, "★") + expect_snapshot(cat(format(tag))) +}) + +test_that("toast_header() icon with status and title", { + icon_elem <- span(class = "success-icon", "✓") + h <- toast_header("Success", icon = icon_elem, status = "just now") + + t <- toast("Operation completed", header = h, id = "full-header-toast") + tag <- as.tags(t) + html <- as.character(tag) + + # Should have all three elements: icon, title, status + expect_match(html, 'class="toast-header-icon"') + expect_match(html, 'class="success-icon"') + expect_match(html, "✓") + expect_match(html, "Success") + expect_match(html, "just now") + expect_match(html, 'class="text-muted text-end"') + expect_snapshot(cat(format(tag))) +}) + test_that("toast() stores additional attributes", { t <- toast("Test", `data-test` = "value", class = "extra-class") @@ -296,6 +423,49 @@ test_that("toast header can be modified after creation", { expect_false(grepl("1 min ago", html)) }) +test_that("toast header with icon can be modified after creation", { + # Create toast with toast_header() including icon + icon1 <- span(class = "icon-1", "A") + t <- toast( + "Body", + header = toast_header("Title", icon = icon1) + ) + + # Modify the header icon + icon2 <- span(class = "icon-2", "B") + t$header$icon <- icon2 + + tag <- as.tags(t) + html <- as.character(tag) + + expect_true(grepl("icon-2", html)) + expect_true(grepl("B", html, fixed = TRUE)) + expect_false(grepl("icon-1", html)) + expect_false(grepl("A", html, fixed = TRUE)) +}) + +test_that("toast header with list pattern and icon", { + # Bare list with title and icon + icon_elem <- span(class = "list-icon", "★") + t <- toast( + "Body", + header = list( + title = "Notes", + icon = icon_elem, + status = "updated" + ) + ) + + tag <- as.tags(t) + html <- as.character(tag) + + expect_true(grepl("toast-header", html)) + expect_true(grepl("Notes", html)) + expect_true(grepl("updated", html)) + expect_true(grepl("list-icon", html)) + expect_true(grepl("★", html)) +}) + test_that("toast header can be replaced with list pattern", { # Create toast with simple character header t <- toast("Body", header = "Simple") @@ -316,6 +486,29 @@ test_that("toast header can be replaced with list pattern", { expect_false(grepl("Simple", html)) }) +test_that("toast with both header icon and body icon", { + # Both header and body can have their own icons + header_icon <- span(class = "h-icon", "H") + body_icon <- span(class = "b-icon", "B") + + t <- toast( + "Message content", + header = toast_header("Title", icon = header_icon), + icon = body_icon, + id = "dual-icon-toast" + ) + + tag <- as.tags(t) + html <- as.character(tag) + + # Both icons should be present in different locations + expect_match(html, 'class="toast-header-icon"') + expect_match(html, 'class="h-icon"') + expect_match(html, 'class="toast-body-icon"') + expect_match(html, 'class="b-icon"') + expect_snapshot(cat(format(tag))) +}) + # normalize_toast_position() helper tests ---- test_that("normalize_toast_position() handles standard kebab-case format", { From be32c7583b948b06e4f06af4a936937f5a9fadbc Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 29 Oct 2025 09:45:42 -0400 Subject: [PATCH 65/68] tests: fix and update snaps --- tests/testthat/_snaps/toast.md | 119 ++++++++++++++++++++++++++++++--- tests/testthat/test-toast.R | 14 ++-- 2 files changed, 118 insertions(+), 15 deletions(-) diff --git a/tests/testthat/_snaps/toast.md b/tests/testthat/_snaps/toast.md index 619f33bde..56db7547d 100644 --- a/tests/testthat/_snaps/toast.md +++ b/tests/testthat/_snaps/toast.md @@ -51,8 +51,8 @@ cat(format(as.tags(t_danger))) Output