VizModules ships two custom Shiny inputs that go beyond standard
shiny::*Input widgets: multiColorPicker
and multiDynamicInput. multiColorPicker
maps colors to the discrete levels of a variable using palettes or
per-group hex pickers, while multiDynamicInput is a
general-purpose widget that lets users dynamically add and remove rows
of heterogeneous inputs.
multiColorPicker assigns a color to each level of a
discrete variable. Users can apply a named palette to every group at
once, or fine-tune individual groups with a native color picker and an
editable hex field. The value reported to input[[inputId]]
is a named character vector of hex colors keyed by group. It is used
throughout the package wherever a plot maps a discrete variable to color
for more granular control than just a palette selection.
The widget renders a palette selector with Apply and Reset buttons, a preview row of swatches, and one row per group. Selecting a palette and clicking Apply recolors every group in order; the per-group color picker and hex input let users override any single group afterwards.
library(shiny)
library(VizModules)
ui <- fluidPage(
br(),
splitLayout(
multiColorPicker(
"dept_cols",
label = "Plot colors",
groups = c("Engineering", "Finance", "HR",
"Marketing", "Operations", "Sales"),
selected_palette = "dittoColors"
),
verbatimTextOutput("value")
)
)
server <- function(input, output, session) {
output$value <- renderPrint(input$dept_cols)
}
shinyApp(ui, server)| Argument | Description |
|---|---|
inputId |
Shiny input id. The value is accessed via
input[[inputId]]. |
label |
Optional label displayed above the control. |
groups |
Character or factor vector of group names — one color row is created per unique value. |
palette_options |
Named list of palettes (each a character vector of colors). Defaults
to default_palettes(). |
selected_palette |
Name of the palette to preselect. |
colors |
Optional named vector of starting colors, matched to
groups by name. |
show_text |
If TRUE (default), show editable hex text inputs beside
the color pickers. |
compact |
If TRUE, render a tighter layout with smaller
controls. |
panel |
If FALSE, remove the surrounding panel/well
styling. |
The value in input[[inputId]] is a named character
vector of hex colors keyed by group:
Use updateMultiColorPicker() to set specific colors,
apply a palette by name, or reset the widget to its initial state:
# Set specific group colors (only named groups change)
updateMultiColorPicker(session, "dept_cols",
colors = c(Engineering = "#1B9E77", Finance = "#D95F02"))
# Apply a named palette to all groups
updateMultiColorPicker(session, "dept_cols", palette = "ggplot2")
# Reset colors and palette back to the initial state
updateMultiColorPicker(session, "dept_cols", reset = TRUE)Exactly one of colors, palette, or
reset should be supplied per call; reset takes
precedence, followed by palette, then
colors.
multiColorPicker() — place in your UI, one color row
per groupinput[[inputId]] — read the named character vector of
hex colors in your serverupdateMultiColorPicker() — set colors, apply a palette,
or reset from the servermultiDynamicInput renders a “+ Add” button and a
container. Each time the user clicks Add, a new row appears with
whatever fields you define. An “×” button on each row deletes it. The
collected value is reported to input[[inputId]] as a named
list of rows.
Unlike insertUI/removeUI, rows are managed
entirely client-side via DOM template cloning — no server round-trips
for add/delete.
library(shiny)
library(VizModules)
ui <- fluidPage(
br(),
splitLayout(
multiDynamicInput(
"layers",
label = "Layers",
row_spec = list(
colour = list(
type = "colour",
args = list(value = "#000000")
),
size = list(
type = "numeric",
args = list(value = 2, min = 0.5, max = 10)
),
style = list(type = "select",
args = list(choices = c("solid", "dashed", "dotted")))
)
),
verbatimTextOutput("value")
)
)
server <- function(input, output, session) {
output$value <- renderPrint(input$layers)
}
shinyApp(ui, server)| Argument | Description |
|---|---|
inputId |
Shiny input id. The value is accessed via
input[[inputId]]. |
label |
Label shown next to the + Add button. Also determines row naming (lowercased). |
row_spec |
Named list defining one row’s fields. Each entry has
type (or fn) and args. |
elements |
Optional list of pre-filled rows to show on startup. |
max_per_row |
Number of fields per visual line before wrapping (default 4). |
panel |
If FALSE, removes the border/background styling. |
row_specEach field in row_spec is a named list with:
type — a shorthand alias:
"select", "text", "numeric",
"slider", "checkbox", "colour" /
"color".fn — alternatively, any input
constructor function (e.g. shiny::dateInput). Use this for
inputs without a built-in alias.args — a list of arguments passed to
the constructor (everything except inputId, which is
auto-generated per row).# Using type aliases
row_spec = list(
name = list(type = "text", args = list(placeholder = "Enter name")),
age = list(type = "numeric", args = list(value = 25, min = 0, max = 120))
)
# Using fn for a custom input
row_spec = list(
date = list(fn = shiny::dateInput, args = list(label = "Date", value = Sys.Date()))
)elementsTo show rows on startup with specific values:
multiDynamicInput(
"models",
label = "Models",
row_spec = list(
model_type = list(type = "select", args = list(choices = c("lm", "glm"))),
formula = list(type = "text", args = list(placeholder = "y ~ x"))
),
elements = list(
models1 = list(model_type = "lm", formula = "revenue ~ units"),
models2 = list(model_type = "glm", formula = "revenue ~ poly(units, 2)")
)
)The names (models1, models2) don’t matter
for input — they’re auto-generated from the label at runtime. The values
inside each element must match the row_spec field
names.
The value in input[[inputId]] is a named list of
rows:
# With label = "Models" and two rows filled in:
input$models
#> list(
#> models1 = list(model_type = "lm", formula = "revenue ~ units"),
#> models2 = list(model_type = "glm", formula = "revenue ~ poly(units, 2)")
#> )Row names are derived from the label argument
(lowercased) plus an index: models1, models2,
etc.
Use updateMultiDynamicInput() to replace all rows or
clear them:
When used with the model backend system (see
vignette("custom-model-lines")), fields can be tagged with
a backend attribute. These fields are hidden by default and
only appear when the matching model type is selected:
row_spec = list(
model_type = list(type = "select", args = list(choices = c("lm", "drm"))),
formula = list(type = "text", args = list(placeholder = "y ~ x")),
# Only visible when model_type == "drm"
drc_fct = list(type = "select", backend = "drm",
args = list(choices = c("LL.4", "LL.3")))
)In practice you don’t build this manually —
build_model_row_spec() does it for you by collecting
fields from all registered backends.
multiDynamicInput() — place in your UIinput[[inputId]] — read the named list of rows in your
serverupdateMultiDynamicInput() — set or clear rows from the
serverbuild_model_row_spec() — auto-build a row_spec from
registered model backendsBoth widgets above follow the same shape, and it is the one to copy
if you add a third: a constructor in R/, a JS binding and a
stylesheet in inst/src/, and an
htmlDependency() tying them together.
A custom input’s stylesheet is injected into the host app’s
document. There is no shadow DOM and no automatic scoping, so a
rule written for your widget applies to the whole page. Since every plot
module renders a multiColorPicker, attaching any module to
an app brings these stylesheets along — which is how a single unscoped
selector came to break stock selectInput() dropdowns in
apps that never used the picker directly (#355).
Anchor every selector on a class you invented:
/* WRONG — `.selectize-dropdown`, `.option` and `.optgroup-header` all belong to
selectize, so this restyles every dropdown on the page, including plain
selectInput()s and DT's column filters. */
.selectize-dropdown .selectize-dropdown-content {
display: flex;
flex-direction: column;
}
/* RIGHT */
.mc-palette-dropdown .selectize-dropdown-content {
display: flex;
flex-direction: column;
}The mistake is easy to make because the wrong version looks scoped — a two-part selector reads like “the content inside my dropdown”. The test is the leftmost class: if you did not invent it, the rule is not scoped.
A dropdown or popover often has to be parented to
<body> so a panel with overflow: hidden
cannot clip it. That is precisely what makes it unreachable from a
.my-widget .thing selector — and precisely why the leaking
rules above were written unscoped. The answer is to give the escaped
element its own class:
$(select).selectize({
dropdownParent: "body",
// Selectize replaces its default dropdownClass wholesale, so restate its own
// class alongside ours.
dropdownClass: "selectize-dropdown mc-palette-dropdown",
...
});multiColorPicker does exactly this, which is why
multiColorPicker.css can scope all fourteen of its dropdown
rules to .mc-palette-dropdown.
Before trusting a rule, inspect the element and check the class is
really on it. One of the picker’s rules targeted .option,
but its custom render.option emits
.mc-palette-option and selectize never adds
.option to it — so the rule matched nothing in the picker
and only affected other widgets.
style=
attributes. They can only be overridden with
!important. Put layout in the stylesheet and pass
per-instance values as CSS custom properties
(style = "--my-columns: 3;").gap needs nothing from the parent.min-width: 0, or
long content refuses to shrink and pushes the widget wider than its
container.htmlDependency() pointing at
inst/src/, attached to the widget’s own markup with
htmltools::attachDependencies(), so the styles travel with
it through a runtime insertUI().tests/testthat/test-ui_utils.R parses every selector in
every stylesheet the package ships — bundled files and inline
<style> blocks alike — and fails on any that is not
anchored to a package-owned prefix. Add yours to that list when you
introduce a widget.