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
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.
Overview
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.
Basic Usage
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)
Key Arguments
| 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. |
Reading the Value
The value in input[[inputId]] is a named character
vector of hex colors keyed by group:
# With groups = c("Engineering", "Finance", "HR", ...)
input$dept_cols
#> c(
#> Engineering = "#E69F00",
#> Finance = "#56B4E9",
#> HR = "#009E73",
#> Marketing = "#F0E442",
#> Operations = "#0072B2",
#> Sales = "#D55E00"
#> )Updating from the Server
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.
Summary
-
multiColorPicker()— place in your UI, one color row per group -
input[[inputId]]— read the named character vector of hex colors in your server -
updateMultiColorPicker()— set colors, apply a palette, or reset from the server
multiDynamicInput
Overview
multiDynamicInput 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.
Basic Usage
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)
Key Arguments
| 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. |
Defining Fields with row_spec
Each 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 exceptinputId, 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:
Pre-filled Rows with elements
To 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.
Reading the Value
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.
Updating from the Server
Use updateMultiDynamicInput() to replace all rows or
clear them:
# Replace all rows
updateMultiDynamicInput(session, "models", elements = list(
models1 = list(model_type = "lm", formula = "y ~ x")
))
# Clear all rows
updateMultiDynamicInput(session, "models", clear = TRUE)Backend-Specific Fields
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.
Summary
-
multiDynamicInput()— place in your UI -
input[[inputId]]— read the named list of rows in your server -
updateMultiDynamicInput()— set or clear rows from the server -
build_model_row_spec()— auto-build a row_spec from registered model backends
Category order controls
level_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;NULLfor a numeric column or one with more thanmax.levels(20) levels, which hides the control -
setup_level_order()— keeps the control in step with its column, restoresdefaults$x.orderon Reset, and returns the order to draw (NULLfor the default) -
apply_level_order()— re-levels the column, so the plot, its statistics and their brackets all take the new order
Writing Your Own Custom Input
Both 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.
Styling: the rule that matters most
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.
Widgets that escape their container
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.
Confirm your selectors actually match
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.
Other practices worth copying
-
Keep layout out of inline
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;"). -
Do not depend on the parent’s padding. Bootstrap’s
negative-margin row idiom breaks in any container with less padding than
it assumes;
gapneeds nothing from the parent. -
Give flex children
min-width: 0, or long content refuses to shrink and pushes the widget wider than its container. - Let every row wrap. These widgets land in sidebars of any width, so controls that cannot shrink past a fixed width spill out instead of reflowing.
-
Ship the CSS through an
htmlDependency()pointing atinst/src/, attached to the widget’s own markup withhtmltools::attachDependencies(), so the styles travel with it through a runtimeinsertUI().
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.