Compare commits

...

3 Commits

Author SHA1 Message Date
Carson
5d563a00eb Port most everything to bslib 2025-04-25 10:19:04 -05:00
Carson
d4bf6aaab9 Restrict type='submit' behavior specifically to submitButton() 2025-04-25 10:06:12 -05:00
Carson
49c5d29003 First pass at adding textSubmitInput() 2025-04-25 10:06:12 -05:00
21 changed files with 278 additions and 82 deletions

View File

@@ -1,7 +1,7 @@
Package: shiny
Type: Package
Title: Web Application Framework for R
Version: 1.10.0.9000
Version: 1.10.0.9001
Authors@R: c(
person("Winston", "Chang", role = c("aut", "cre"), email = "winston@posit.co", comment = c(ORCID = "0000-0002-1576-2126")),
person("Joe", "Cheng", role = "aut", email = "joe@posit.co"),

20
NEWS.md
View File

@@ -1,6 +1,20 @@
# shiny (development version)
## New features and improvements
## New features
* `textInput()`, `textAreaInput()`, `numericInput()` and `passwordInput()` all gain an `updateOn` option. `updateOn = "change"` is the default and previous behavior, where the input value updates immediately whenever the value changes. With `updateOn = "blur"`, the input value will update only when the text input loses focus or when the user presses Enter (or Cmd/Ctrl + Enter for `textAreaInput()`). (#4183)
* `textAreaInput()` gains a `autoresize` option, which automatically resizes the text area to fit its content.
* The `callback` argument of Shiny.js' `InputBinding.subscribe()` method gains support for a value of `"event"`. This makes it possible for an input binding to use event priority when updating the value (i.e., send immediately and always resend, even if the value hasn't changed).
## 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.
## Improvements
* When auto-reload is enabled, Shiny now reloads the entire app when support files, like Shiny modules, additional script files, or web assets, change. To enable auto-reload, call `devmode(TRUE)` to enable Shiny's developer mode, or set `options(shiny.autoreload = TRUE)` to specifically enable auto-reload. You can choose which files are watched for changes with the `shiny.autoreload.pattern` option. (#4184)
* When busy indicators are enabled (i.e., `useBusyIndicators()`), Shiny now:
* Shows a spinner on recalculating htmlwidgets that have previously rendered an error (including `req()` and `validate()`). (#4172)
@@ -11,10 +25,6 @@
* Shiny's Typescript assets are now compiled to ES2021 instead of ES5. (#4066)
* `textInput()`, `textAreaInput()`, `numericInput()` and `passwordInput()` all gain an `updateOn` option. `updateOn = "change"` is the default and previous behavior, where the input value updates immediately whenever the value changes. With `updateOn = "blur"`, the input value will update only when the text input loses focus or when the user presses Enter (or Cmd/Ctrl + Enter for `textAreaInput()`). (#4183)
* When auto-reload is enabled, Shiny now reloads the entire app when support files, like Shiny modules, additional script files, or web assets, change. To enable auto-reload, call `devmode(TRUE)` to enable Shiny's developer mode, or set `options(shiny.autoreload = TRUE)` to specifically enable auto-reload. You can choose which files are watched for changes with the `shiny.autoreload.pattern` option. (#4184)
## Bug fixes
* Fixed a bug with modals where calling `removeModal()` too quickly after `showModal()` would fail to remove the modal if the remove modal message was received while the modal was in the process of being revealed. (#4173)

View File

@@ -57,7 +57,7 @@ submitButton <- function(text = "Apply Changes", icon = NULL, width = NULL) {
div(
tags$button(
type="submit",
class="btn btn-primary",
class="btn btn-primary submit-button",
style = css(width = validateCssUnit(width)),
list(icon, text)
)

View File

@@ -16,6 +16,8 @@
#' @param resize Which directions the textarea box can be resized. Can be one of
#' `"both"`, `"none"`, `"vertical"`, and `"horizontal"`. The default, `NULL`,
#' will use the client browser's default setting for resizing textareas.
#' @param autoresize If `TRUE`, the textarea will automatically resize to fit
#' the input text.
#' @return A textarea input control that can be added to a UI definition.
#'
#' @family input elements
@@ -52,6 +54,7 @@ textAreaInput <- function(
placeholder = NULL,
resize = NULL,
...,
autoresize = FALSE,
updateOn = c("change", "blur")
) {
rlang::check_dots_empty()
@@ -63,22 +66,27 @@ textAreaInput <- function(
resize <- match.arg(resize, c("both", "none", "vertical", "horizontal"))
}
style <- css(
# The width is specified on the parent div.
width = if (!is.null(width)) "100%",
height = validateCssUnit(height),
resize = resize
)
classes <- c("shiny-input-textarea", "form-control")
if (autoresize) {
classes <- c(classes, "textarea-autoresize")
if (is.null(rows)) {
rows <- 1
}
}
div(
class = "form-group shiny-input-container",
style = css(width = validateCssUnit(width)),
shinyInputLabel(inputId, label),
style = if (!is.null(width)) paste0("width: ", validateCssUnit(width), ";"),
tags$textarea(
id = inputId,
class = "shiny-input-textarea form-control",
class = classes,
placeholder = placeholder,
style = style,
style = css(
width = if (!is.null(width)) "100%",
height = validateCssUnit(height),
resize = resize
),
rows = rows,
cols = cols,
`data-update-on` = updateOn,

View File

@@ -2196,6 +2196,55 @@
return (0, import_jquery20.default)(scope).find("textarea");
}
};
function onDelegatedEvent(eventName, selector, callback) {
document.addEventListener(eventName, (e4) => {
const e22 = e4;
if (e22.target.matches(selector)) {
callback(e22.target);
}
});
}
var textAreaIntersectionObserver = null;
function callUpdateHeightWhenTargetIsVisible(target) {
if (textAreaIntersectionObserver === null) {
textAreaIntersectionObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
textAreaIntersectionObserver?.unobserve(entry.target);
updateHeight(entry.target);
});
});
}
textAreaIntersectionObserver.observe(target);
}
function updateHeight(target) {
if (target.scrollHeight > 0) {
target.style.height = "auto";
target.style.height = target.scrollHeight + "px";
} else {
callUpdateHeightWhenTargetIsVisible(target);
}
}
onDelegatedEvent(
"input",
"textarea.textarea-autoresize",
(target) => {
updateHeight(target);
}
);
function updateOnLoad() {
if (document.readyState === "loading") {
setTimeout(updateOnLoad, 10);
return;
}
const textAreas = document.querySelectorAll(
"textarea.textarea-autoresize"
);
textAreas.forEach(updateHeight);
}
updateOnLoad();
// srcts/src/bindings/input/index.ts
function initInputBindings() {
@@ -5560,19 +5609,14 @@
function isJQuery(value) {
return Boolean(value && value.jquery);
}
function valueChangeCallback(inputs, binding, el, allowDeferred) {
function valueChangeCallback(inputs, binding, el, priority) {
let id = binding.getId(el);
if (id) {
const value = binding.getValue(el);
const type = binding.getType(el);
if (type)
id = id + ":" + type;
const opts = {
priority: allowDeferred ? "deferred" : "immediate",
binding,
el
};
inputs.setInput(id, value, opts);
inputs.setInput(id, value, { priority, binding, el });
}
}
var bindingsRegistry = (() => {
@@ -5687,8 +5731,9 @@ ${duplicateIdMsg}`;
const thisCallback = function() {
const thisBinding = binding;
const thisEl = el;
return function(allowDeferred) {
valueChangeCallback(inputs, thisBinding, thisEl, allowDeferred);
return function(priority) {
const normalizedPriority = typeof priority !== "boolean" ? priority : priority ? "deferred" : "immediate";
valueChangeCallback(inputs, thisBinding, thisEl, normalizedPriority);
};
}();
binding.subscribe(el, thisCallback);
@@ -7190,10 +7235,10 @@ ${duplicateIdMsg}`;
const inputsRate = new InputRateDecorator(inputsEvent);
const inputsDefer = new InputDeferDecorator(inputsEvent);
let target;
if ((0, import_jquery39.default)('input[type="submit"], button[type="submit"]').length > 0) {
if (document.querySelector(".submit-button")) {
target = inputsDefer;
(0, import_jquery39.default)('input[type="submit"], button[type="submit"]').each(function() {
(0, import_jquery39.default)(this).click(function(event) {
document.querySelectorAll(".submit-button").forEach(function(x2) {
x2.addEventListener("click", function(event) {
event.preventDefault();
inputsDefer.submit();
});

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

@@ -384,6 +384,14 @@ html.autoreload-enabled #shiny-disconnected-overlay.reloading {
width: 100%;
}
/* Styling for inputTextArea(autoresize=TRUE) */
.textarea-autoresize textarea.form-control {
padding: 5px 8px;
resize: none;
overflow-y: hidden;
height: auto;
}
#shiny-notification-panel {
position: fixed;

View File

@@ -15,6 +15,7 @@ textAreaInput(
placeholder = NULL,
resize = NULL,
...,
autoresize = FALSE,
updateOn = c("change", "blur")
)
}
@@ -52,6 +53,9 @@ will use the client browser's default setting for resizing textareas.}
\item{...}{Ignored, included to require named arguments and for future
feature expansion.}
\item{autoresize}{If \code{TRUE}, the textarea will automatically resize to fit
the input text.}
\item{updateOn}{A character vector specifying when the input should be
updated. Options are \code{"change"} (default) and \code{"blur"}. Use \code{"change"} to
update the input immediately whenever the value changes. Use \code{"blur"}to

View File

@@ -0,0 +1,6 @@
.textarea-autoresize textarea.form-control {
padding: 5px 8px;
resize: none;
overflow-y: hidden;
height: auto;
}

View File

@@ -1,3 +1,4 @@
import type { EventPriority } from "../../inputPolicies/inputPolicy";
import type { RatePolicyModes } from "../../inputPolicies/inputRateDecorator";
import type { BindScope } from "../../shiny/bind";
@@ -26,10 +27,16 @@ class InputBinding {
el; // unused var
}
// The callback method takes one argument, whose value is boolean. If true,
// allow deferred (debounce or throttle) sending depending on the value of
// getRatePolicy. If false, send value immediately. Default behavior is `false`
subscribe(el: HTMLElement, callback: (value: boolean) => void): void {
// Historically, the callback value could only be boolean. In this case:
// * false: send value immediately (i.e., priority = "immediate")
// * true: send value later (i.e., priority = "deferred")
// * The input rate policy is also consulted on whether to debounce or throttle
// In recent versions, the value can also be "event", meaning that the
// value should be sent regardless of whether it has changed.
subscribe(
el: HTMLElement,
callback: (value: EventPriority | boolean) => void
): void {
// empty
el; // unused var
callback; // unused var

View File

@@ -1,6 +1,7 @@
import $ from "jquery";
import { $escape, hasDefinedProperty, updateLabel } from "../../utils";
import type { EventPriority } from "../../inputPolicies/inputPolicy";
import { InputBinding } from "./inputBinding";
// interface TextHTMLElement extends NameValueHTMLElement {
@@ -49,7 +50,10 @@ class TextInputBindingBase extends InputBinding {
value;
}
subscribe(el: TextHTMLElement, callback: (x: boolean) => void): void {
subscribe(
el: TextHTMLElement,
callback: (x: EventPriority | boolean) => void
): void {
const $el = $(el);
const updateOn = $el.data("update-on") || "change";

View File

@@ -2,11 +2,96 @@ import $ from "jquery";
import { TextInputBinding } from "./text";
class TextareaInputBinding extends TextInputBinding {
export class TextareaInputBinding extends TextInputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
// Inputs now also have the .shiny-input-textarea class
return $(scope).find("textarea");
}
}
export { TextareaInputBinding };
/*************************************************************
* Code below this point is for textAreaInput(autoresize=TRUE)
************************************************************/
interface DOMEvent<T extends EventTarget> extends Event {
readonly target: T;
}
function onDelegatedEvent(
eventName: string,
selector: string,
callback: (target: HTMLTextAreaElement) => void
) {
document.addEventListener(eventName, (e) => {
const e2 = e as DOMEvent<HTMLTextAreaElement>;
if (e2.target.matches(selector)) {
callback(e2.target);
}
});
}
// Use a single intersectionObserver as they are slow to create / use.
let textAreaIntersectionObserver: IntersectionObserver | null = null;
function callUpdateHeightWhenTargetIsVisible(target: HTMLTextAreaElement) {
if (textAreaIntersectionObserver === null) {
// Create a single observer to watch for the textarea becoming visible
textAreaIntersectionObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// Quit if the entry is not visible
if (!entry.isIntersecting) {
return;
}
// If the entry is visible (even if it's just a single pixel)
// Stop observing the target
textAreaIntersectionObserver?.unobserve(entry.target);
// Update the height of the textarea
updateHeight(entry.target as HTMLTextAreaElement);
});
});
}
textAreaIntersectionObserver.observe(target);
}
function updateHeight(target: HTMLTextAreaElement) {
if (target.scrollHeight > 0) {
// Automatically resize the textarea to fit its content.
target.style.height = "auto";
target.style.height = target.scrollHeight + "px";
} else {
// The textarea is not visible on the page, therefore it has a 0 scroll height.
// If we should autoresize the text area height, then we can wait for the textarea to
// become visible and call `updateHeight` again. Hopefully the scroll height is no
// longer 0
callUpdateHeightWhenTargetIsVisible(target);
}
}
// Update on change
onDelegatedEvent(
"input",
"textarea.textarea-autoresize",
(target: HTMLTextAreaElement) => {
updateHeight(target);
}
);
// Update on load
function updateOnLoad() {
if (document.readyState === "loading") {
// Document still loading, wait 10ms to check again.
setTimeout(updateOnLoad, 10);
return;
}
// document.readyState in ["interactive", "complete"];\
const textAreas = document.querySelectorAll(
"textarea.textarea-autoresize"
) as NodeListOf<HTMLTextAreaElement>;
textAreas.forEach(updateHeight);
}
updateOnLoad();

View File

@@ -8,6 +8,7 @@ import type {
InputRateDecorator,
InputValidateDecorator,
} from "../inputPolicies";
import type { InputPolicyOpts } from "../inputPolicies/inputPolicy";
import { shinyAppBindOutput, shinyAppUnbindOutput } from "./initedMethods";
import { sendImageSizeFns } from "./sendImageSize";
@@ -27,7 +28,7 @@ function valueChangeCallback(
inputs: InputValidateDecorator,
binding: InputBinding,
el: HTMLElement,
allowDeferred: boolean
priority: InputPolicyOpts["priority"]
) {
let id = binding.getId(el);
@@ -37,17 +38,7 @@ function valueChangeCallback(
if (type) id = id + ":" + type;
const opts: {
priority: "deferred" | "immediate";
binding: typeof binding;
el: typeof el;
} = {
priority: allowDeferred ? "deferred" : "immediate",
binding: binding,
el: el,
};
inputs.setInput(id, value, opts);
inputs.setInput(id, value, { priority, binding, el });
}
}
@@ -272,8 +263,17 @@ function bindInputs(
const thisBinding = binding;
const thisEl = el;
return function (allowDeferred: boolean) {
valueChangeCallback(inputs, thisBinding, thisEl, allowDeferred);
// Historically speaking, this callback has only accepted a boolean value,
// but in recent versions it can also accept a input priority.
return function (priority: InputPolicyOpts["priority"] | boolean) {
const normalizedPriority =
typeof priority !== "boolean"
? priority
: priority
? "deferred"
: "immediate";
valueChangeCallback(inputs, thisBinding, thisEl, normalizedPriority);
};
})();

View File

@@ -177,12 +177,12 @@ class ShinyClass {
let target: InputPolicy;
if ($('input[type="submit"], button[type="submit"]').length > 0) {
if (document.querySelector(".submit-button")) {
// If there is a submit button on the page, use defer decorator
target = inputsDefer;
$('input[type="submit"], button[type="submit"]').each(function () {
$(this).click(function (event) {
document.querySelectorAll(".submit-button").forEach(function (x) {
x.addEventListener("click", function (event) {
event.preventDefault();
inputsDefer.submit();
});

View File

@@ -1,3 +1,4 @@
import type { EventPriority } from "../../inputPolicies/inputPolicy";
import type { RatePolicyModes } from "../../inputPolicies/inputRateDecorator";
import type { BindScope } from "../../shiny/bind";
declare class InputBinding {
@@ -6,7 +7,7 @@ declare class InputBinding {
getId(el: HTMLElement): string;
getType(el: HTMLElement): string | null;
getValue(el: HTMLElement): any;
subscribe(el: HTMLElement, callback: (value: boolean) => void): void;
subscribe(el: HTMLElement, callback: (value: EventPriority | boolean) => void): void;
unsubscribe(el: HTMLElement): void;
receiveMessage(el: HTMLElement, data: unknown): Promise<void> | void;
getState(el: HTMLElement): unknown;

View File

@@ -1,3 +1,4 @@
import type { EventPriority } from "../../inputPolicies/inputPolicy";
import { InputBinding } from "./inputBinding";
type TextHTMLElement = HTMLInputElement;
type TextReceiveMessageData = {
@@ -10,7 +11,7 @@ declare class TextInputBindingBase extends InputBinding {
getId(el: TextHTMLElement): string;
getValue(el: TextHTMLElement): unknown;
setValue(el: TextHTMLElement, value: unknown): void;
subscribe(el: TextHTMLElement, callback: (x: boolean) => void): void;
subscribe(el: TextHTMLElement, callback: (x: EventPriority | boolean) => void): void;
unsubscribe(el: TextHTMLElement): void;
receiveMessage(el: TextHTMLElement, data: unknown): void;
getState(el: TextHTMLElement): unknown;

View File

@@ -1,5 +1,4 @@
import { TextInputBinding } from "./text";
declare class TextareaInputBinding extends TextInputBinding {
export declare class TextareaInputBinding extends TextInputBinding {
find(scope: HTMLElement): JQuery<HTMLElement>;
}
export { TextareaInputBinding };

View File

@@ -0,0 +1,18 @@
import type { EventPriority } from "../../inputPolicies/inputPolicy";
import { InputBinding } from "./inputBinding";
type TextHTMLElement = HTMLInputElement;
type TextSubmitReceiveMessageData = {
value?: string;
placeholder?: string;
submit?: boolean;
focus?: boolean;
};
declare class TextSubmitInputBinding extends InputBinding {
find(scope: HTMLElement): JQuery<HTMLElement>;
getValue(el: TextHTMLElement): string;
setValue(el: TextHTMLElement, value: string): void;
subscribe(el: TextHTMLElement, callback: (x: EventPriority | boolean) => void): void;
unsubscribe(el: HTMLElement): void;
receiveMessage(el: TextHTMLElement, data: TextSubmitReceiveMessageData): void;
}
export { TextSubmitInputBinding };