VizModules Custom Shiny Inputs

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

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:

# 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:

row_spec = list(
    groups = list(type = "select", args = list(
        label = "Groups, in order", choices = c("setosa", "versicolor", "virginica"), multiple = TRUE
    ))
)
# elements = list(trajectories1 = list(groups = c("virginica", "setosa")))

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

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)

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

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.