diff --git a/CHANGELOG.md b/CHANGELOG.md index 477807e13..5a8176096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +### New features + +* Added a new `input_submit_textarea()` input element, which is similar to `input_text_area()`, but includes a submit button to only submit the text changes to the server on click. This is especially useful when the input text change triggers a long-running operation and/or the user wants to type longer-form input and review it before submitting it. (#2099) + ### Bug fixes * Fixed `ui.tooltip()`'s `options` parameter to properly pass Bootstrap tooltip options to the underlying web component. (#2101) diff --git a/docs/_quartodoc-core.yml b/docs/_quartodoc-core.yml index e2e12ad5d..82d1785b8 100644 --- a/docs/_quartodoc-core.yml +++ b/docs/_quartodoc-core.yml @@ -55,6 +55,7 @@ quartodoc: - ui.input_numeric - ui.input_text - ui.input_text_area + - ui.input_submit_textarea - ui.input_password - ui.input_action_button - ui.input_action_link @@ -166,6 +167,7 @@ quartodoc: - ui.update_text - name: ui.update_text_area dynamic: "shiny.ui.update_text" + - ui.update_submit_textarea - ui.update_navset - ui.update_action_button - ui.update_action_link diff --git a/docs/_quartodoc-express.yml b/docs/_quartodoc-express.yml index 14cd6d87a..dc132df6b 100644 --- a/docs/_quartodoc-express.yml +++ b/docs/_quartodoc-express.yml @@ -27,6 +27,7 @@ quartodoc: - express.ui.input_numeric - express.ui.input_text - express.ui.input_text_area + - express.ui.input_submit_textarea - express.ui.input_password - express.ui.input_action_button - express.ui.input_action_link @@ -126,6 +127,7 @@ quartodoc: - express.ui.update_numeric - express.ui.update_text - express.ui.update_text_area + - express.ui.update_submit_textarea - express.ui.update_navset - express.ui.update_action_button - express.ui.update_action_link diff --git a/docs/_quartodoc-testing.yml b/docs/_quartodoc-testing.yml index 84de2ef27..ccb50bbe4 100644 --- a/docs/_quartodoc-testing.yml +++ b/docs/_quartodoc-testing.yml @@ -39,6 +39,7 @@ quartodoc: - playwright.controller.InputSelectize - playwright.controller.InputSlider - playwright.controller.InputSliderRange + - playwright.controller.InputSubmitTextarea - playwright.controller.InputSwitch - playwright.controller.InputTaskButton - playwright.controller.InputText diff --git a/shiny/_versions.py b/shiny/_versions.py index 68010e7f2..729876e85 100644 --- a/shiny/_versions.py +++ b/shiny/_versions.py @@ -1,4 +1,4 @@ -shiny_html_deps = "1.11.1.9000" +shiny_html_deps = "1.11.1.9001" bslib = "0.9.0.9000" htmltools = "0.5.8.9000" bootstrap = "5.3.1" diff --git a/shiny/api-examples/input_submit_textarea/app-core.py b/shiny/api-examples/input_submit_textarea/app-core.py new file mode 100644 index 000000000..eac5d7f5d --- /dev/null +++ b/shiny/api-examples/input_submit_textarea/app-core.py @@ -0,0 +1,22 @@ +import time + +from shiny import App, Inputs, Outputs, Session, render, ui + +app_ui = ui.page_fluid( + ui.input_submit_textarea("text", placeholder="Enter some input..."), + ui.output_text("value"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @render.text + def value(): + if "text" in input: + # Simulate processing time + time.sleep(2) + return f"You entered: {input.text()}" + else: + return "Submit some input to see it here." + + +app = App(app_ui, server) diff --git a/shiny/api-examples/input_submit_textarea/app-express.py b/shiny/api-examples/input_submit_textarea/app-express.py new file mode 100644 index 000000000..b108fbcfd --- /dev/null +++ b/shiny/api-examples/input_submit_textarea/app-express.py @@ -0,0 +1,15 @@ +import time + +from shiny.express import input, render, ui + +ui.input_submit_textarea("text", placeholder="Enter some input...") + + +@render.text +def value(): + if "text" in input: + # Simulate processing time + time.sleep(2) + return f"You entered: {input.text()}" + else: + return "Submit some input to see it here." diff --git a/shiny/api-examples/update_submit_textarea/app-core.py b/shiny/api-examples/update_submit_textarea/app-core.py new file mode 100644 index 000000000..b810d2418 --- /dev/null +++ b/shiny/api-examples/update_submit_textarea/app-core.py @@ -0,0 +1,45 @@ +from shiny import App, Inputs, Outputs, Session, reactive, render, ui + +app_ui = ui.page_fluid( + ui.input_submit_textarea( + "comment", + label="Your Comment", + placeholder="Type your comment here...", + rows=2, + toolbar=[ + ui.input_action_button("clear", "Clear", class_="btn-sm btn-danger"), + ui.input_action_button("template", "Use Template", class_="btn-sm"), + ], + ), + ui.output_text("submitted_comment"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.effect + @reactive.event(input.clear) + def _(): + ui.update_submit_textarea( + "comment", + value="", + placeholder="Type your comment here...", + ) + + @reactive.effect + @reactive.event(input.template) + def _(): + ui.update_submit_textarea( + "comment", + value="Thank you for your feedback. We appreciate your input!", + placeholder="", + label="Your Comment (Template Applied)", + ) + + @render.text + def submitted_comment(): + if "comment" in input: + return f"Submitted: {input.comment()}" + return "No comment submitted yet." + + +app = App(app_ui, server) diff --git a/shiny/api-examples/update_submit_textarea/app-express.py b/shiny/api-examples/update_submit_textarea/app-express.py new file mode 100644 index 000000000..54ed80890 --- /dev/null +++ b/shiny/api-examples/update_submit_textarea/app-express.py @@ -0,0 +1,41 @@ +from shiny import reactive, render +from shiny.express import input, ui + +ui.input_submit_textarea( + "comment", + label="Your Comment", + placeholder="Type your comment here...", + rows=2, + toolbar=[ + ui.input_action_button("clear", "Clear", class_="btn-sm btn-danger"), + ui.input_action_button("template", "Use Template", class_="btn-sm"), + ], +) + + +@reactive.effect +@reactive.event(input.clear) +def _(): + ui.update_submit_textarea( + "comment", + value="", + placeholder="Type your comment here...", + ) + + +@reactive.effect +@reactive.event(input.template) +def _(): + ui.update_submit_textarea( + "comment", + value="Thank you for your feedback. We appreciate your input!", + placeholder="", + label="Your Comment (Template Applied)", + ) + + +@render.text +def submitted_comment(): + if "comment" in input: + return f"Submitted: {input.comment()}" + return "No comment submitted yet." diff --git a/shiny/express/ui/__init__.py b/shiny/express/ui/__init__.py index 98ee3f325..0b414aa6e 100644 --- a/shiny/express/ui/__init__.py +++ b/shiny/express/ui/__init__.py @@ -64,6 +64,7 @@ input_select, input_selectize, input_slider, + input_submit_textarea, input_switch, input_task_button, input_text, @@ -102,6 +103,7 @@ update_selectize, update_sidebar, update_slider, + update_submit_textarea, update_switch, update_task_button, update_text, @@ -220,6 +222,7 @@ "input_select", "input_selectize", "input_slider", + "input_submit_textarea", "bind_task_button", "input_task_button", "input_text", @@ -243,6 +246,7 @@ "update_select", "update_selectize", "update_slider", + "update_submit_textarea", "update_task_button", "update_text", "update_text_area", diff --git a/shiny/playwright/controller/__init__.py b/shiny/playwright/controller/__init__.py index 058da7f5b..b0e72cff0 100644 --- a/shiny/playwright/controller/__init__.py +++ b/shiny/playwright/controller/__init__.py @@ -32,6 +32,7 @@ InputDateRange, InputNumeric, InputPassword, + InputSubmitTextarea, InputText, InputTextArea, ) @@ -83,6 +84,7 @@ "InputSelectize", "InputSlider", "InputSliderRange", + "InputSubmitTextarea", "InputSwitch", "InputTaskButton", "InputText", diff --git a/shiny/playwright/controller/_input_fields.py b/shiny/playwright/controller/_input_fields.py index 77a1b0ae6..1cafed93d 100644 --- a/shiny/playwright/controller/_input_fields.py +++ b/shiny/playwright/controller/_input_fields.py @@ -213,6 +213,28 @@ def expect_autocomplete( ) +class _ExpectRowsAttrM: + """A mixin class for the rows attribute.""" + + def expect_rows( + self: UiBaseP, + value: AttrValue, + *, + timeout: Timeout = None, + ) -> None: + """ + Expect the `rows` attribute to have a specific value. + + Parameters + ---------- + value + The expected value of the `rows` attribute. + timeout + The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. + """ + _expect_attribute_to_have_value(self.loc, "rows", value=value, timeout=timeout) + + class InputText( _SetTextM, _ExpectTextInputValueM, @@ -296,6 +318,7 @@ class InputTextArea( _ExpectPlaceholderAttrM, _ExpectAutocompleteAttrM, _ExpectSpellcheckAttrM, + _ExpectRowsAttrM, UiWithLabel, ): """Controller for :func:`shiny.ui.input_text_area`.""" @@ -368,19 +391,6 @@ def expect_cols(self, value: AttrValue, *, timeout: Timeout = None) -> None: """ _expect_attribute_to_have_value(self.loc, "cols", value=value, timeout=timeout) - def expect_rows(self, value: AttrValue, *, timeout: Timeout = None) -> None: - """ - Expect the `rows` attribute of the input text area to have a specific value. - - Parameters - ---------- - value - The expected value of the `rows` attribute. - timeout - The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. - """ - _expect_attribute_to_have_value(self.loc, "rows", value=value, timeout=timeout) - def expect_resize( self, value: Resize | None, @@ -966,3 +976,98 @@ def expect_autoclose( # TODO-future; Composable expectations self.date_start.expect_autoclose(value, timeout=timeout) self.date_end.expect_autoclose(value, timeout=timeout) + + +class InputSubmitTextarea( + _SetTextM, + WidthContainerStyleM, + _ExpectTextInputValueM, + _ExpectPlaceholderAttrM, + _ExpectRowsAttrM, + UiWithLabel, +): + """Controller for :func:`shiny.ui.input_submit_textarea`.""" + + loc_button: Locator + """Playwright `Locator` for the submit button.""" + + def __init__(self, page: Page, id: str) -> None: + """ + Initializes the input submit textarea. + + Parameters + ---------- + page + The page where the input submit textarea is located. + id + The id of the input submit textarea. + """ + super().__init__( + page, + id=id, + loc=f"textarea#{id}.form-control", + ) + self.loc_button = self.loc_container.locator(".bslib-submit-textarea-btn") + + def set(self, value: str, *, submit: bool = False, timeout: Timeout = None) -> None: + """ + Sets the text value in the textarea. + + Parameters + ---------- + value + The text to set. + submit + Whether to click the submit button after setting the text. Defaults to `False`. + timeout + The maximum time to wait for the text to be set. Defaults to `None`. + """ + set_text(self.loc, value, timeout=timeout) + if submit: + self.loc_button.click(timeout=timeout) + + def submit(self, *, timeout: Timeout = None) -> None: + """ + Clicks the submit button. + + Parameters + ---------- + timeout + The maximum time to wait for the click. Defaults to `None`. + """ + self.loc_button.click(timeout=timeout) + + def expect_data_needs_modifier( + self, value: bool, *, timeout: Timeout = None + ) -> None: + """ + Expect the `data-needs-modifier` attribute to be present or absent. + + Parameters + ---------- + value + If `True`, expects the attribute to be present. If `False`, expects it to be absent. + timeout + The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. + """ + _expect_attribute_to_have_value( + self.loc, + "data-needs-modifier", + value="" if value else None, + timeout=timeout, + ) + + def expect_button_label( + self, value: PatternOrStr, *, timeout: Timeout = None + ) -> None: + """ + Expect the submit button to have a specific label. + + Parameters + ---------- + value + The expected label text. + timeout + The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. + """ + playwright_expect(self.loc_button).to_contain_text(value, timeout=timeout) diff --git a/shiny/ui/__init__.py b/shiny/ui/__init__.py index f43d80f13..d2395dfd2 100644 --- a/shiny/ui/__init__.py +++ b/shiny/ui/__init__.py @@ -84,6 +84,7 @@ from ._input_password import input_password from ._input_select import input_select, input_selectize from ._input_slider import AnimationOptions, SliderStepArg, SliderValueArg, input_slider +from ._input_submit_textarea import input_submit_textarea, update_submit_textarea from ._input_task_button import bind_task_button, input_task_button from ._input_text import input_text, input_text_area from ._input_update import ( @@ -251,6 +252,9 @@ "SliderValueArg", "SliderStepArg", "AnimationOptions", + # _input_submit_textarea + "input_submit_textarea", + "update_submit_textarea", # _input_task_button "bind_task_button", "input_task_button", diff --git a/shiny/ui/_input_submit_textarea.py b/shiny/ui/_input_submit_textarea.py new file mode 100644 index 000000000..a226b2ac6 --- /dev/null +++ b/shiny/ui/_input_submit_textarea.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +__all__ = ("input_submit_textarea", "update_submit_textarea") + +import copy +from typing import Literal, Optional + +from htmltools import Tag, TagAttrValue, TagChild, css, div, span, tags + +from .._docstring import add_example +from .._utils import drop_none +from ..bookmark import restore_input +from ..module import resolve_id +from ..session import Session, require_active_session +from ._html_deps_shinyverse import components_dependencies +from ._input_task_button import input_task_button +from ._utils import shiny_input_label + + +@add_example() +def input_submit_textarea( + id: str, + label: TagChild = None, + *, + placeholder: Optional[str] = None, + value: str = "", + width: str = "min(680px, 100%)", + rows: int = 1, + button: Optional[Tag] = None, + toolbar: TagChild | TagAttrValue = None, + submit_key: Literal["enter+modifier", "enter"] = "enter+modifier", + **kwargs: TagAttrValue, +) -> Tag: + """ + Create a textarea input control with explicit submission. + + Creates a textarea input where users can enter multi-line text and submit + their input using a dedicated button or keyboard shortcut. This control is + ideal when you want to capture finalized input, rather than reacting to every + keystroke, making it useful for chat boxes, comments, or other scenarios + where users may compose and review their text before submitting. + + Parameters + ---------- + id + The input ID. + label + The label to display above the input control. If `None`, no label is displayed. + placeholder + The placeholder text to display when the input is empty. This can be used to + provide a hint or example of the expected input. + value + The initial input text. Note that, unlike :func:`~shiny.ui.input_text_area`, + this won't set a server-side value until the value is explicitly submitted. + width + Any valid CSS unit (e.g., `width="100%"`). + rows + The number of rows (i.e., height) of the textarea. This essentially sets the + minimum height -- the textarea can grow taller as the user enters more text. + button + A :class:`~htmltools.Tag` element to use for the submit button. It's recommended + that this be an :func:`~shiny.ui.input_task_button` since it will automatically + provide a busy indicator (and disable) until the next flush occurs. Note also + that if the submit button launches an :class:`~shiny.reactive.ExtendedTask`, + this button can also be bound to the task (:func:`~shiny.ui.bind_task_button`) + and/or manually updated for more accurate progress reporting + (:func:`~shiny.ui.update_task_button`). + toolbar + UI elements to include alongside the submit button (e.g., help text, links, etc.). + submit_key + A string indicating what keyboard event should trigger the submit button. + The default is `"enter+modifier"`, which requires the user to hold down + Ctrl (or Cmd on Mac) before pressing Enter to submit. This helps prevent + accidental submissions. To allow submission with just the Enter key, use + `"enter"`. In this case, the user can still insert new lines using + Shift+Enter or Alt+Enter. + **kwargs + Additional attributes to apply to the underlying `