mirror of
https://github.com/rstudio/shiny.git
synced 2026-04-29 03:00:45 -04:00
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:
@@ -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
12
NEWS.md
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
21
R/utils-tags.R
Normal 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)))
|
||||||
|
}
|
||||||
@@ -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
2
inst/www/shared/shiny.min.css
vendored
2
inst/www/shared/shiny.min.css
vendored
File diff suppressed because one or more lines are too long
26
inst/www/shared/shiny.min.js
vendored
26
inst/www/shared/shiny.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
srcts/types/src/bindings/input/actionbutton.d.ts
vendored
13
srcts/types/src/bindings/input/actionbutton.d.ts
vendored
@@ -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 };
|
||||||
|
|||||||
21
tests/testthat/_snaps/actionButton.md
Normal file
21
tests/testthat/_snaps/actionButton.md
Normal 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>
|
||||||
|
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user