--- title: "Multi-Select with glasstabs" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Multi-Select with glasstabs} %\VignetteEncoding{UTF-8} %\VignetteEngine{knitr::rmarkdown} editor_options: markdown: wrap: 72 --- ```{r setup, include=FALSE} knitr::opts_chunk$set(eval = FALSE) library(shiny) library(glasstabs) fruits <- c(Apple = "apple", Banana = "banana", Cherry = "cherry", Mango = "mango", Peach = "peach") ``` ## Why multi-select is the centerpiece Most Shiny dashboards are, at heart, a filter problem: pick a set of categories, regions, or products, and everything downstream — tables, plots, summaries — follows that set. `glassMultiSelect()` is the widget built for exactly that job. It is a searchable, themeable, animated multi-select dropdown that behaves like a standard Shiny input while adding the pieces a filter UI usually needs bolted on afterwards: a compact trigger summary, removable tag pills, a select-all row, server-side search for large choice sets, and transactional server updates. If you only learn one glasstabs widget, learn this one. The single-select (`glassSelect()`) shares its dropdown engine, themes, and animations, so much of this transfers—but the multi-select is where the package's filtering story lives. See the [single-select guide](glassSelect.html) when one value is the right contract. ## A complete app in one page ```{r app} library(shiny) library(glasstabs) fruits <- c(Apple = "apple", Banana = "banana", Cherry = "cherry", Mango = "mango", Peach = "peach") ui <- fluidPage( useGlassTabs(), glassMultiSelect("pick", fruits), glassFilterTags("pick"), verbatimTextOutput("out") ) server <- function(input, output, session) { output$out <- renderPrint(input$pick) } if (interactive()) shinyApp(ui, server) ``` Two things to notice. First, `useGlassTabs()` is called once in the UI: it loads the package CSS and JavaScript. The multi-select does **not** need a tab layout — it works standalone in any Shiny page. Second, the widget is an ordinary Shiny input: `input$pick` is a character vector of the selected values, and it updates as the user selects and deselects options. ## What the widget produces | Input | Type | Value | |-------------------------|------------------|--------------------------------------| | `input$` | character vector | Currently selected values | | `input$_style` | character | Active checkbox style | | `input$_open` | logical | Whether the dropdown is open | | `input$_ready` | logical | Whether the widget has finished initializing | `input$_ready` becomes `TRUE` once the widget's JavaScript has booted and synced its initial state. Gate observers that depend on the widget being initialized on it, rather than on session start: ```{r ready} server <- function(input, output, session) { observeEvent(input$pick_ready, { # Safe to read input$pick and drive downstream outputs here. }, ignoreInit = FALSE) } ``` ## The selection model: all, some, or none The default surprises people coming from `selectizeInput()`, so say it plainly: **when `selected = NULL` (the default), every choice starts selected.** That is the filter-friendly default — an untouched filter means "everything", not "nothing". ```{r selection} # All selected (default) glassMultiSelect("f", fruits) # A subset pre-selected glassMultiSelect("f", fruits, selected = c("apple", "cherry")) # Nothing pre-selected — the trigger shows the placeholder instead glassMultiSelect("f", fruits, selected = character(0)) ``` Two labels control the trigger text at the extremes. `placeholder` (default `"Filter by Category"`) shows when nothing is selected; `all_label` (default `"All categories"`) shows when everything is selected. Set them to match the domain — `"Filter by region"` and `"All regions"` read better than the generics in a real app. ### How a partial selection is shown `selection_display` controls the trigger when some, but not all, options are selected: ```{r display} # "auto" — the familiar single-label / "Multiple selection" behavior glassMultiSelect("f", fruits, selection_display = "auto") # "3 selected" — compact, always the same width glassMultiSelect("f", fruits, selection_display = "count") # First labels, then "+N" — the dashboard default glassMultiSelect("f", fruits, selection_display = "summary", selection_max_items = 2 ) # Every selected label, comma-separated glassMultiSelect("f", fruits, selection_display = "labels") ``` `selection_max_items` (default `2`) sets how many labels `summary` shows before the `+N` remainder. For a filter bar with several multi-selects side by side, `count` or `summary` keeps triggers from jumping around as selections change. ## Check styles, and the style switcher Three indicator styles are available via `check_style`: ```{r styles} # Bordered box + animated tick (default) glassMultiSelect("f", fruits, check_style = "checkbox") # Tick only — minimal, no box border glassMultiSelect("f", fruits, check_style = "check-only") # Solid coloured box glassMultiSelect("f", fruits, check_style = "filled") ``` By default a Check / Box / Fill switcher row sits inside the dropdown so users can change the style at runtime; the active style is readable at `input$_style`. Hide it with `show_style_switcher = FALSE` to lock the style silently. ### Filled rows: one accent, or one hue per option `fill_colors` controls the `filled` style, and the default matters: ```{r filled} # One theme accent for every selected row (the default) glassMultiSelect("f", fruits, check_style = "filled", fill_colors = "single" ) # Per-option hues — categorical colour per choice glassMultiSelect("f", fruits, check_style = "filled", fill_colors = "multi" ) ``` With `fill_colors = "multi"`, hues are auto-assigned evenly around the colour wheel. Override them with a named integer vector of HSL hue angles (0–360); the `hues` argument only applies when `fill_colors = "multi"`: ```{r hues} glassMultiSelect("f", fruits, check_style = "filled", fill_colors = "multi", hues = c(apple = 0, banana = 50, cherry = 340, mango = 30, peach = 20) ) ``` ## Search: always, automatic, or never Search is shown by default. Three modes cover the real cases: ```{r search} # Always show the search box (the default) glassMultiSelect("f", fruits, searchable = TRUE) # Hide it entirely — right for a small, fixed list glassMultiSelect("f", fruits, searchable = FALSE) # Show it only once the list is big enough to need it glassMultiSelect("f", fruits, searchable = "auto", search_threshold = 12 ) ``` With `searchable = "auto"`, the search box appears only when the choice count reaches `search_threshold` (default `15`). Five categories stay uncluttered; fifty get a search box without any extra code. The threshold is a choice count, not a character count, and it can be changed at runtime with `updateGlassMultiSelect()`. Two smaller search controls: `no_matches_text` (default `"No matches"`) is the empty-result message, and `dropdown_max_height` (default `"260px"`) caps the scrolling option area. `search_focus_ring = TRUE` opts into an accent outline when the search field is focused; the default `FALSE` uses a quieter background and border change. ## Long labels, wrapping, and tooltips The open dropdown always matches the trigger width — it never stretches a short field to fit a long label. Longer labels wrap inside that shared width instead, and the trigger itself uses a stable compact width (`160px`) unless you pass `width` explicitly (fixed or fluid widths are supported and stay capped by the containing layout): ```{r width} glassMultiSelect("f", fruits, width = "100%") glassMultiSelect("f", fruits, width = "240px") ``` A wrapped option is still fully readable. With the default `overflow_tooltip = "auto"`, hovering a truncated option—or reaching it with keyboard navigation—reveals a capped text preview; only one option tooltip appears at a time. The complete option text remains in the DOM and accessible name. The trigger itself does not combine every selection into one visual tooltip—for a multi-select, that combined tooltip would just be noise. On touch devices, the wrapped option text inside the open dropdown is the fallback. Set `overflow_tooltip = "never"` to disable option tooltips. ```{r long-labels} glassMultiSelect( "long_filters", c( "Commercial real estate development and property management services" = "property", Healthcare = "healthcare" ), selected = "property", width = "240px", selection_display = "summary", overflow_tooltip = "auto" ) ``` ## Select all, clear all, and the chrome rows Three chrome rows sit inside the dropdown, each independently toggleable (all default `TRUE`): ```{r chrome} glassMultiSelect("f", fruits, show_style_switcher = FALSE, # hide the Check / Box / Fill row show_select_all = FALSE, # hide the "Select all" row show_clear_all = FALSE # hide the "Clear all" footer link ) ``` The select-all row acts only on **enabled, visible** options: it selects everything currently matching the search (skipping disabled rows), or clears them if all are already selected, and it shows an indeterminate state for a partial selection. In server mode the row acts on the currently loaded page: options that have not arrived from the server yet are not part of the toggle set, and `glassMultiSelectServer(..., disabled_choices)` marks values disabled as they arrive in search responses. "Clear all" clears the whole selection. Note there is no `clearable` argument on `glassMultiSelect()` — clearing is `show_clear_all`, which is `TRUE` by default. (`clearable` belongs to the single-select and does not exist here; passing it would be an unused-argument error.) ## Disabled options — and what survives a choices update Disable the whole widget, or individual options: ```{r disabled} glassMultiSelect("filters_d", fruits, disabled = TRUE) glassMultiSelect("filters_dc", fruits, disabled_choices = "banana") ``` Disabled rows cannot be selected, and "Select all" skips them. A disabled choice is also excluded from the default all-selected state: with `disabled_choices = "banana"` and no explicit `selected`, banana starts unselected while everything else starts selected. One update subtlety is worth knowing: when choices are replaced without passing `disabled_choices`, the widget **keeps** the previous disabled rows. To change the disabled set, pass it explicitly in the same update (see "Updating from the server" below). ## Grouped choices Grouped choices use the `selectInput()`-style named list: ```{r groups} glassMultiSelect("food", list( Fruit = c(Apple = "apple", Banana = "banana"), Veg = c(Carrot = "carrot", Pea = "pea") )) ``` As with `selectInput()`, a one-element named list such as `list(Group = "x")` is treated as a flat, ungrouped choice. ## Tag pills with `glassFilterTags()` Place a `glassFilterTags()` container anywhere on the page to show the active selections as removable tag pills. The JavaScript engine keeps them in sync automatically — clicking the x on a tag deselects that option: ```{r filter-tags} ui <- fluidPage( useGlassTabs(), glassMultiSelect("cat", fruits, show_style_switcher = FALSE), glassFilterTags("cat") # can be placed anywhere, even in a different tab pane ) ``` You can place several `glassFilterTags("cat")` containers for the same filter — all stay in sync. This is the pattern that makes the multi-select feel like a filter system rather than a single input: the dropdown lives in a sidebar, the pills live next to the results, and both reflect the same `input$cat`. ## Reading values on the server Read the selection directly from `input$` and the style from `input$_style`: ```{r server} server <- function(input, output, session) { observe({ message("Selected: ", paste(input$pick, collapse = ", ")) message("Style: ", input$pick_style) }) } ``` ### The `glassMultiSelectValue()` helper For filtering code, `glassMultiSelectValue()` wraps the raw input with one genuinely useful idea: an empty selection is ambiguous. Does empty mean "match nothing" or "match everything"? The raw input stays honest (`character(0)`), and `resolved()` answers the question separately: ```{r value-helper} server <- function(input, output, session) { ms <- glassMultiSelectValue(input, "pick") observe({ message("Selected: ", paste(ms$selected(), collapse = ", ")) message("Style: ", ms$style()) }) } ``` ```{r empty-resolution} rows <- data.frame( fruit = c("apple", "banana", "cherry"), n = c(12, 7, 9) ) server <- function(input, output, session) { ms <- glassMultiSelectValue( input, "pick", choices = fruits, empty_behavior = "all" ) filtered <- reactive({ rows[rows$fruit %in% ms$resolved(), , drop = FALSE] }) } ``` `empty_behavior` has three modes: `"none"` (the default — `resolved()` is `character(0)`), `"all"` (empty resolves to every choice value, so an untouched filter matches everything), and `"null"` (empty resolves to `NULL`, for downstream code that expects it). `empty_behavior = "all"` requires `choices` — pass the same choices used by `glassMultiSelect()`, or a reactive returning them. `is_empty()` is also available for UI messages or validation. ## Understanding selection behavior: All, Some, and None The multi-select default surprises developers who expect an empty starting state: `selected = NULL` selects **every enabled choice** initially. Disabled choices are never in that default, and they are never part of the Select-all set either — the Select-all row and its indicator act on enabled, visible choices only (see the server-mode note below for how that scope works with bounded results). Three states, three meanings: | State | Widget value | What the app usually does with it | | ----- | ------------ | --------------------------------- | | All choices selected | Character vector of all selected values | Show all matching records | | Some choices selected | Character vector of those values | Show matching records only | | Nothing selected | `character(0)` | Show no records — or, if the app chooses, no filter | The last row is where bugs hide. An app that treats empty as "no filter" is valid when chosen deliberately — but if the example is copied without that choice, pressing Clear all makes every record reappear even though the dropdown visibly holds no selection. Decide which meaning empty has, and write it down in the filter: Approach A: empty means no records. Note that `req()` on an empty selection also stops the calculation, so the explicit version below is the one to copy: ```{r run-filter-empty-none} rows <- data.frame( fruit = c("apple", "banana", "cherry"), n = c(12, 7, 9) ) filter_none <- function(selected) { if (is.null(selected) || length(selected) == 0L) { return(rows[0, , drop = FALSE]) } rows[rows$fruit %in% selected, , drop = FALSE] } stopifnot(nrow(filter_none(character(0))) == 0L) stopifnot(nrow(filter_none("apple")) == 1L) ``` Approach B: empty means all records: ```{r run-filter-empty-all} filter_all <- function(selected) { if (is.null(selected) || length(selected) == 0L) { return(rows) } rows[rows$fruit %in% selected, , drop = FALSE] } stopifnot(nrow(filter_all(character(0))) == 3L) stopifnot(nrow(filter_all("banana")) == 1L) ``` The same decision applies at three moments: on initial load (default `selected = NULL` starts at All, while `selected = character(0)` starts at None), after Clear all (the widget returns `character(0)`), and after a reset (whatever the reset writes back — see the linked-filters article for the three reset meanings). The `glassMultiSelectValue(..., empty_behavior = "all")` helper changes how the *app* resolves an empty selection; it does not change what the widget returns, which is always `character(0)` when empty. ## Server-side search for large choice sets For hundreds or thousands of choices, keep the full vector on the server and send a small bounded set of matches to the browser. Set `server = TRUE` in the UI and register `glassMultiSelectServer()` in the server function: ```{r server-search} many_choices <- stats::setNames( sprintf("value-%04d", 1:2000), sprintf("Choice %04d", 1:2000) ) ui <- fluidPage( useGlassTabs(), glassMultiSelect( "pick", many_choices, server = TRUE, server_limit = 30, show_style_switcher = FALSE ), verbatimTextOutput("out") ) server <- function(input, output, session) { glassMultiSelectServer("pick", many_choices, session = session, limit = 30) output$out <- renderPrint(length(input$pick)) } if (interactive()) shinyApp(ui, server) ``` Two independent limits bound what the browser renders. They are set in different places and nothing ties them together — setting one does not change the other: | Setting | Where it lives | What it bounds | | ------- | -------------- | -------------- | | `server_limit` (default `50`) | `glassMultiSelect()` / `glassSelect()` | Choices rendered initially, excluding explicitly included selections | | `limit` (default `50`) | `glassMultiSelectServer()` / `glassSelectServer()` | Matches returned for each search | So `server_limit = 25` with `limit = 100` shows 25 choices on open and up to 100 per search. `server_min_chars` (default `0`) sets how many characters must be typed before server-side matching filters choices, and `ignore_case = TRUE` (the default in `glassMultiSelectServer()`) matches labels and values case-insensitively. Two details that make this safe: explicitly selected values that fall outside the initial slice are still rendered as extra rows, so they stay visible and checked; and the control keeps the selected values on the client while only the current bounded set of matches is rendered. Results are bounded, not paginated: the helper returns the first N matches for each search. There is no next-page cursor and no built-in Load more mechanism, so "page" is the wrong word for what comes back. In server mode the Select-all row acts on the currently loaded page: with 20,000 facilities and 30 matches on screen, Select all selects those 30, not all 20,000. The full selection stays represented in Shiny — selected values persist on the client across searches — but only the current matches are rendered. The same scope rule applies to the indicator: it reports the enabled, visible set, not the whole dataset. ### Performance and limitations Server mode reduces what is rendered in the browser, not what R holds: the complete choices vector is still supplied to the constructor and to the server helper, so this is not database-backed lazy search. And because the default multi-select selection contains all enabled choice values, a large selection still creates a large input payload even with bounded DOM rendering — pass `selected = character(0)` when the app does not need everything selected up front. One unsupported configuration: `server = TRUE` with search disabled (`searchable = FALSE` or `"never"`). Without a search input the user cannot query beyond the initial slice, so choices outside it are unreachable through the dropdown. Keep search enabled in server mode. This pattern also suits controls inserted by `renderUI()`: register `glassMultiSelectServer()` once for the same `inputId`, and the newly rendered control receives search results through Shiny. ## Updating from the server `updateGlassMultiSelect()` updates choices, selection, style, shape, disabled state, search visibility and threshold, summary mode, and dropdown height — in one transaction: ```{r updates} ui <- fluidPage( useGlassTabs(), actionButton("subset", "Keep first 3 fruits"), actionButton("pick2", "Select 2 fruits"), actionButton("clear", "Clear"), actionButton("fill", "Switch to filled style"), glassMultiSelect("pick", fruits), verbatimTextOutput("out") ) server <- function(input, output, session) { output$out <- renderPrint(input$pick) observeEvent(input$subset, { updateGlassMultiSelect(session, "pick", choices = fruits[1:3]) }) observeEvent(input$pick2, { updateGlassMultiSelect( session, "pick", selected = c("apple", "cherry") ) }) observeEvent(input$clear, { updateGlassMultiSelect(session, "pick", selected = character(0)) }) observeEvent(input$fill, { updateGlassMultiSelect(session, "pick", check_style = "filled") }) } if (interactive()) shinyApp(ui, server) ``` The `NULL` semantics follow Shiny conventions and are worth memorizing: - `choices = NULL` leaves choices unchanged; `selected = NULL` leaves the selection unchanged. - `selected = character(0)` clears the selection. - When choices are updated without `selected`, the widget keeps the intersection of the current selection and the new choice set. Three arguments control the transaction itself: ```{r transactional-update} server <- function(input, output, session) { observeEvent(input$region, { updateGlassMultiSelect( session, "pick", choices = fruits[fruits %in% c("apple", "banana", "cherry")], preserve_selection = TRUE, drop_invalid = TRUE, searchable = "auto", notify = "changed" ) }) } ``` - `preserve_selection = TRUE` (the default) keeps the current selection when choices change; `FALSE` starts the new choice set empty unless `selected` is supplied explicitly. - `drop_invalid = TRUE` (the default) drops selected values absent from the new choices; `FALSE` keeps them around for when they reappear, which is what a bounded server search wants. - `notify = "changed"` (the default) avoids rerunning observers when the effective selection and style did not change; `"always"` forces an event; `"never"` makes a quiet update for one input only — it does not sync other inputs, so every input whose server value must sync needs its own notification (see the linked-filters recipe). ## Closing dropdowns and dynamic layouts For dynamic layouts, modals, sidebars, or tab changes, close an open dropdown explicitly from the server: ```{r lifecycle} server <- function(input, output, session) { observeEvent(input$change_layout, { closeGlassMultiSelect(session, "pick") }) } ``` Use `closeAllGlassSelects(session)` when a layout change should close any open glasstabs select dropdown. Dropdowns also dismiss themselves when a full-screen overlay appears — a modal, a spinner, or a hand-rolled overlay — so an open panel never floats above a dialog. ## Theming ### Built-in presets ```{r presets} glassMultiSelect("f", fruits, theme = "dark") # default glassMultiSelect("f", fruits, theme = "light") # white panel, dark text glassMultiSelect("f", fruits, theme = "auto") # follows Bootstrap/bslib color mode ``` `theme = "auto"` follows the Bootstrap/bslib color mode: it uses the light preset by default and switches to the dark preset when an ancestor carries `data-bs-theme = "dark"`. For apps that announce dark mode some other way — a `body.dark-mode` class in bs4Dash, for example — pass that selector as `dark_selector`, and the widget emits an extra scoped style block that reverts colors to the dark defaults whenever the selector is active: ```{r dark-selector} glassMultiSelect("f", fruits, theme = "auto", dark_selector = "body.dark-mode" ) ``` ### Custom themes with `glass_select_theme()` Supply only the colors you want to change; everything else inherits from the `mode` base preset: ```{r custom-theme} # One field — accent colour only glassMultiSelect("f", fruits, theme = glass_select_theme(accent_color = "#f59e0b") ) # Two fields glassMultiSelect("f", fruits, theme = glass_select_theme( text_color = "#1e293b", accent_color = "#2563eb" ) ) # Four commonly customized fields glassMultiSelect("f", fruits, theme = glass_select_theme( bg_color = "#1a0a2e", border_color = "#a855f7", text_color = "#ede9fe", accent_color = "#a855f7" ) ) ``` | Argument | What it controls | |----------------|-------------------------------------------------------| | `bg_color` | Dropdown panel and trigger button background | | `border_color` | Border colour | | `text_color` | Main text and label colour | | `accent_color` | Tick marks, badge, checked highlights, Clear all link | | `focus_ring` | Keyboard focus indicator | | `label_color` | Field-title colour | | `mode` | Theme mode metadata (`"dark"` or `"light"`) | ### Presentation: tongue, labels, corners, width ```{r presentation} glassMultiSelect("f", fruits, tongue = TRUE, # attached unfurl (default); FALSE = detached short fade label_bold = TRUE, # semibold field label, matching native inputs label_size = "14px", # keeps the title distinct from the options search_focus_ring = FALSE, # quiet search focus (default); TRUE = accent outline shape = "square" # crisp corners beside native selectize inputs ) ``` `tongue = TRUE` (the default) attaches the dropdown flush to the trigger and unfurls it with the signature animation; `FALSE` detaches the panel with a short fade, which sits more naturally beside native Shiny inputs. Either way the panel matches the trigger width, long labels wrap, and reduced-motion preferences collapse the transitions. ## Placement: standalone, in tabs, in dashboards The multi-select is fully independent of the tab widget — only `useGlassTabs()` (for the CSS and JavaScript) is required. It works inside a `glassTabsUI()` layout, in a plain `fluidPage()`, in a `bslib` page with `theme = "auto"`, and in a `bs4Dash` dashboard with `dark_selector` for the dashboard's dark-mode class. Each instance is scoped to its own `inputId` and works independently; when one dropdown is open it floats above the others: ```{r multi} ui <- fluidPage( useGlassTabs(), glassMultiSelect("a", c(X = "x", Y = "y", Z = "z")), glassMultiSelect("b", c(P = "p", Q = "q", R = "r"), check_style = "filled", show_style_switcher = FALSE) ) ``` ## Keyboard and pointer interaction - **Trigger:** Enter or Space opens and closes the dropdown. Arrow Down / Arrow Up / Home / End open it and move to an option. Escape closes it and returns focus to the trigger; Tab closes it and continues normal keyboard navigation. - **Open dropdown:** Arrow Down / Arrow Up / Home / End move between options; Enter or Space toggles the active option; Escape returns focus to the trigger, while Tab closes the panel and moves onward. - **Chrome rows:** Arrow Down past the last option moves focus to the style switcher, then the "Select all" row, then "Clear all" (Arrow Up reverses); Enter or Space activates the focused button. Tab and Escape behave as above from any focused row. The rows are buttons, not Tab stops, so Tab never gets trapped inside the dropdown. - **Search:** typing filters options (debounced); the "Select all" row follows the filtered, enabled set. - **Pointer and keyboard:** a truncated active option reveals a capped text preview unless `overflow_tooltip = "never"`; clicking a tag pill's x deselects that option. - **Motion:** transitions collapse when the device asks for reduced motion, and the widget carries full accessible labels for assistive technology. ## A worked dashboard: linked filters This is the pattern most apps end up with — two filters, one driving the other, tags beside the results, and a reset that clears both: ```{r dashboard} library(shiny) library(glasstabs) sales <- data.frame( region = c("North", "North", "South", "South", "East", "West"), product = c("Alpha", "Beta", "Alpha", "Gamma", "Beta", "Alpha"), revenue = c(42000, 31000, 27000, 55000, 38000, 61000) ) products_by_region <- list( North = c(Alpha = "Alpha", Beta = "Beta"), South = c(Alpha = "Alpha", Gamma = "Gamma"), East = c(Beta = "Beta"), West = c(Alpha = "Alpha") ) ui <- fluidPage( useGlassTabs(), glassMultiSelect("region", c(North = "North", South = "South", East = "East", West = "West"), show_style_switcher = FALSE ), glassMultiSelect("product", c(Alpha = "Alpha", Beta = "Beta", Gamma = "Gamma"), show_style_switcher = FALSE, check_style = "check-only" ), glassFilterTags("region"), glassFilterTags("product"), actionButton("reset", "Reset filters"), tableOutput("tbl") ) server <- function(input, output, session) { fallback <- function(x, default) { if (is.null(x) || length(x) == 0) default else x } # Region drives the product list; keep what still applies. observeEvent(input$region, { available <- unique(unlist( products_by_region[fallback(input$region, names(products_by_region))], use.names = FALSE )) updateGlassMultiSelect( session, "product", choices = stats::setNames(available, available), preserve_selection = TRUE, drop_invalid = TRUE, notify = "changed" ) }, ignoreNULL = FALSE) observeEvent(input$reset, { updateGlassMultiSelect(session, "region", selected = character(0)) updateGlassMultiSelect(session, "product", selected = character(0)) }) filtered <- reactive({ sales[ sales$region %in% fallback(input$region, unique(sales$region)) & sales$product %in% fallback(input$product, unique(sales$product)), ] }) output$tbl <- renderTable(filtered()) } if (interactive()) shinyApp(ui, server) ``` Note the two update choices that make linked filters behave: the product update keeps the current selection where it still applies (`preserve_selection = TRUE`) and drops values the new region no longer offers (`drop_invalid = TRUE`), and `notify = "changed"` means the table only recomputes when the effective selection actually moved. ## Compared with `selectizeInput(multiple = TRUE)` If you are migrating a native multi-select, this is what changes: - **You gain:** glass theming with dark / light / auto presets and custom themes; trigger summaries (`count`, `summary`, `labels`) that keep a filter bar compact; tag pills placeable anywhere on the page; a runtime style switcher; filled rows with per-option hues; server-side search that still renders out-of-slice selections; option tooltips for long labels; and transactional updates with `preserve_selection`, `drop_invalid`, and `notify` modes. - **The server contract stays familiar:** it is still a Shiny input returning a character vector. Native selectize-only extensions such as creating new choices are outside its scope. The main migration adjustments are defaults: an untouched glasstabs filter means "everything" (pass `selected = character(0)` for an empty start), the default width is a compact `160px` (pass `width = "100%"` for a full-width filter), and clearing is `show_clear_all`, not a `clearable` argument. ## Gotchas, collected - `selected = NULL` means **all** choices selected. For an empty start, pass `selected = character(0)` explicitly. - There is **no `clearable` argument** on `glassMultiSelect()` — that belongs to the single-select. Clearing is `show_clear_all`. - `search_threshold` only matters when `searchable = "auto"`. - `server = TRUE` without a registered `glassMultiSelectServer()` for the same `inputId` leaves search with nothing to answer it. - A choices-only update **keeps** the previous disabled rows; pass `disabled_choices` explicitly in the same update to change them. - An empty filter is `character(0)` at `input$`. If "empty means everything" in your app, resolve it with `glassMultiSelectValue(..., empty_behavior = "all")` instead of special-casing `length(input$pick) == 0` everywhere. - `hues` only applies when `check_style = "filled"` **and** `fill_colors = "multi"`; with the default `"single"`, every filled row uses the theme accent.