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()))
)A "select" field given multiple = TRUE
reports a character vector of its items, in the order they were picked,
so a row can hold an ordered list. The scatter module’s
Trajectories list works this way, one row per
trajectory, each picking the groups its path visits:
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 backendslevel_order_input() is a drag-and-drop list of a
categorical column’s levels, built on
shinyjqui::orderInput(), that sets the order they are
plotted in. The BarPlot, BoxPlot,
freqPlot and yPlot modules use it for their
x-axis; the same three helpers add it to a custom module:
ui <- fluidPage(
selectInput("x", "X", c("department", "job_level")),
level_order_input("x.order", "X Order",
items = level_order_choices(example_demographics, "department")
),
plotOutput("plot")
)
server <- function(input, output, session) {
data <- reactive(example_demographics)
# Refills the control when the column or data changes, and holds the order to draw.
x.order <- setup_level_order(input, session, data, reactive(input$x))
output$plot <- renderPlot({
df <- apply_level_order(data(), input$x, x.order())
boxplot(df$salary ~ df[[input$x]], xlab = input$x)
})
}
shinyApp(ui, server)level_order_choices() — the levels of a column in
drawing order, optionally reordered; NULL for a numeric
column or one with more than max.levels (20) levels, which
hides the controlsetup_level_order() — keeps the control in step with
its column, restores defaults$x.order on Reset, and returns
the order to draw (NULL for the default)apply_level_order() — re-levels the column, so the
plot, its statistics and their brackets all take the new orderBoth 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.