Allow update input labels with HTML (#3996)

* fix: allow update input labels with HTML fixes #3995

* refactor: use processDeps and renderContent

* fix: formatting on lists

* fix: put spaces between infix

* chore: generated files

* fix: update input tests

* revert: generated javascript and sourcemaps

* fix: empty label check

* Remove package-lock

* Undo unintended change when merging

* Update news

* Simplify

---------

Co-authored-by: Carson <cpsievert1@gmail.com>
This commit is contained in:
John Coene
2025-06-16 18:01:44 +02:00
committed by GitHub
parent e8b7c08a19
commit db9f210257
25 changed files with 972 additions and 911 deletions

View File

@@ -6,6 +6,8 @@
* `textAreaInput()` gains a `autoresize` option, which automatically resizes the text area to fit its content. (#4210)
* The family of `update*Input()` functions can now render HTML content passed to the `label` argument (e.g., `updateInputText(label = tags$b("New label"))`). (#3996)
## Changes
* Shiny no longer suspends input changes when _any_ `<input type="submit">` or `<button type="submit">` is on the page. Instead, it now only suspends when a `submitButton()` is present. If you have reason for creating a submit button from custom HTML, add a CSS class of `shiny-submit-button` to the button. (#4209)

View File

@@ -37,7 +37,11 @@
updateTextInput <- function(session = getDefaultReactiveDomain(), inputId, label = NULL, value = NULL, placeholder = NULL) {
validate_session_object(session)
message <- dropNulls(list(label=label, value=value, placeholder=placeholder))
message <- dropNulls(list(
label = processDeps(label, session),
value = value,
placeholder = placeholder
))
session$sendInputMessage(inputId, message)
}
@@ -111,7 +115,10 @@ updateTextAreaInput <- updateTextInput
updateCheckboxInput <- function(session = getDefaultReactiveDomain(), inputId, label = NULL, value = NULL) {
validate_session_object(session)
message <- dropNulls(list(label=label, value=value))
message <- dropNulls(list(
label = processDeps(label, session),
value = value
))
session$sendInputMessage(inputId, message)
}
@@ -175,13 +182,17 @@ updateActionButton <- function(session = getDefaultReactiveDomain(), inputId, la
validate_session_object(session)
if (!is.null(icon)) icon <- as.character(validateIcon(icon))
message <- dropNulls(list(label=label, icon=icon, disabled=disabled))
message <- dropNulls(list(
label = processDeps(label, session),
icon = icon,
disabled = disabled
))
session$sendInputMessage(inputId, message)
}
#' @rdname updateActionButton
#' @export
updateActionLink <- function(session = getDefaultReactiveDomain(), inputId, label = NULL, icon = NULL) {
updateActionButton(session, inputId=inputId, label=label, icon=icon)
updateActionButton(session, inputId = inputId, label = processDeps(label, session), icon = icon)
}
@@ -225,7 +236,12 @@ updateDateInput <- function(session = getDefaultReactiveDomain(), inputId, label
min <- dateYMD(min, "min")
max <- dateYMD(max, "max")
message <- dropNulls(list(label=label, value=value, min=min, max=max))
message <- dropNulls(list(
label = processDeps(label, session),
value = value,
min = min,
max = max
))
session$sendInputMessage(inputId, message)
}
@@ -275,7 +291,7 @@ updateDateRangeInput <- function(session = getDefaultReactiveDomain(), inputId,
max <- dateYMD(max, "max")
message <- dropNulls(list(
label = label,
label = processDeps(label, session),
value = dropNulls(list(start = start, end = end)),
min = min,
max = max
@@ -374,13 +390,16 @@ updateNavlistPanel <- updateTabsetPanel
#' }
#' @export
updateNumericInput <- function(session = getDefaultReactiveDomain(), inputId, label = NULL, value = NULL,
min = NULL, max = NULL, step = NULL) {
min = NULL, max = NULL, step = NULL) {
validate_session_object(session)
message <- dropNulls(list(
label = label, value = formatNoSci(value),
min = formatNoSci(min), max = formatNoSci(max), step = formatNoSci(step)
label = processDeps(label, session),
value = formatNoSci(value),
min = formatNoSci(min),
max = formatNoSci(max),
step = formatNoSci(step)
))
session$sendInputMessage(inputId, message)
}
@@ -460,7 +479,7 @@ updateSliderInput <- function(session = getDefaultReactiveDomain(), inputId, lab
}
message <- dropNulls(list(
label = label,
label = processDeps(label, session),
value = formatNoSci(value),
min = formatNoSci(min),
max = formatNoSci(max),
@@ -491,7 +510,11 @@ updateInputOptions <- function(session, inputId, label = NULL, choices = NULL,
))
}
message <- dropNulls(list(label = label, options = options, value = selected))
message <- dropNulls(list(
label = processDeps(label, session),
options = options,
value = selected
))
session$sendInputMessage(inputId, message)
}
@@ -644,7 +667,11 @@ updateSelectInput <- function(session = getDefaultReactiveDomain(), inputId, lab
choices <- if (!is.null(choices)) choicesWithNames(choices)
if (!is.null(selected)) selected <- as.character(selected)
options <- if (!is.null(choices)) selectOptions(choices, selected, inputId, FALSE)
message <- dropNulls(list(label = label, options = options, value = selected))
message <- dropNulls(list(
label = processDeps(label, session),
options = options,
value = selected
))
session$sendInputMessage(inputId, message)
}

File diff suppressed because it is too large Load Diff

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

@@ -113,10 +113,10 @@ class CheckboxGroupInputBinding extends InputBinding {
options: options,
};
}
receiveMessage(
async receiveMessage(
el: CheckboxGroupHTMLElement,
data: CheckboxGroupReceiveMessageData
): void {
): Promise<void> {
const $el = $(el);
// This will replace all the options
@@ -132,7 +132,7 @@ class CheckboxGroupInputBinding extends InputBinding {
this.setValue(el, data.value);
}
updateLabel(data.label, getLabelNode(el));
await updateLabel(data.label, getLabelNode(el));
$(el).trigger("change");
}

View File

@@ -292,10 +292,13 @@ class DateInputBinding extends DateInputBindingBase {
startview: startview,
};
}
receiveMessage(el: HTMLElement, data: DateReceiveMessageData): void {
async receiveMessage(
el: HTMLElement,
data: DateReceiveMessageData
): Promise<void> {
const $input = $(el).find("input");
updateLabel(data.label, this._getLabelNode(el));
await updateLabel(data.label, this._getLabelNode(el));
if (hasDefinedProperty(data, "min")) this._setMin($input[0], data.min);

View File

@@ -106,13 +106,16 @@ class DateRangeInputBinding extends DateInputBindingBase {
startview: startview,
};
}
receiveMessage(el: HTMLElement, data: DateRangeReceiveMessageData): void {
async receiveMessage(
el: HTMLElement,
data: DateRangeReceiveMessageData
): Promise<void> {
const $el = $(el);
const $inputs = $el.find("input");
const $startinput = $inputs.eq(0);
const $endinput = $inputs.eq(1);
updateLabel(data.label, getLabelNode(el));
await updateLabel(data.label, getLabelNode(el));
if (hasDefinedProperty(data, "min")) {
this._setMin($startinput[0], data.min);

View File

@@ -51,7 +51,10 @@ class NumberInputBinding extends TextInputBindingBase {
return "shiny.number";
el;
}
receiveMessage(el: NumberHTMLElement, data: NumberReceiveMessageData): void {
async receiveMessage(
el: NumberHTMLElement,
data: NumberReceiveMessageData
): Promise<void> {
// Setting values to `""` will remove the attribute value from the DOM element.
// The attr key will still remain, but there is not value... ex: `<input id="foo" type="number" min max/>`
if (hasDefinedProperty(data, "value")) el.value = data.value ?? "";
@@ -59,7 +62,7 @@ class NumberInputBinding extends TextInputBindingBase {
if (hasDefinedProperty(data, "max")) el.max = data.max ?? "";
if (hasDefinedProperty(data, "step")) el.step = data.step ?? "";
updateLabel(data.label, getLabelNode(el));
await updateLabel(data.label, getLabelNode(el));
$(el).trigger("change");
}

View File

@@ -103,7 +103,10 @@ class RadioInputBinding extends InputBinding {
options: options,
};
}
receiveMessage(el: RadioHTMLElement, data: RadioReceiveMessageData): void {
async receiveMessage(
el: RadioHTMLElement,
data: RadioReceiveMessageData
): Promise<void> {
const $el = $(el);
// This will replace all the options
@@ -122,7 +125,7 @@ class RadioInputBinding extends InputBinding {
this.setValue(el, data.value);
}
updateLabel(data.label, getLabelNode(el));
await updateLabel(data.label, getLabelNode(el));
$(el).trigger("change");
}

View File

@@ -102,10 +102,10 @@ class SelectInputBinding extends InputBinding {
options: options,
};
}
receiveMessage(
async receiveMessage(
el: SelectHTMLElement,
data: SelectInputReceiveMessageData
): void {
): Promise<void> {
const $el = $(el);
// This will replace all the options
@@ -205,7 +205,7 @@ class SelectInputBinding extends InputBinding {
this.setValue(el, data.value);
}
updateLabel(data.label, getLabelNode(el));
await updateLabel(data.label, getLabelNode(el));
$(el).trigger("change");
}

View File

@@ -179,7 +179,10 @@ class SliderInputBinding extends TextInputBindingBase {
unsubscribe(el: HTMLElement): void {
$(el).off(".sliderInputBinding");
}
receiveMessage(el: HTMLElement, data: SliderReceiveMessageData): void {
async receiveMessage(
el: HTMLElement,
data: SliderReceiveMessageData
): Promise<void> {
const $el = $(el);
const slider = $el.data("ionRangeSlider");
const msg: {
@@ -226,7 +229,7 @@ class SliderInputBinding extends TextInputBindingBase {
}
}
updateLabel(data.label, getLabelNode(el));
await updateLabel(data.label, getLabelNode(el));
// (maybe) update data elements
const domElements: Array<"data-type" | "time-format" | "timezone"> = [

View File

@@ -126,10 +126,13 @@ class TextInputBinding extends TextInputBindingBase {
placeholder: el.placeholder,
};
}
receiveMessage(el: TextHTMLElement, data: TextReceiveMessageData): void {
async receiveMessage(
el: TextHTMLElement,
data: TextReceiveMessageData
): Promise<void> {
if (hasDefinedProperty(data, "value")) this.setValue(el, data.value);
updateLabel(data.label, getLabelNode(el));
await updateLabel(data.label, getLabelNode(el));
if (hasDefinedProperty(data, "placeholder"))
el.placeholder = data.placeholder;

View File

@@ -1,4 +1,6 @@
import $ from "jquery";
import type { HtmlDep } from "../shiny/render";
import { renderContent } from "../shiny/render";
import { windowDevicePixelRatio } from "../window/pixelRatio";
import type { MapValuesUnion, MapWithResult } from "./extraTypes";
import { hasDefinedProperty, hasOwnProperty } from "./object";
@@ -351,23 +353,27 @@ const compareVersion = function (
else throw `Unknown operator: ${op}`;
};
function updateLabel(
labelTxt: string | undefined,
async function updateLabel(
labelContent: string | { html: string; deps: HtmlDep[] } | undefined,
labelNode: JQuery<HTMLElement>
): void {
): Promise<void> {
// Only update if label was specified in the update method
if (typeof labelTxt === "undefined") return;
if (typeof labelContent === "undefined") return;
if (labelNode.length !== 1) {
throw new Error("labelNode must be of length 1");
}
// Should the label be empty?
const emptyLabel = Array.isArray(labelTxt) && labelTxt.length === 0;
if (typeof labelContent === "string") {
labelContent = {
html: labelContent,
deps: [],
};
}
if (emptyLabel) {
if (labelContent.html === "") {
labelNode.addClass("shiny-label-null");
} else {
labelNode.text(labelTxt);
await renderContent(labelNode, labelContent);
labelNode.removeClass("shiny-label-null");
}
}

View File

@@ -20,7 +20,7 @@ declare class CheckboxGroupInputBinding extends InputBinding {
value: ReturnType<CheckboxGroupInputBinding["getValue"]>;
options: ValueLabelObject[];
};
receiveMessage(el: CheckboxGroupHTMLElement, data: CheckboxGroupReceiveMessageData): void;
receiveMessage(el: CheckboxGroupHTMLElement, data: CheckboxGroupReceiveMessageData): Promise<void>;
subscribe(el: CheckboxGroupHTMLElement, callback: (x: boolean) => void): void;
unsubscribe(el: CheckboxGroupHTMLElement): void;
}

View File

@@ -52,7 +52,7 @@ declare class DateInputBinding extends DateInputBindingBase {
format: string;
startview: DatepickerViewModes;
};
receiveMessage(el: HTMLElement, data: DateReceiveMessageData): void;
receiveMessage(el: HTMLElement, data: DateReceiveMessageData): Promise<void>;
}
export { DateInputBinding, DateInputBindingBase };
export type { DateReceiveMessageData };

View File

@@ -27,7 +27,7 @@ declare class DateRangeInputBinding extends DateInputBindingBase {
language: string;
startview: string;
};
receiveMessage(el: HTMLElement, data: DateRangeReceiveMessageData): void;
receiveMessage(el: HTMLElement, data: DateRangeReceiveMessageData): Promise<void>;
initialize(el: HTMLElement): void;
subscribe(el: HTMLElement, callback: (x: boolean) => void): void;
unsubscribe(el: HTMLElement): void;

View File

@@ -12,7 +12,7 @@ declare class NumberInputBinding extends TextInputBindingBase {
getValue(el: NumberHTMLElement): string[] | number | string | null | undefined;
setValue(el: NumberHTMLElement, value: number): void;
getType(el: NumberHTMLElement): string;
receiveMessage(el: NumberHTMLElement, data: NumberReceiveMessageData): void;
receiveMessage(el: NumberHTMLElement, data: NumberReceiveMessageData): Promise<void>;
getState(el: NumberHTMLElement): {
label: string;
value: ReturnType<NumberInputBinding["getValue"]>;

View File

@@ -18,7 +18,7 @@ declare class RadioInputBinding extends InputBinding {
value: ReturnType<RadioInputBinding["getValue"]>;
options: ValueLabelObject[];
};
receiveMessage(el: RadioHTMLElement, data: RadioReceiveMessageData): void;
receiveMessage(el: RadioHTMLElement, data: RadioReceiveMessageData): Promise<void>;
subscribe(el: RadioHTMLElement, callback: (x: boolean) => void): void;
unsubscribe(el: RadioHTMLElement): void;
}

View File

@@ -28,7 +28,7 @@ declare class SelectInputBinding extends InputBinding {
label: string;
}>;
};
receiveMessage(el: SelectHTMLElement, data: SelectInputReceiveMessageData): void;
receiveMessage(el: SelectHTMLElement, data: SelectInputReceiveMessageData): Promise<void>;
subscribe(el: SelectHTMLElement, callback: (x: boolean) => void): void;
unsubscribe(el: HTMLElement): void;
initialize(el: SelectHTMLElement): void;

View File

@@ -26,7 +26,7 @@ declare class SliderInputBinding extends TextInputBindingBase {
setValue(el: HTMLElement, value: number | string | [number | string, number | string]): void;
subscribe(el: HTMLElement, callback: (x: boolean) => void): void;
unsubscribe(el: HTMLElement): void;
receiveMessage(el: HTMLElement, data: SliderReceiveMessageData): void;
receiveMessage(el: HTMLElement, data: SliderReceiveMessageData): Promise<void>;
getRatePolicy(el: HTMLElement): {
policy: "debounce";
delay: 250;

View File

@@ -27,7 +27,7 @@ declare class TextInputBinding extends TextInputBindingBase {
value: string;
placeholder: string;
};
receiveMessage(el: TextHTMLElement, data: TextReceiveMessageData): void;
receiveMessage(el: TextHTMLElement, data: TextReceiveMessageData): Promise<void>;
}
export { TextInputBinding, TextInputBindingBase };
export type { TextHTMLElement, TextReceiveMessageData };

View File

@@ -1,3 +1,4 @@
import type { HtmlDep } from "../shiny/render";
import type { MapValuesUnion, MapWithResult } from "./extraTypes";
import { hasDefinedProperty, hasOwnProperty } from "./object";
declare function escapeHTML(str: string): string;
@@ -26,7 +27,10 @@ declare function isnan(x: unknown): boolean;
declare function _equal(x: unknown, y: unknown): boolean;
declare function equal(...args: unknown[]): boolean;
declare const compareVersion: (a: string, op: "<" | "<=" | "==" | ">" | ">=", b: string) => boolean;
declare function updateLabel(labelTxt: string | undefined, labelNode: JQuery<HTMLElement>): void;
declare function updateLabel(labelContent: string | {
html: string;
deps: HtmlDep[];
} | undefined, labelNode: JQuery<HTMLElement>): Promise<void>;
declare function getComputedLinkColor(el: HTMLElement): string;
declare function isBS3(): boolean;
declare function toLowerCase<T extends string>(str: T): Lowercase<T>;

View File

@@ -16,18 +16,19 @@ test_that("Radio buttons and checkboxes work with modules", {
resultA <- sessA$lastInputMessage
expect_equal(resultA$id, "test1")
expect_equal(resultA$message$label, "Label")
expect_equal(as.character(resultA$message$label$html), "Label")
expect_equal(resultA$message$value, "a")
expect_match(resultA$message$options, '"modA-test1"')
expect_no_match(resultA$message$options, '"test1"')
sessB <- createModuleSession("modB")
updateCheckboxGroupInput(sessB, "test2", label = "Label", choices = LETTERS[1:5])
updateCheckboxGroupInput(sessB, "test2", label = icon("eye"), choices = LETTERS[1:5])
resultB <- sessB$lastInputMessage
expect_equal(resultB$id, "test2")
expect_equal(resultB$message$label, "Label")
expect_length(resultB$message$label, 2)
expect_s3_class(resultB$message$label$html, "html")
expect_null(resultB$message$value)
expect_match(resultB$message$options, '"modB-test2"')
expect_no_match(resultB$message$options, '"test2"')