Adding a New Module

This checklist walks through how to add a new plotting module to VizModules so it matches the package’s organization, documentation, and testing standards.

VizModules modules are designed to be a joy to use, which requires some discipline to implement in a consistent way. This checklist may seem daunting at first glance, but most of the items already have helpers to implement them.

And a fair few are to keep you from shooting yourself in the foot and avoiding most of the common module pitfalls.

If you are working with an AI coding agent, the package ships an Agent Skill covering exactly this checklist - vizmodules-new-module - along with the required roxygen sections, the uniform input helpers, the traps that have cost real time here, and file templates to copy. Install it (and its two siblings) into your project with VizModules::use_vizmodules_skills("."), or pass client = "copilot" / client = "claude" to write to .github/skills/ or .claude/skills/ instead of the default .agents/skills/. The skills live in inst/skills/ in this repository, so keep them in step with this vignette when you change a convention it documents.

Quick Checklist

Naming & Organization

Documentation Standards

1. @section Plot parameters not implemented or with altered functionality:

List all parameters from the base plot function that are not exposed via UI inputs, with explanations:

#' @section Plot parameters not implemented or with altered functionality:
#' The following [plotthis::AreaPlot()] parameters are not available via UI inputs:
#' \itemize{
#'   \item \code{xlab} - X-axis label (plotly allows interactive editing)
#'   \item \code{ylab} - Y-axis label (plotly allows interactive editing)
#'   \item \code{title} - Plot title (plotly allows interactive editing)
#'   \item \code{subtitle} - Plot subtitle (not supported in plotly)
#'   \item \code{legend.position} - Legend positioning (plotly allows interactive repositioning)
#'   \item \code{split_by} - Split variable (returns a patchwork object, not supported in plotly)
#'   \item \code{palette} - Managed internally via the palette selection UI
#' }

2. @section Plot parameters and defaults:

Document all parameters that are exposed, listing their UI label and default value:

#' @section Plot parameters and defaults:
#' The following [plotthis::AreaPlot()] parameters can be accessed via UI inputs and/or the \code{defaults} argument:
#' \itemize{
#'   \item \code{x} - X-axis variable (UI: "X values", default: 2nd categorical variable)
#'   \item \code{y} - Y-axis variable (UI: "Y values", default: 2nd numeric variable)
#'   \item \code{group_by} - Grouping variable (UI: "Group by", default: 3rd categorical variable or "")
#'   \item \code{facet_by} - Faceting variable (UI: "Facet by", default: "")
#'   \item \code{theme} - ggplot2 theme (UI: "Theme", default: "theme_this")
#'   \item \code{alpha} - Area fill transparency (UI: "Alpha", default: 1)
#' }

3. @section Plot parameters implementing new functionality:

Document all module-specific parameters (plotly controls, reference lines, etc.):

#' The following parameters implementing new functionality or controlling plotly-specific features are also available:
#' \itemize{
#'   \item \code{axis.font.size} - Axis title font size (UI: "Axis font size", default: 18)
#'   \item \code{axis.showline} - Show axis border lines (UI: "Show axis lines", default: TRUE)
#'   \item \code{axis.tickfont.size} - Size of tick labels (UI: "Tick label size", default: 12)
#'   \item \code{hline.intercepts} - Y-coordinates for horizontal reference lines (UI: "Y-intercepts", default: "")
#'   \item \code{hline.colors} - Colors for horizontal lines, comma-separated (UI: "Colors", default: "#000000")
#'   \item \code{hline.linetypes} - Line types for horizontal lines, comma-separated (UI: "Line types", default: "dashed")
#'   \item \code{vline.intercepts} - X-coordinates for vertical reference lines (UI: "X-intercepts", default: "")
#'   \item \code{abline.slopes} - Slopes for diagonal reference lines (UI: "Slopes", default: "")
#' }

Note: Reference line parameters (hline.*, vline.*, abline.*) accept comma-separated values to control each line individually.

Functionality & Non-Exposed Inputs

Example App Requirement

myPlotApp <- function(data_list = NULL, defaults = NULL, hide.inputs = NULL, hide.tabs = NULL) {
    # With no data, open on the module's showcase example (its entry in
    # .module_showcase()), layering any caller defaults over its own.
    if (is.null(data_list)) {
        example <- .module_example("myplot")
        data_list <- example$data_list
        defaults <- utils::modifyList(example$defaults, defaults %||% list())
    }
    createModuleApp(
        inputs_ui_fn = myPlotInputsUI,
        output_ui_fn = myPlotOutputUI,
        server_fn    = myPlotServer,
        data_list    = data_list,
        defaults     = defaults,
        hide.inputs  = hide.inputs,
        hide.tabs    = hide.tabs,
        title        = "Modular myPlots"
    )
}

Testing Requirements

Implementing a New Plotting Function (e.g., piePlot)

Supporting Reactive Defaults

An entry in defaults may be a reactive() rather than a fixed value, so a parent app can make an input follow its state without the double render that update*Input() causes (see vignette("defaults-and-hiding", package = "VizModules")). Two lines wire this up, and they are required in every new module.

Easy. setup_reactive_defaults() returns NULL when no entry is reactive, in which case isolate_fn is the plain identity()/isolate() it has always been. When a store is present, isolate_fn recognises direct input$<key> reads and resolves them from the store instead, so your existing read sites need no edits, as long as they stay in the isolate_fn(input$<key>) form. A wrapped read such as isolate_fn(as.numeric(input$size)) cannot be recognised and will not support a reactive default; do the conversion outside the call instead.

Your *InputsUI() and reset observer need no special handling: both go through get_default(), which resolves reactive entries on its own. Reset therefore restores the reactive’s current value.

Updating Your Own Inputs From the Server

Modules often derive a value on the server and push it back into one of their own controls, e.g. an auto-calculated y-axis range, a regenerated list of stat comparison pairs, a rebuilt colour picker. update*Input() is an asynchronous round-trip to the browser, so the plot renders twice: once immediately with the stale value, then again when the client echoes the new one. This is annoying as hell and can be avoided by freezing the input before you update it:

Freezing pauses everything that reads that input until the real value arrives, so the intermediate render never happens. Three rules:

This does not apply to the reset observer, where a burst of updates is expected.

Inputs Rebuilt by renderUI()

Freezing does not cover an input your module rebuilds with renderUI(), such as a [multiColorPicker()] whose groups follow the data. A freeze pauses only the readers that run after it in that flush, and at startup the plot output runs first. The freeze lands too late to pause anything, and the value the freshly built input reports then rebuilds the plot for a mapping it was already drawing.

In short, this results in the plot being re-rendered one or more times in a way that can be annoying, particularly for large data sets.

Give the plot a server-side value to read instead, so the client’s echo is compared against what is already in use rather than against NULL. For a colour picker, setup_group_colors() does this for you — it resolves the mapping as soon as the group set is known and holds it in a reactiveVal(), which only invalidates on a real change:

The same shape works for any renderUI()-rebuilt input: resolve the value on the server, hold it in a reactiveVal(), and have the plot read that.

Axis Limits

Limits behave the same way, and for the same reason: the module derives them on the server, pushes them into the y.min/y.max controls, and the echo of that push rebuilds the plot. setup_axis_range() is the store for them.

If your module draws significance brackets, pass headroom as well. The brackets are stacked above the data, so the limits have to clear them or they are drawn clipped; stat_bracket_y_max() works out how high they will reach, and the store raises the maximum to meet it and updates the control to match. It only ever raises, so a larger limit the user chose is left alone:

y_range_store <- setup_axis_range(
    input, session, params = params,
    headroom = function() {
        if (!isTRUE(input$stats.enabled)) {
            return(NULL)
        }
        .stat_bracket_headroom(
            df = data(), x = input$x.data, y = input$y.data,
            group.by = .blank_to_null(input$group.by),
            facet.by = .blank_to_null(input$facet.by),
            per.facet = isTRUE(input$stat.per.facet),
            input = input
        )
    }
)

Pass the resolved limits on to apply_stat_annotations() too, as y.min and y.max. It has the last word on the drawn range, and knowing what you asked for is what lets it leave a large maximum alone rather than shrinking the axis onto the brackets.

Debouncing Free-Text Inputs

The re-render problems above come from the server pushing values at the client. Free text has the opposite problem: textInput() and textAreaInput() report to the server on every keystroke, so a plot that reads one directly is rebuilt once per character.

That matters most when the input is an expression, because the intermediate states are not just wasted work, they are mostly invalid work. Typing condition == "Disease" walks through c, co, con, … and every one of those is a complete render cycle spent on an expression that cannot parse.

Wrap the read in shiny::debounce() so a burst of typing collapses into one render once the user pauses:

# Once, in the module server body -- not inside a reactive.
filter_text <- debounce(reactive(input$row_filter), 700)

filtered <- reactive({
    keep <- safe_eval_filter(filter_text(), data())
    if (is.null(keep)) data() else data()[keep, , drop = FALSE]
})

debounce() emits its initial value immediately and then holds the previous value while typing, so startup is unaffected and there is no blank first render to design around.

ComplexHeatmap_HeatmapServer()’s Row/Column Filter inputs are the worked example — a heatmap is drawn to a device and measured before it can be shown, so a per-keystroke rebuild is especially painful there.

Persisting Manual Layout Edits

Every VizModules plot is interactively editable: users can drag the legend, reposition or re-text annotations, drag the (draggable) axis titles to their heart’s content.

Because each module rebuilds its figure from scratch on every input change, those hand-made tweaks would be lost on the next re-render unless they are captured and re-applied.

Two exported helpers handle this for you. Use them in every new module so the behaviour is available from the start.

Server

Pretty simple.

setup_manual_edits() registers the observers that capture plotly_relayout events (legend, annotation, and axis-title drags) plus the JavaScript-forwarded colorbar drag; finalize_manual_edits() tags the figure with the event source, restores any captured edits, records the figure for stable annotation keying, and re-attaches the colorbar listener.

Edits are matched to annotations by a content-derived key, so they survive even when annotations are added, removed, or reordered between rebuilds (e.g. when statistical brackets or reference labels appear).

Key helpers (all in R/plot_helpers.R)

Function Purpose
setup_manual_edits() Create the edit store and register the relayout/colorbar capture observers (call once, near the top of the server)
finalize_manual_edits() Tag the event source, restore captured edits, record the figure, and attach the colorbar listener (call in renderPlotly(), just before returning)

Both functions are exported, so the same two-step pattern works from a custom module in your own package. The supporting internals (.capture_manual_edits(), .reapply_manual_edits(), .add_colorbar_listener()) are not exported and shouldn’t need to be used directly (though you can always access them via VizModules::: if really necessary.

See any module server (e.g. dittoViz_scatterPlotServer) for a complete example.

Integrating Statistical Testing (Stats Tab)

Modules for categorical-vs-numeric plots (box, violin, etc.) can include an optional Stats tab that provides pairwise statistical testing with plotly bracket annotations. If your new module supports grouped comparisons along a categorical x-axis, follow this pattern:

UI

Server

Key helpers (all in R/stat_helper.R)

Function Purpose
compute_pairwise_stats() Run pairwise or omnibus tests with p-value adjustment
create_stat_annotations() Convert stats to plotly shapes/annotations with bracket packing
apply_stat_annotations() Append shapes/annotations to the plotly figure and adjust y-axes
generate_pair_strings() Build "A vs B" strings for the comparison selector
parse_pair_strings() Convert selected pair strings back to list of length-2 vectors

See the plotthis_BoxPlotServer, dittoViz_yPlotServer, or dittoViz_freqPlotServer implementations for complete integration examples.

Review Before Submitting

Style Guide

Following a consistent style makes the package easier to read, maintain, and extend. Apply these conventions to every new module.

Input Labels

Select Inputs

Use viz_select_input() rather than shiny::selectInput() or shiny::selectizeInput(), and update_viz_select() in place of their update*() counterparts. It takes the same inputId/label/choices/selected/multiple arguments, but renders a virtualised dropdown, so an input backed by a column with tens of thousands of distinct values stays usable. A search box appears automatically once there are more than ten choices.

An empty-string choice still means “no selection”; it is displayed as (none) so users can see and pick it.

viz_select_input(ns("group.by"), "Group By",
    choices = cat.choices,
    selected = get_default(defaults, "group.by", "", function(x) x %in% cat.choices)
)

Tooltips with tipify

Wrap any non-obvious input in shinyBS::tipify() to show a tooltip on hover. This keeps labels concise while still informing the user.

Apply tipify when:

Standard pattern — always use placement = "top" and options = list(container = "body") so tooltips render correctly inside sidebar panels:

tipify(
    textInput(ns("hline.intercepts"), "Y-intercepts",
        placeholder = "e.g. 2, -2",
        value = get_default(defaults, "hline.intercepts", "")
    ),
    paste(
        "For categorical or factor axes, enter the index (position) of the",
        "category rather than its name."
    ),
    placement = "top", options = list(container = "body")
)

Inputs that are self-explanatory from their label (e.g., "Plot Title", "X-axis Variable") do not need a tooltip.

Reuse Uniform Input Helpers

In time, these helpers will be further formalized and exported, but they can be used with the VizModules::: prefix in the meantime.

Before writing custom inputs, check whether a uniform helper already covers your needs:

Helper Provides
uniform_lines_inputs_ui() Horizontal, vertical, and diagonal reference line controls
uniform_axes_inputs_ui() Font, axis border, gridline, tick, and facet styling
.uniform_stats_inputs_ui() Pairwise statistical testing and bracket annotation controls
uniform_plotly_inputs_ui() Download buttons, margins, subplot spacing, and draw-shape styling
uniform_legend_inputs_ui() Legend visibility, font family and colour, and title and entry label font sizes
uniform_annotation_inputs_ui() Highlighting and labelling of individual data points

Each UI helper has a matching reset_*_inputs() function to call from the module’s observeEvent(input$reset, ...) block.

Modules that draw individual points can adopt uniform_annotation_inputs_ui() to let users highlight and label points by the values of a chosen column. The server side needs the chosen column carried in the plot’s hover text (that is where the values are read back from), then .apply_highlight_styling() to restyle matching markers and .create_highlight_annotations()/.create_selected_annotations() to build the labels. Set require.markers = TRUE when other scatter traces are drawn from the same data (box or violin outlines, say) so only the point markers are matched. Append the resulting annotations to fig$x$layout$annotations rather than replacing them, or facet strip labels and statistical brackets will be lost.

Pass ns and a defaults list to each helper. Use the include.* arguments to opt in to optional groups (e.g., include.fit.lines = TRUE for scatter plots, include.rotate = TRUE for bar plots).

Using the uniform helpers ensures that shared inputs behave identically across every module and that future changes to those helpers propagate automatically.

Imports: @importFrom Over ::

The only exception is a one-off call in an @examples block or vignette where the full qualified name aids readability.

Additional Conventions

Styling and CSS Containment

Every stylesheet a module loads is injected into the host app’s document. There is no shadow DOM and no automatic scoping: a rule you write for your widget applies to the whole page. Because each plot module pulls in a colour picker, attaching any module to a page brings the package’s stylesheets with it — so a careless selector silently restyles an app that merely embedded one plot.

This has bitten the package three times (#355), and every case was the same mistake: styling a class the package does not own.

Only ever style classes this package created

Anchor every selector on a package-owned prefix — .multi-color-picker, .mc-, .mdi-, .vizmodules-, .viz-, .pb-. Never write a bare rule against a class that belongs to Bootstrap, Shiny, selectize, DT or any other library:

/* WRONG — restyles every dropdown, well and tab strip in the host app */
.selectize-dropdown .option { padding: 0 !important; }
.well .btn { padding: 4px 10px; }
.nav-tabs { flex-wrap: wrap; }

/* RIGHT — reaches only markup this package rendered */
.mc-palette-dropdown .option { padding: 0 !important; }
.pb-app .well .btn { padding: 4px 10px; }
.vizmodules-input-tabs > .nav-tabs { flex-wrap: wrap; }

The trap is that these rules look scoped. .well .btn reads like “the buttons in my well” — but .well is what shiny::sidebarPanel() renders, so it is every sidebar on the page. If a selector’s leftmost class is not one you invented, it is not scoped.

tests/testthat/test-ui_utils.R enforces this: it parses every selector in every bundled and inline stylesheet and fails on anything not anchored to a package prefix. Add your widget’s prefix there if you introduce one.

A dropdown or popover parented to <body> needs its own marker class

Anything that escapes its container to avoid clipping — selectize’s dropdownParent: "body", a tooltip, a popper — can no longer be reached by a .my-widget .thing selector, which is exactly why the leaking rules were written unscoped in the first place. Give the escaped element a class of its own instead:

$(select).selectize({
  dropdownParent: "body",
  // Selectize replaces its default dropdownClass wholesale, so its own class
  // has to be restated alongside ours.
  dropdownClass: "selectize-dropdown mc-palette-dropdown",
  ...
});

Check that the rule matches what you think it does

.selectize-dropdown .option never matched the colour picker’s own options at all — its custom render.option emits .mc-palette-option, and selectize does not add .option to that. The rule’s only effect was on other people’s dropdowns: a selector that misses everything you own and hits everything you don’t. Open the widget, inspect the element, and confirm the class you are targeting is actually on it.

Keep layout out of inline styles

An inline style= attribute can only be overridden with !important, which makes a host app fight your widget rather than theme it. Put layout in a stylesheet and pass only the values that vary per instance, as CSS custom properties:

# organize_inputs() emits just the column count; the rest lives in vizModules.css
div(
    class = "vizmodules-input-grid",
    style = paste0("--viz-input-columns: ", columns, ";"),
    lapply(cells, function(x) div(class = "vizmodules-input-cell", x))
)

Do not assume anything about the parent

The control grid used Bootstrap’s negative-margin row idiom (margin-left: -15px on the row, matching padding on each cell), which assumes a parent with at least that much horizontal padding to absorb it. Dropped into a bslib::sidebar() with less, the grid overhung on both sides and the sidebar grew a horizontal scrollbar. Use gap instead — it needs nothing from the parent.

Give flex children min-width: 0 as well. Without it a long select or label refuses to shrink below its intrinsic width and pushes the container wider — the same overflow by a different route.

Shipping a stylesheet

Put it in inst/src/, serve it through an htmlDependency(), and attach it to the UI that needs it rather than to the app as a whole:

.viz_modules_dependency <- function() {
    htmlDependency(
        name = "viz-modules",
        version = as.character(utils::packageVersion("VizModules")),
        src = "src", package = "VizModules",
        stylesheet = "vizModules.css"
    )
}

Attaching it with htmltools::attachDependencies(ui, dep, append = TRUE) means the styles travel with the markup — including through a runtime insertUI(), which the Figure Builder relies on when it injects a panel’s controls.

Verifying containment

Automated checks catch an unscoped selector, but not whether your rule actually changed anything. To see the real effect, load the widget beside a stock selectInput(), sidebarPanel() and DT::datatable(), then disable just your stylesheet in the browser and re-measure:

document.querySelectorAll('link[rel=stylesheet]').forEach(function (l) {
  if (l.href.indexOf('yourFile.css') !== -1) { l.disabled = true; }
});

If a computed style on the host’s own controls changes, your CSS is leaking. If nothing in your widget changes, your rule was never matching in the first place.

Sanitizing User-Provided Expressions

Never use eval(str2expression()) or eval(parse()) on raw user input. If a Shiny app is deployed publicly, this allows arbitrary code execution on the server (e.g., system("rm -rf /")). VizModules provides three exported helper functions for safely handling user-typed expressions. Use them whenever your module accepts free-text input that will be evaluated or passed to a plotting function.

safe_eval_filter(expr_text, data)

Use when a module evaluates a user-typed filter expression directly to produce a logical vector for row subsetting. The expression is parsed, its AST is walked to ensure only allowed operations are present (comparisons, logical operators, column references, and literals), and then it is evaluated in a restricted environment containing only the data frame’s columns.

# In a module server — filtering rows by a textInput:
rows.use = safe_eval_filter(isolate_fn(input$rows.use), data())

Returns a logical vector (same length as nrow(data)), or NULL if the input is empty, unparseable, or contains disallowed operations.

validate_expression(expr_text, col_names)

Use when a module passes a user-typed expression string through to a downstream plotting function that will evaluate it internally (e.g., plotthis::BoxPlot(highlight = ...)). The string is validated but not executed.

# In a module server — passing a highlight expression to plotthis:
highlight <- validate_expression(isolate_fn(input$highlight), names(data()))

Returns the original string if safe, or NULL.

safe_resolve_adj_fxn(fn_name)

Use when a module resolves a function name from a dropdown or text input into an actual function reference (e.g., for x.adj.fxn, y.adj.fxn). Only function names in the allowed list ("log2", "log", "log10", "neg_log10", "log1p", "as.factor", "abs", "sqrt") are accepted.

# In a module server — resolving an adjustment function:
x.adj.fxn = safe_resolve_adj_fxn(isolate_fn(input$x.adj.fxn))

Returns the function, or NULL if the name is empty or not in the allowed list.

What counts as “allowed”?

safe_eval_filter() and validate_expression() share one whitelist of safe AST nodes:

Anything outside this list (including function calls like system(), file.remove(), library(), etc.) is rejected and a warning is issued. So is a namespaced or extracted call such as base::log(x), which would otherwise slip past a name-based check.

The expression must also be a single statement: anything after a ; or a newline is rejected rather than silently ignored.

safe_resolve_adj_fxn() is the exception — it does not walk an AST at all. It matches the whole input against its own short list of numeric transforms (log2, log, log10, neg_log10, log1p, as.factor, abs, sqrt) and resolves it with match.fun().

Model formulas (.safe_build_model(), behind the custom fit lines) reuse the same walker with a formula-specific vocabulary: ~, +, -, *, /, ^, (, :, I(), and the transforms log(), log2(), log10(), sqrt(), exp(), poly().