Put actionButton()s icon and label into containers (#4249)

* Put action icon and label into containers

* Update snaps

* More robust test

* Don't include container if icon/label isn't specified

* `yarn build` (GitHub Actions)

* Send HTML string/deps on update; update news

---------

Co-authored-by: cpsievert <cpsievert@users.noreply.github.com>
This commit is contained in:
Carson Sievert
2025-07-14 16:22:03 -05:00
committed by GitHub
parent f7528568e5
commit ecf6bfe9a7
15 changed files with 229 additions and 96 deletions

View File

@@ -202,6 +202,7 @@ Collate:
'test.R' 'test.R'
'update-input.R' 'update-input.R'
'utils-lang.R' 'utils-lang.R'
'utils-tags.R'
'version_bs_date_picker.R' 'version_bs_date_picker.R'
'version_ion_range_slider.R' 'version_ion_range_slider.R'
'version_jquery.R' 'version_jquery.R'

12
NEWS.md
View File

@@ -1,5 +1,17 @@
# shiny (development version) # shiny (development version)
## New features
* The `icon` argument of `updateActionButton()`/`updateActionLink()` nows allows values other than `shiny::icon()` (e.g., `fontawesome::fa()`, `bsicons::bs_icon()`, etc). (#4249)
## Bug fixes
* `updateActionButton()`/`updateActionLink()` now correctly renders HTML content passed to the `label` argument. (#4249)
## Changes
* The return value of `actionButton()`/`actionLink()` changed slightly: `label` and `icon` are wrapped in an additional HTML container element. This allows for: 1. `updateActionButton()`/`updateActionLink()` to distinguish between the `label` and `icon` when making updates and 2. spacing between `label` and `icon` to be more easily customized via CSS.
# shiny 1.11.1 # shiny 1.11.1
This is a patch release primarily for addressing the bugs introduced in v1.11.0. This is a patch release primarily for addressing the bugs introduced in v1.11.0.

View File

@@ -56,13 +56,24 @@ actionButton <- function(inputId, label, icon = NULL, width = NULL,
value <- restoreInput(id = inputId, default = NULL) value <- restoreInput(id = inputId, default = NULL)
tags$button(id=inputId, icon <- validateIcon(icon)
if (!is.null(icon)) {
icon <- span(icon, class = "action-icon")
}
if (!is.null(label)) {
label <- span(label, class = "action-label")
}
tags$button(
id = inputId,
style = css(width = validateCssUnit(width)), style = css(width = validateCssUnit(width)),
type="button", type = "button",
class="btn btn-default action-button", class = "btn btn-default action-button",
`data-val` = value, `data-val` = value,
disabled = if (isTRUE(disabled)) NA else NULL, disabled = if (isTRUE(disabled)) NA else NULL,
list(validateIcon(icon), label), icon, label,
... ...
) )
} }
@@ -72,30 +83,40 @@ actionButton <- function(inputId, label, icon = NULL, width = NULL,
actionLink <- function(inputId, label, icon = NULL, ...) { actionLink <- function(inputId, label, icon = NULL, ...) {
value <- restoreInput(id = inputId, default = NULL) value <- restoreInput(id = inputId, default = NULL)
tags$a(id=inputId, icon <- validateIcon(icon)
href="#",
class="action-button", if (!is.null(icon)) {
icon <- span(icon, class = "action-icon")
}
if (!is.null(label)) {
label <- span(label, class = "action-label")
}
tags$a(
id = inputId,
href = "#",
class = "action-button action-link",
`data-val` = value, `data-val` = value,
list(validateIcon(icon), label), icon, label,
... ...
) )
} }
# Check that the icon parameter is valid: # Throw an informative warning if icon isn't html-ish
# 1) Check if the user wants to actually add an icon:
# -- if icon=NULL, it means leave the icon unchanged
# -- if icon=character(0), it means don't add an icon or, more usefully,
# remove the previous icon
# 2) If so, check that the icon has the right format (this does not check whether
# it is a *real* icon - currently that would require a massive cross reference
# with the "font-awesome" and the "glyphicon" libraries)
validateIcon <- function(icon) { validateIcon <- function(icon) {
if (is.null(icon) || identical(icon, character(0))) { if (length(icon) == 0) {
return(icon) return(icon)
} else if (inherits(icon, "shiny.tag") && icon$name == "i") {
return(icon)
} else {
stop("Invalid icon. Use Shiny's 'icon()' function to generate a valid icon")
} }
if (!isTagLike(icon)) {
rlang::warn(
c(
"It appears that a non-HTML value was provided to `icon`.",
i = "Try using a `shiny::icon()` (or an equivalent) to get an icon."
),
class = "shiny-validate-icon"
)
}
icon
} }

View File

@@ -181,8 +181,11 @@ updateCheckboxInput <- function(session = getDefaultReactiveDomain(), inputId, l
updateActionButton <- function(session = getDefaultReactiveDomain(), inputId, label = NULL, icon = NULL, disabled = NULL) { updateActionButton <- function(session = getDefaultReactiveDomain(), inputId, label = NULL, icon = NULL, disabled = NULL) {
validate_session_object(session) validate_session_object(session)
if (!is.null(icon)) icon <- as.character(validateIcon(icon)) message <- dropNulls(list(
message <- dropNulls(list(label=label, icon=icon, disabled=disabled)) label = if (!is.null(label)) processDeps(label, session),
icon = if (!is.null(icon)) processDeps(validateIcon(icon), session),
disabled = disabled
))
session$sendInputMessage(inputId, message) session$sendInputMessage(inputId, message)
} }
#' @rdname updateActionButton #' @rdname updateActionButton

21
R/utils-tags.R Normal file
View File

@@ -0,0 +1,21 @@
# Check if `x` is a tag(), tagList(), or HTML()
# @param strict If `FALSE`, also consider a normal list() of 'tags' to be a tag list.
isTagLike <- function(x, strict = FALSE) {
isTag(x) || isTagList(x, strict = strict) || isTRUE(attr(x, "html"))
}
isTag <- function(x) {
inherits(x, "shiny.tag")
}
isTagList <- function(x, strict = TRUE) {
if (strict) {
return(inherits(x, "shiny.tag.list"))
}
if (!is.list(x)) {
return(FALSE)
}
all(vapply(x, isTagLike, logical(1)))
}

View File

@@ -1165,30 +1165,34 @@
getState(el) { getState(el) {
return { value: this.getValue(el) }; return { value: this.getValue(el) };
} }
receiveMessage(el, data) { async receiveMessage(el, data) {
const $el = (0, import_jquery7.default)(el); if (hasDefinedProperty(data, "icon")) {
if (hasDefinedProperty(data, "label") || hasDefinedProperty(data, "icon")) { let iconContainer = el.querySelector(
let label = $el.text(); ":scope > .action-icon"
let icon = ""; );
if ($el.find("i[class]").length > 0) { if (!iconContainer) {
const iconHtml = $el.find("i[class]")[0]; iconContainer = document.createElement("span");
if (iconHtml === $el.children()[0]) { iconContainer.className = "action-icon";
icon = (0, import_jquery7.default)(iconHtml).prop("outerHTML"); el.prepend(iconContainer);
}
} }
if (hasDefinedProperty(data, "label")) { await renderContent(iconContainer, data.icon);
label = data.label; }
if (hasDefinedProperty(data, "label")) {
let labelContainer = el.querySelector(
":scope > .action-label"
);
if (!labelContainer) {
labelContainer = document.createElement("span");
labelContainer.className = "action-label";
el.appendChild(labelContainer);
} }
if (hasDefinedProperty(data, "icon")) { await renderContent(labelContainer, data.label);
icon = Array.isArray(data.icon) ? "" : data.icon ?? "";
}
$el.html(icon + " " + label);
} }
if (hasDefinedProperty(data, "disabled")) { if (hasDefinedProperty(data, "disabled")) {
if (data.disabled) { if (data.disabled) {
$el.attr("disabled", ""); el.setAttribute("disabled", "");
} else { } else {
$el.attr("disabled", null); el.removeAttribute("disabled");
} }
} }
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -463,6 +463,13 @@ textarea.textarea-autoresize.form-control {
} }
} }
// Add spacing between icon and label for actionButton()
.action-button:not(.action-link) {
.action-icon + .action-label {
padding-left: 0.5ch;
}
}
/* Overrides bootstrap-datepicker3.css styling for invalid date ranges. /* Overrides bootstrap-datepicker3.css styling for invalid date ranges.
See https://github.com/rstudio/shiny/issues/2042 for details. */ See https://github.com/rstudio/shiny/issues/2042 for details. */
.datepicker table tbody tr td.disabled, .datepicker table tbody tr td.disabled,

View File

@@ -1,10 +1,12 @@
import $ from "jquery"; import $ from "jquery";
import type { HtmlDep } from "../../shiny/render";
import { renderContent } from "../../shiny/render";
import { hasDefinedProperty } from "../../utils"; import { hasDefinedProperty } from "../../utils";
import { InputBinding } from "./inputBinding"; import { InputBinding } from "./inputBinding";
type ActionButtonReceiveMessageData = { type ActionButtonReceiveMessageData = {
label?: string; label?: { html: string; deps: HtmlDep[] };
icon?: string | []; icon?: { html: string; deps: HtmlDep[] };
disabled?: boolean; disabled?: boolean;
}; };
@@ -39,45 +41,40 @@ class ActionButtonInputBinding extends InputBinding {
getState(el: HTMLElement): { value: number } { getState(el: HTMLElement): { value: number } {
return { value: this.getValue(el) }; return { value: this.getValue(el) };
} }
receiveMessage(el: HTMLElement, data: ActionButtonReceiveMessageData): void { async receiveMessage(
const $el = $(el); el: HTMLElement,
data: ActionButtonReceiveMessageData
if (hasDefinedProperty(data, "label") || hasDefinedProperty(data, "icon")) { ): Promise<void> {
// retrieve current label and icon if (hasDefinedProperty(data, "icon")) {
let label: string = $el.text(); let iconContainer = el.querySelector<HTMLElement>(
let icon = ""; ":scope > .action-icon"
);
// to check (and store) the previous icon, we look for a $el child // If no container exists yet, create one
// object that has an i tag, and some (any) class (this prevents if (!iconContainer) {
// italicized text - which has an i tag but, usually, no class - iconContainer = document.createElement("span");
// from being mistakenly selected) iconContainer.className = "action-icon";
if ($el.find("i[class]").length > 0) { el.prepend(iconContainer);
const iconHtml = $el.find("i[class]")[0];
if (iconHtml === $el.children()[0]) {
// another check for robustness
icon = $(iconHtml).prop("outerHTML");
}
} }
await renderContent(iconContainer, data.icon);
}
// update the requested properties if (hasDefinedProperty(data, "label")) {
if (hasDefinedProperty(data, "label")) { let labelContainer = el.querySelector<HTMLElement>(
label = data.label; ":scope > .action-label"
);
if (!labelContainer) {
labelContainer = document.createElement("span");
labelContainer.className = "action-label";
el.appendChild(labelContainer);
} }
if (hasDefinedProperty(data, "icon")) { await renderContent(labelContainer, data.label);
// `data.icon` can be an [] if user gave `character(0)`.
icon = Array.isArray(data.icon) ? "" : data.icon ?? "";
}
// produce new html
$el.html(icon + " " + label);
} }
if (hasDefinedProperty(data, "disabled")) { if (hasDefinedProperty(data, "disabled")) {
if (data.disabled) { if (data.disabled) {
$el.attr("disabled", ""); el.setAttribute("disabled", "");
} else { } else {
$el.attr("disabled", null); el.removeAttribute("disabled");
} }
} }
} }

View File

@@ -1,7 +1,14 @@
import type { HtmlDep } from "../../shiny/render";
import { InputBinding } from "./inputBinding"; import { InputBinding } from "./inputBinding";
type ActionButtonReceiveMessageData = { type ActionButtonReceiveMessageData = {
label?: string; label?: {
icon?: string | []; html: string;
deps: HtmlDep[];
};
icon?: {
html: string;
deps: HtmlDep[];
};
disabled?: boolean; disabled?: boolean;
}; };
declare class ActionButtonInputBinding extends InputBinding { declare class ActionButtonInputBinding extends InputBinding {
@@ -13,7 +20,7 @@ declare class ActionButtonInputBinding extends InputBinding {
getState(el: HTMLElement): { getState(el: HTMLElement): {
value: number; value: number;
}; };
receiveMessage(el: HTMLElement, data: ActionButtonReceiveMessageData): void; receiveMessage(el: HTMLElement, data: ActionButtonReceiveMessageData): Promise<void>;
unsubscribe(el: HTMLElement): void; unsubscribe(el: HTMLElement): void;
} }
export { ActionButtonInputBinding }; export { ActionButtonInputBinding };

View File

@@ -0,0 +1,21 @@
# Action button allows icon customization
Code
actionButton("foo", "Click me")
Output
<button id="foo" type="button" class="btn btn-default action-button">
<span class="action-label">Click me</span>
</button>
---
Code
actionButton("foo", "Click me", icon = icon("star"))
Output
<button id="foo" type="button" class="btn btn-default action-button">
<span class="action-icon">
<i class="far fa-star" role="presentation" aria-label="star icon"></i>
</span>
<span class="action-label">Click me</span>
</button>

View File

@@ -55,3 +55,42 @@ test_that("Action link accepts class arguments", {
get_class(make_link("extra extra2")), sub("\"$", " extra extra2\"", act_class) get_class(make_link("extra extra2")), sub("\"$", " extra extra2\"", act_class)
) )
}) })
test_that("Action button allows icon customization", {
# No separator between icon and label
expect_snapshot(actionButton("foo", "Click me"))
# Should include separator between icon and label
expect_snapshot(
actionButton("foo", "Click me", icon = icon("star"))
)
# Warn on a non-HTML icon
expect_warning(
actionButton("foo", "Click me", icon = "not an icon"),
"non-HTML value was provided"
)
# Allows for arbitrary HTML as icon
btn <- expect_no_warning(
actionButton("foo", "Click me", icon = tags$svg())
)
btn2 <- expect_no_warning(
actionButton("foo", "Click me", icon = tagList(tags$svg()))
)
btn3 <- expect_no_warning(
actionButton("foo", "Click me", icon = list(tags$svg()))
)
btn4 <- expect_no_warning(
actionButton("foo", "Click me", icon = HTML("<svg></svg>"))
)
# Ignore newlines+indentation for comparison
as_character <- function(x) {
gsub("\\n\\s*", "", as.character(x))
}
expect_equal(as_character(btn), as_character(btn2))
expect_equal(as_character(btn2), as_character(btn3))
expect_equal(as_character(btn3), as_character(btn4))
})