Pathogen View Components is a focused library of Rails ViewComponents and Stimulus controllers designed for accessible, internationalized, and consistent UI across Pathogen and IRIDA Next applications. It provides a small, opinionated design system: sensible defaults, strong accessibility primitives, and the hooks you need to extend behavior without fighting the framework.
This repository is the extracted, standalone home for the Pathogen UI layer. It ships as a Rails engine with helpers, precompiled CSS, and JavaScript source for host applications to bundle with esbuild.
- Accessible by default: ARIA patterns, focus management, and SR-friendly utilities.
- Component-first API: ViewComponents with slots and options that scale with your app.
- Stimulus-ready: Built-in controllers for tabs, tooltips, disclosures, data grids, and toolbars.
- Pre-built Tailwind CSS: one compiled stylesheet (
pathogen_view_components.css) with design tokens as CSS variables; host apps do not run Tailwind. - Engine-powered: Helpers, locales, and assets wired through the Rails engine.
JavaScript coverage runs in CI and is surfaced in two places for pull requests:
- Sticky PR comment with lines, statements, functions, and branches coverage.
- Workflow summary plus uploaded
javascript-coverageartifact containing HTML, LCOV, and JSON reports.
Running pnpm test:coverage locally prints a per-file table with uncovered line numbers, making it easy to see exactly what to test next.
Coverage is ratcheted per file: once a file reaches 100%, it is added to the allowlist in vitest.config.js and CI fails if it regresses. Run pnpm test:coverage:strict locally to demand 100% across every file.
For host applications:
- Ruby 3.3+
- Rails 8.1+
view_component>= 4.0, < 5.0
CI runs the Ruby suite on Ruby 3.3 and the development version in .ruby-version,
using the locked Rails 8.1 dependency set. Broader dependency bounds do not mean every
newer Rails/Ruby combination has been tested. Applications on older Rails versions
must upgrade before adopting this library version.
For contributing, use the exact Ruby version in .ruby-version, Node.js 24, and
pnpm 11.22, with the committed lockfiles. Node and pnpm are build/test tools;
the gem ships precompiled CSS for consumers.
JavaScript dependencies (installed by the host application):
@hotwired/stimulus^3.0.0@hotwired/turbo-rails^8.0.0 (peer dependency)uuid^14.0.2@floating-ui/dom^1.8.0
Add this line to your application's Gemfile:
gem 'pathogen_view_components'Then install:
bundle installPathogen components are under the Pathogen namespace and follow the ViewComponent render pattern.
<%= render Pathogen::Button.new(tone: :primary, emphasis: :solid, text: "Save") %>Pass button text with text: in Lookbook preview templates and other ERB templates rendered outside a normal ViewComponent block context. Content blocks still work from Ruby preview methods and host app views.
Use disabled: true for fully inactive buttons (removed from tab order). Use aria_disabled: true when the
button should stay focusable but not act yet, for example, a form submit that announces validation errors after
activation (focusable disabled pattern).
<%= render Pathogen::Button.new(tone: :primary, emphasis: :solid, aria_disabled: true, text: "Continue") %>For icon-only actions, use icon_only: true with a required accessible name. Pass the icon through
leading_visual or trailing_visual. When multiple icon-only buttons repeat the same visual, give each a
distinct name:
<%= render Pathogen::Button.new(icon_only: true, text: "Edit payment date", size: :small) do |button| %>
<% button.with_leading_visual do %>
<%= icon("pencil", class: "size-4") %>
<% end %>
<% end %>Navigation that looks like a button should use tag: :a with an href:
<%= render Pathogen::Button.new(tag: :a, href: samples_path, tone: :primary, emphasis: :solid) { "View samples" } %><%= render Pathogen::DataGridComponent.new(rows: @rows, caption: "Samples") do |grid| %>
<% grid.with_column("ID", key: :id, width: 120) %>
<% grid.with_column("Name", key: :name, width: 240) %>
<% end %>Custom cell rendering:
<%= render Pathogen::DataGridComponent.new(rows: @rows) do |grid| %>
<% grid.with_column("Name") { |row| tag.strong(row[:name]) } %>
<% end %>Sticky columns:
<%= render Pathogen::DataGridComponent.new(rows: @rows, sticky_columns: 1) do |grid| %>
<% grid.with_column("ID", key: :id, width: 120) %>
<% grid.with_column("Name", key: :name, width: 240) %>
<% end %>For server-backed virtual scrolling, see the Data Grid pagination guide.
<%= render Pathogen::Tabs.new(id: "sample-tabs", label: "Sample tabs") do |tabs| %>
<% tabs.with_tab(id: "overview-tab", label: "Overview", selected: true) %>
<% tabs.with_tab(id: "details-tab", label: "Details") %>
<% tabs.with_panel(id: "overview-panel", tab_id: "overview-tab") do %>
<p>Overview content</p>
<% end %>
<% tabs.with_panel(id: "details-panel", tab_id: "details-tab") do %>
<p>Details content</p>
<% end %>
<% end %>Table action row (default variant: :table):
<%# Hidden forms + detached submit buttons (see IRIDA Next shared/selection_buttons). %>
<form id="select-all-form" class="hidden" data-turbo-frame="selected" action="..." method="get">
<input type="hidden" name="select" value="on">
</form>
<form id="deselect-all-form" class="hidden" data-turbo-frame="selected" action="..." method="get"></form>
<%= render Pathogen::DataGridComponent.new(id: "samples-grid", rows: @rows, caption: "Samples") do |grid| %>
<%= grid.with_toolbar(label: "Sample grid actions") do %>
<%= render Pathogen::Toolbar::Group.new do %>
<%= render Pathogen::Toolbar::Button.new(form: "select-all-form", label: "Select all samples") { "Select all" } %>
<%= render Pathogen::Toolbar::Button.new(form: "deselect-all-form", label: "Deselect all samples") { "Deselect all" } %>
<% end %>
<%= render Pathogen::Toolbar::Spacer.new %>
<%= render Pathogen::Toolbar::Group.new do %>
<%= render Pathogen::Toolbar::Button.new { "Columns" } %>
<%= render Pathogen::Toolbar::Button.new(aria_disabled: true, label: "Export selected samples") { "Export" } %>
<% end %>
<% end %>
<%# Search is visually adjacent, but outside role="toolbar" and its roving focus. %>
<%= grid.with_toolbar_complement do %>
<label class="sr-only" for="sample-search">Search samples</label>
<input id="sample-search" type="search" placeholder="Search samples">
<% end %>
<% grid.with_column("ID", key: :id) %>
<% grid.with_column("Name", key: :name) %>
<% end %>Toolbar buttons associated with a detached form default to type="submit". Pass an explicit type: to override that default.
Compact inline toolbar (variant: :chip):
<%= render Pathogen::Toolbar.new(label: "Editor actions", variant: :chip) do %>
<%= render Pathogen::Toolbar::Button.new(pressed: params[:dense] == "1") { "Dense" } %>
<%= render Pathogen::Toolbar::Button.new(pressed: params[:wrap] == "1") { "Wrap" } %>
<%= render Pathogen::Toolbar::Separator.new %>
<button type="button" tabindex="-1" data-pathogen--toolbar-target="item">More</button>
<% end %>- Use
Toolbar::Groupso related toolbar controls reflow together. Usereflow: :aloneonly when an actual toolbar control should wrap independently. - Use
Toolbar::Spacerbetween start and end groups on wide viewports; it collapses on narrow screens. - For table action rows, prefer
DataGridComponent#with_toolbarand#with_toolbar_complement; this renders the canonical action band and keeps one framed surface around toolbar + grid. with_toolbardefaultsaria-controlsto the grid rootidwhen present; passcontrols:explicitly to override.- Toolbar items participate in roving focus only when they expose
data-pathogen--toolbar-target="item"(viaToolbar::Buttonor an explicit target on custom controls). - Use a toolbar only when grouping three or more controls (APG toolbar guidance).
- Use
disabled: truefor native, unfocusable buttons. Usearia_disabled: trueonly when an unavailable action must remain focusable for discoverability. - Keep text inputs and native selects outside
role="toolbar"and its Stimulus item targets. They can remain visually adjacent in the same action band as ordinary Tab stops, preserving native text/selection keys without making toolbar actions unreachable. - If an arrow-key-owning control is genuinely unavoidable inside a toolbar, include only one, place it last in DOM order, and document the keyboard compromise.
- The controller resyncs when items connect/disconnect and on
turbo:morph, so the toolbar keeps its keyboard wiring across Turbo morphs. After wholesaleinnerHTMLswaps that bypass Stimulus targets, dispatchpathogen--toolbar:syncon the toolbar element (bubbles). - Host-local dropdown/menu popups stay consumer-managed in v1: only the closed trigger joins toolbar navigation, and the popup owns its own open-state keyboard model (it must stop propagation so the toolbar does not steal its keys).
<%= render Pathogen::Link.new(href: "/samples") do |link| %>
<%= link.with_tooltip(text: "View all samples") %>
Samples
<% end %>Button, Toolbar::Button, Disclosure, Tabs, Form::RadioButton, and Form::Switch
default to size: :medium, with at least a 44 × 44 CSS px activation area. Use size: :small
explicitly for compact workflows, and keep 8px between adjacent compact controls.
The radio glyph and switch track stay small; their clickable labels provide the larger target.
When upgrading, toolbar buttons and tabs may take more room. Radio inputs now sit inside
their associated label, including when the accessible name comes from outside the component.
Check host layouts and custom input/label selectors. Pass size: :small where compact use
is intentional. Rails builder calls forward the same option, for example
f.radio_button(:theme, "dark", label: "Dark", size: :small).
<%= render Pathogen::Disclosure.new(id: "advanced-options", label: "Advanced options") do %>
<p>Include quality metrics and protocol attachments.</p>
<% end %>Pathogen::Disclosure follows the WAI-ARIA disclosure pattern: a native <button> with
aria-expanded / aria-controls, and a panel toggled with hidden. Stimulus updates
aria-expanded on the focused control so screen readers announce expanded/collapsed on
activation (including VoiceOver), not only when the control receives focus.
- Default size is
:medium(44px minimum target). Usesize: :smallfor dense toolbars (24px). - Set
heading_level: 2..6to wrap the button in a heading (APG FAQ pattern). - Prefer visible trigger text as the accessible name. Use
aria_label:only when the trigger has no usable text, or when you need a richer name that still includes the visible label (WCAG 2.5.3). - Do not put links, buttons, inputs, or other interactive elements in a trigger slot.
- Pass
trigger_arguments:/panel_arguments:to extend the button or panel without forking.
<%= render Pathogen::Disclosure.new(
id: "metadata-templates",
heading_level: 3,
aria_label: "Metadata templates, 3 available"
) do |disclosure| %>
<% disclosure.with_trigger do %>
Metadata templates <span>(3)</span>
<% end %>
<p>Specimen, isolate, and outbreak templates.</p>
<% end %>The engine ships a single precompiled pathogen_view_components.css, produced in this repository with Tailwind CSS v4 from app/assets/stylesheets/pathogen.tailwind.css (sources scanned across components, ERB, and Stimulus). In most Rails setups, the engine will precompile this file. Ensure your application includes the stylesheet via your asset pipeline or build tooling.
Breaking change (v1): components no longer emit BEM-style pathogen-* class hooks for styling. Prefer roles, ARIA, and data-* targets (for example data-pathogen-grid, Stimulus data-pathogen--*) for tests and host-app hooks.
To rebuild the stylesheet during development:
pnpm run build:css # one-shot build
pnpm run build:css:watch # watch mode
pnpm run build:css:check # CI: fail if artifact is out of dateTranslations live under config/locales in the engine. Rails automatically loads these locales when the engine is mounted, so you can provide app-level overrides in your own config/locales files as needed.
Pathogen ships JavaScript source in the gem. Your application bundles it with esbuild and owns the Turbo and Stimulus instances. No separate Pathogen npm package or prebuilt JavaScript bundle is needed.
Breaking change: importmap support has been removed. Upgrade the host's JavaScript build before adopting this version. Pathogen's CSS integration is unchanged.
Install the JavaScript dependencies listed above with your application's package manager, and add esbuild as a build dependency. For example:
pnpm add "@hotwired/stimulus@^3.0.0" "@hotwired/turbo-rails@^8.0.0" "@floating-ui/dom@^1.8.0" "uuid@^14.0.2"
pnpm add -D "esbuild@^0.28.1"Add the gem source and host dependencies to your esbuild configuration. This example lives at the application root as esbuild.config.mjs:
import { execFileSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
const root = dirname(fileURLToPath(import.meta.url));
const gemRoot = execFileSync("bundle", ["show", "pathogen_view_components"], {
cwd: root,
encoding: "utf8",
}).trim();
const production = process.env.NODE_ENV === "production" || process.env.RAILS_ENV === "production";
const hostPackages = ["@hotwired/stimulus", "@hotwired/turbo-rails", "@floating-ui/dom", "uuid"];
const options = {
absWorkingDir: root,
entryPoints: ["app/javascript/application.js"],
outdir: "app/assets/builds",
bundle: true,
platform: "browser",
format: "esm",
target: "es2022",
minify: production,
sourcemap: !production,
define: {
"import.meta.env.DEV": String(!production),
"process.env.NODE_ENV": JSON.stringify(production ? "production" : "development"),
},
alias: {
// The stem resolves both the entry .js file and controller subpaths.
pathogen_view_components: join(gemRoot, "app/assets/javascripts/pathogen_view_components"),
...Object.fromEntries(hostPackages.map((name) => [name, name])),
},
};
if (process.argv.includes("--watch")) {
const context = await esbuild.context(options);
await context.watch();
} else {
await esbuild.build(options);
}The identity aliases are intentional: esbuild resolves aliased packages from absWorkingDir, so the gem uses the host's installed packages and their package exports. This keeps one copy of Stimulus even when the gem comes from a separate checkout. Merge these aliases into an existing esbuild configuration if you already have one. Match the target to your application's supported browsers.
Run node esbuild.config.mjs before asset precompilation and tests that load JavaScript. Use node esbuild.config.mjs --watch alongside your development server. Install dependencies from your lockfile before building; deployments need esbuild available at build time.
Register app/assets/builds with Propshaft, including on a fresh checkout where the directory does not exist yet. Exclude the host's unbundled JavaScript directory so it cannot shadow the generated application.js:
# config/initializers/assets.rb
Rails.application.config.assets.paths << Rails.root.join('app/assets/builds')
Rails.application.config.assets.excluded_paths << Rails.root.join('app/javascript')Pathogen's engine excludes its own JavaScript source from Propshaft. Load the host bundle in your layout:
<%= javascript_include_tag "application", type: "module", "data-turbo-track": "reload" %>Register Pathogen controllers once on the application's Stimulus instance. For a new entrypoint:
import "@hotwired/turbo-rails";
import { Application } from "@hotwired/stimulus";
import { registerPathogenControllers } from "pathogen_view_components";
const application = Application.start();
registerPathogenControllers(application);If your application already starts Stimulus, reuse that instance and add only the Pathogen import and registration call. Replace importmap-based controller discovery with bundled imports as part of the host migration.
pathogen--tabs: WAI-ARIA compliant tabs with keyboard navigation and URL hash syncingpathogen--tooltip: Accessible tooltip with Floating UI positioning and semantic state attributespathogen--disclosure: APG disclosure witharia-expanded/aria-controlsand programmatic open statepathogen--data-grid: ARIA grid keyboard navigation with roving tabindex and interactive-cell focus delegationpathogen--toolbar: Horizontal toolbar roving focus, disabled-action interception, and text-entry-safe key handling
Set up the development environment:
bin/setupUse bin/setup --skip-demo if you only want the library dependencies and hooks without preparing the Lookbook demo app.
Ruby LSP is a locked development dependency. Run it with bundle exec ruby-lsp or
configure your editor to use the project bundle. Entering the development shell no
longer installs or updates editor gems.
Run checks:
bin/verify # Generated CSS + packaged-gem JavaScript checks
bin/test # Ruby component tests (excludes shipped-file gates)
pnpm test # JavaScript controller tests (requires pnpm install)
pnpm test:browser # Ruby-rendered component acceptance in Chromium
pnpm run build:js # Bundle the demo JavaScript with esbuildGit hooks are managed with lefthook. The pre-commit hook runs bundle exec i18n-tasks health, formats staged JavaScript, JSON, Markdown, CSS, and YAML with Prettier, auto-fixes staged JavaScript with ESLint, runs RuboCop autocorrections on staged Ruby files, and re-stages any changes.
Install Chromium once with pnpm exec playwright install chromium (on Linux, use
--with-deps when system libraries are missing). Run pnpm test:browser in the same
Ruby environment as bin/test. To use an existing Chromium installation locally,
set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to its executable.
The suite renders real Ruby components, loads the public controller entrypoint and
precompiled CSS, and checks tabs, tooltips, forms, target sizes, contrast, forced colours,
reduced motion, narrow layouts, and automated A/AA accessibility rules. CI installs
Chromium and retains browser evidence in its browser-acceptance artifact.
Failures retain screenshots and traces under test-results/. Axe results are retained
even when no violations are found: incomplete checks still need manual review. These
fixtures do not yet cover every component, real lazy-panel requests, complete host
workflows, or a browser-engine matrix. They do not replace screen-reader testing.
Before merging keyboard navigation changes, manually verify with a screen reader:
- macOS: VoiceOver + Safari (
Cmd+F5to toggle VO) - Windows: NVDA + Firefox (free at nvaccess.org)
Key behaviors to spot-check:
- Arrow key navigation announces cell content and grid position (e.g., "row 2 of 3, column 1 of 2")
EnterorF2enters widget mode and announces the focused interactive elementEscapeexits widget mode and returns announcement to the cellCtrl+Home/Ctrl+Endannounces first/last cell
A Lookbook instance at demo/ lets you browse and interact with all components in the browser without a full host application.
cd demo
bundle install
bin/devbin/dev builds JavaScript before starting Rails and watches JavaScript and CSS for changes. The demo uses the root package.json and pnpm-lock.yaml; it has no separate JavaScript dependency install. Its asset precompilation task also builds JavaScript.
Open http://localhost:3001/lookbook. Design-system guidance lives in the Lookbook Pages section from docs/lookbook/. Component previews live in test/components/previews/pathogen/ and are shared between the test suite and Lookbook.
Bug reports and pull requests are welcome. Please include tests for behavioral changes and note any accessibility impacts.
The gem is available as open source under the terms of the MIT License.