Skip to content
Draft
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
89647ef
feat: Improvements to selectize plugin defaults/updates
cpsievert Aug 20, 2025
dfb09ca
Let the client handle supplying and retaining default plugins
cpsievert Aug 21, 2025
be5387f
Pull in changes to shiny.js (via https://github.com/rstudio/shiny/pul…
cpsievert Aug 21, 2025
9c9ba4e
Remove unused import
cpsievert Aug 21, 2025
43d6715
Merge branch 'main' into feat/selectize-options
cpsievert Aug 21, 2025
9c1e6f6
Merge branch 'main' into feat/selectize-options
cpsievert Aug 21, 2025
9fd03b2
Simplify to always include clear_button plugin
cpsievert Aug 21, 2025
38aba75
Update changelog
cpsievert Aug 21, 2025
2229d25
fix: don't include selecitze script tag for normal select
cpsievert Aug 21, 2025
657475d
update html dependencies
cpsievert Aug 21, 2025
7a9ebff
Add test for clear_button plugin with multiple=True
cpsievert Aug 21, 2025
6fd3bbb
Add some playwright tests
cpsievert Aug 21, 2025
e350db0
Revert defaulting to adding clear_button by default
cpsievert Aug 22, 2025
9f5d86e
Callout the problem with this approach
cpsievert Aug 22, 2025
e44924c
Move more in a 'remove_button' attribute direction
cpsievert Aug 22, 2025
a720e43
Move to a JSON-only approach
cpsievert Aug 23, 2025
905aff7
Drop sticky update logic by always sending 'missing' value and resolv…
cpsievert Aug 23, 2025
4eee4ca
Update JS assets
cpsievert Aug 28, 2025
0a31f29
Address review feedback
cpsievert Aug 28, 2025
7ce208b
Point back to main branch of shiny.js
cpsievert Aug 28, 2025
9cbba66
Fix import
cpsievert Aug 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* `playwright.controller.InputActionButton` gains a `expect_icon()` method. As a result, the already existing `expect_label()` no longer includes the icon. (#2020)

* `ui.input_selectize()`'s `remove_button` parameter gains a supported value of `"both"`, which adds both of the `"remove_button"` and `"clear_button"` selectize plugins. This is most useful when `multiple=True`, allowing for clearing of individual as well as _all_ selected items. (#2064)

### Changes

* `express.ui.insert_accordion_panel()`'s function signature has changed to be more ergonomic. Now you can pass the `panel_title` and `panel_contents` directly instead of `ui.hold()`ing the `ui.accordion_panel()` context manager. (#2042)
Expand All @@ -49,11 +51,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Bug fixes

* Fixed numerous issues related to programmatically updating selectize options. (#2053)
* `update_selectize(options=...)` no longer gets ignored when `server=False` (the default).
* `update_selectize(options=...)` now works as expected in a module.

* Fixed an issue with `update_selectize()` to properly display labels with HTML reserved characters like "&" (#1330)
* Fixed numerous issues with `update_selectize()`:
* `options` now works when `server=False`. (#2053)
* `options` now works when used in a module. (#2053)
* `options` now correctly preserves `remove_button` on update. (#2064)
* HTML reserved characters (i.e., `&`, `<`, `>`) inside `choices` labels no longer get incorrectly escaped (when `server=True`). (#1330)

* Fixed an issue with `ui.Chat()` sometimes wanting to scroll a parent element. (#1996)

Expand Down
2 changes: 1 addition & 1 deletion scripts/_pkg-sources.R
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
list(
bslib = "rstudio/bslib@main",
shiny = "rstudio/shiny@main",
shiny = "rstudio/shiny@fix/update-selectize-default-plugins",
sass = "sass",
htmltools = "rstudio/htmltools@main"
)
98 changes: 29 additions & 69 deletions shiny/ui/_input_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
"input_select",
"input_selectize",
)
import copy
from json import dumps
from typing import Any, Mapping, Optional, Union, cast
import json
from typing import Literal, Mapping, Optional, Union, cast

from htmltools import Tag, TagChild, TagList, css, div, tags

Expand Down Expand Up @@ -59,7 +58,7 @@ def input_selectize(
selected: Optional[str | list[str]] = None,
multiple: bool = False,
width: Optional[str] = None,
remove_button: Optional[bool] = None,
remove_button: Optional[Literal[True, False, "both"]] = None,
options: Optional[dict[str, Jsonifiable | JSEval]] = None,
) -> Tag:
"""
Expand All @@ -85,9 +84,16 @@ def input_selectize(
width
The CSS width, e.g. '400px', or '100%'
remove_button
Whether to add a remove button. This uses the `clear_button` and `remove_button`
selectize plugins which can also be supplied as options. By default it will apply a
remove button to multiple selections, but not single selections.
Whether to include remove button(s). The following values are supported:

- `None` (the default): The 'remove_button' selection plugin is included when
`multiple=True`.
- `True`: Same as `None` in the `multiple=True` case, but when `multiple=False`,
the 'clear_button' plugin is included.
- `False`: No plugins are included.
- `"both"`: Both 'remove_button' and 'clear_button' plugins are included. This
is useful for being able to clear each and all selected items when
`multiple=True`.
options
A dictionary of options. See the documentation of selectize.js for possible options.
If you want to pass a JavaScript function, wrap the string in `ui.JS`.
Expand Down Expand Up @@ -245,14 +251,12 @@ def _input_select_impl(
selectize: bool = False,
width: Optional[str] = None,
size: Optional[str] = None,
remove_button: Optional[bool] = None,
remove_button: Literal[True, False, "both", None] = None,
options: Optional[dict[str, Jsonifiable | JSEval]] = None,
) -> Tag:
if options is not None and selectize is False:
raise Exception("Options can only be set when selectize is `True`.")

remove_button = _resolve_remove_button(remove_button, multiple)

resolved_id = resolve_id(id)

choices_ = _normalize_choices(choices)
Expand All @@ -264,10 +268,22 @@ def _input_select_impl(
if options is None:
options = {}

opts = _update_options(options, remove_button, multiple)

choices_tags = _render_choices(choices_, selected)

# Add our own special remove_button option
options["shinyRemoveButton"] = str(remove_button).lower()

selectize_config = None
if selectize:
selectize_config = tags.script(
json.dumps(options),
selectize_deps(),
type="application/json",
data_for=resolved_id,
# Which option values should be interpreted as JS?
Copy link
Contributor

@elnelson575 elnelson575 Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you saying the next line addresses this, or is this an open question?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The former

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might make that clearer? But up to you

data_eval=json.dumps(extract_js_keys(options)),
)

return div(
shiny_input_label(resolved_id, label),
div(
Expand All @@ -279,56 +295,13 @@ def _input_select_impl(
multiple=multiple,
size=size,
),
(
TagList(
tags.script(
dumps(opts),
type="application/json",
data_for=resolved_id,
data_eval=dumps(extract_js_keys(opts)),
),
selectize_deps(),
)
if selectize
else None
),
selectize_config,
),
class_="form-group shiny-input-container",
style=css(width=width),
)


def _resolve_remove_button(remove_button: Optional[bool], multiple: bool) -> bool:
if remove_button is None:
if multiple:
return True
else:
return False
return remove_button


def _update_options(
options: dict[str, Any], remove_button: bool, multiple: bool
) -> dict[str, Any]:
opts = copy.deepcopy(options)
plugins = opts.get("plugins", [])

if remove_button:
if multiple:
to_add = "remove_button"
else:
to_add = "clear_button"

if to_add not in plugins:
plugins.append(to_add)

if not plugins:
return options

opts["plugins"] = plugins
return opts


def _normalize_choices(x: SelectChoicesArg) -> _SelectChoices:
if x is None:
raise TypeError("`choices` must be a list, tuple, or dict.")
Expand All @@ -338,19 +311,6 @@ def _normalize_choices(x: SelectChoicesArg) -> _SelectChoices:
return x


def _contains_html(x: _SelectChoices) -> bool:
for v in x.values():
if isinstance(v, Mapping):
# Check the `_Choices` values of `_OptGrpChoices`
for vv in v.values():
if not isinstance(vv, str):
return True
else:
if not isinstance(v, str):
return True
return False


def _render_choices(
x: _SelectChoices, selected: Optional[str | list[str]] = None
) -> TagList:
Expand Down
10 changes: 8 additions & 2 deletions shiny/ui/_input_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,7 @@ def update_selectize(
label: Optional[TagChild] = None,
choices: Optional[SelectChoicesArg] = None,
selected: Optional[str | list[str]] = None,
remove_button: Optional[Literal[True, False, "both"]] = None,
options: Optional[dict[str, str | float | JSEval]] = None,
server: bool = False,
session: Optional[Session] = None,
Expand All @@ -741,8 +742,11 @@ def update_selectize(
``<optgroup>`` labels.
selected
The values that should be initially selected, if any.
remove_button
Whether to show a remove button for the select input.
options
Options to send to update, see `input_selectize` for details.
Selectize.js options to customize the behavior of the select input.
See <https://selectize.dev/docs/usage> for more details.
server
Whether to store choices on the server side, and load the select options
dynamically on searching, instead of writing all choices into the page at once
Expand All @@ -762,7 +766,9 @@ def update_selectize(

session = require_active_session(session)

if options is not None:
if options is not None or remove_button is not None:
options = options or {}
options["shinyRemoveButton"] = str(remove_button).lower()
cfg = tags.script(
json.dumps(options),
type="application/json",
Expand Down
2 changes: 1 addition & 1 deletion shiny/www/shared/_version.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"note!": "Generated by scripts/htmlDependencies.R: do not edit by hand",
"package": "shiny",
"version": "1.11.1.9000 (rstudio/shiny@0e355ed25cc1066d6894733f04f4b511a27acc53)"
"version": "1.11.1.9000 (rstudio/shiny@c057bbab161cee58472fb81d24f9963114ccdb80)"
}
2 changes: 1 addition & 1 deletion shiny/www/shared/bootstrap/_version.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"note!": "Generated by scripts/htmlDependencies.R: do not edit by hand",
"shiny_version": "1.11.1.9000 (rstudio/shiny@0e355ed25cc1066d6894733f04f4b511a27acc53)",
"shiny_version": "1.11.1.9000 (rstudio/shiny@c057bbab161cee58472fb81d24f9963114ccdb80)",
"bslib_version": "0.9.0.9000 (rstudio/bslib@9562108e40a0bffb4a7c8709c2963509435c5c0f)",
"htmltools_version": "0.5.8.9000 (rstudio/htmltools@487aa0bed7313d7597b6edd5810e53cab0061198)",
"bootstrap_version": "5.3.1"
Expand Down
2 changes: 1 addition & 1 deletion shiny/www/shared/bootstrap/bootstrap.min.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion shiny/www/shared/sass/preset/bootstrap/bootstrap.min.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion shiny/www/shared/sass/preset/shiny/bootstrap.min.css

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions shiny/www/shared/sass/shiny/www/shared/shiny_scss/shiny.scss
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,13 @@ textarea.textarea-autoresize.form-control {
cursor: not-allowed;
}

// Selectize's remove_button plugin has a bug that can lead to multiple remove
// buttons being displayed when options get updated. This prevents that from
// happening (see #4274 for reprex)
.shiny-input-select .selectize-input > .item > .remove:not(:first-child) {
display: none;
}

/* Hidden tabPanels */
.nav-hidden {
/* override anything bootstrap sets for `.nav` */
Expand Down
28 changes: 28 additions & 0 deletions shiny/www/shared/shiny.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions shiny/www/shared/shiny.js.map

Large diffs are not rendered by default.

Loading
Loading