feat(textAreaInput): Add an autoresize option (#4210)

* feat(textAreaInput): Add an autoresize option

* `devtools::document()` (GitHub Actions)

* `yarn build` (GitHub Actions)

* Update NEWS.md

* Fix broken CSS selector.

Rules aren't being applied correctly in PyShiny either...

* Put shiny input class on container (to mirror what PyShiny does)

* Refactor autoresize logic

* Reduce diff size

---------

Co-authored-by: cpsievert <cpsievert@users.noreply.github.com>
This commit is contained in:
Carson Sievert
2025-04-30 18:34:04 -05:00
committed by GitHub
parent f79a22b987
commit 316c3c8409
11 changed files with 160 additions and 42 deletions

14
NEWS.md
View File

@@ -1,6 +1,14 @@
# 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. (#4210)
## 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 +19,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

@@ -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 <- "form-control"
if (autoresize) {
classes <- c(classes, "textarea-autoresize")
if (is.null(rows)) {
rows <- 1
}
}
div(
class = "form-group shiny-input-container",
class = "shiny-input-textarea 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

@@ -26,11 +26,20 @@
if (!member.has(obj))
throw TypeError("Cannot " + msg);
};
var __privateGet = (obj, member, getter) => {
__accessCheck(obj, member, "read from private field");
return getter ? getter.call(obj) : member.get(obj);
};
var __privateAdd = (obj, member, value) => {
if (member.has(obj))
throw TypeError("Cannot add the same private member more than once");
member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
};
var __privateSet = (obj, member, value, setter) => {
__accessCheck(obj, member, "write to private field");
setter ? setter.call(obj, value) : member.set(obj, value);
return value;
};
var __privateMethod = (obj, member, method) => {
__accessCheck(obj, member, "access private method");
return method;
@@ -2191,11 +2200,50 @@
// srcts/src/bindings/input/textarea.ts
var import_jquery20 = __toESM(require_jquery());
var intersectObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
updateHeight(entry.target);
}
});
});
var _inputHandler;
var TextareaInputBinding = class extends TextInputBinding {
constructor() {
super(...arguments);
__privateAdd(this, _inputHandler, null);
}
find(scope) {
return (0, import_jquery20.default)(scope).find("textarea");
}
initialize(el) {
super.initialize(el);
updateHeight(el);
}
subscribe(el, callback) {
super.subscribe(el, callback);
__privateSet(this, _inputHandler, (e4) => updateHeight(e4.target));
el.addEventListener("input", __privateGet(this, _inputHandler));
intersectObserver.observe(el);
}
unsubscribe(el) {
super.unsubscribe(el);
if (__privateGet(this, _inputHandler))
el.removeEventListener("input", __privateGet(this, _inputHandler));
intersectObserver.unobserve(el);
}
};
_inputHandler = new WeakMap();
function updateHeight(el) {
if (!el.classList.contains("textarea-autoresize")) {
return;
}
if (el.scrollHeight == 0) {
return;
}
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
// srcts/src/bindings/input/index.ts
function initInputBindings() {

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 textAreaInput(autoresize=TRUE) */
textarea.textarea-autoresize.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

@@ -2,11 +2,53 @@ import $ from "jquery";
import { TextInputBinding } from "./text";
// When a textarea becomes visible, update the height
const intersectObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
updateHeight(entry.target as HTMLInputElement);
}
});
});
class TextareaInputBinding extends TextInputBinding {
#inputHandler: EventListener | null = null;
find(scope: HTMLElement): JQuery<HTMLElement> {
// Inputs now also have the .shiny-input-textarea class
return $(scope).find("textarea");
}
initialize(el: HTMLInputElement): void {
super.initialize(el);
updateHeight(el);
}
subscribe(el: HTMLInputElement, callback: (x: boolean) => void): void {
super.subscribe(el, callback);
this.#inputHandler = (e) => updateHeight(e.target as HTMLInputElement);
el.addEventListener("input", this.#inputHandler);
intersectObserver.observe(el);
}
unsubscribe(el: HTMLInputElement): void {
super.unsubscribe(el);
if (this.#inputHandler) el.removeEventListener("input", this.#inputHandler);
intersectObserver.unobserve(el);
}
}
function updateHeight(el: HTMLInputElement) {
if (!el.classList.contains("textarea-autoresize")) {
return;
}
if (el.scrollHeight == 0) {
return;
}
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
export { TextareaInputBinding };

View File

@@ -1,5 +1,9 @@
import { TextInputBinding } from "./text";
declare class TextareaInputBinding extends TextInputBinding {
#private;
find(scope: HTMLElement): JQuery<HTMLElement>;
initialize(el: HTMLInputElement): void;
subscribe(el: HTMLInputElement, callback: (x: boolean) => void): void;
unsubscribe(el: HTMLInputElement): void;
}
export { TextareaInputBinding };