A rails gem to provide reusable view components used throughout the SDR applications and implement component library assets.
Add to gemfile:
gem 'sdr_view_components'
This set of components relies on the component library stylesheets, add:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/sul-dlss/component-library@v2026-09-28/styles/sul.css">
<%= stylesheet_link_tag "sdr_view_components", "data-turbo-track": "reload" %>
with the most recent date tagged release to your application.html.erb layout file.
Some components require JavaScript. The gem ships Stimulus controllers under app/javascript/sdr_view_components/ and registers that path with the asset pipeline automatically.
The controllers that use Bootstrap's JavaScript import it as an ES module (import * as bootstrap from "bootstrap"), so bootstrap must be pinned to an ES module build of Bootstrap. The UMD bootstrap.bundle.min.js has no ES module exports and won't work. For example, in config/importmap.rb:
pin "bootstrap", to: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/+esm"Here is an example of how to add a Stimulus controller:
The disappearing toast uses sdr_view_components/toast_controller to remove itself from the DOM after its fade-out animation completes.
Add to config/importmap.rb:
pin "sdr_view_components/toast_controller", to: "sdr_view_components/toast_controller.js"Register the controller in app/javascript/controllers/index.js:
import { application } from "controllers/application"
import ToastController from "sdr_view_components/toast_controller"
application.register("sdr-toast", ToastController)SdrViewComponents::TabForm::TabListComponent (see Tab form components below) similarly ships three controllers: sdr_view_components/tab_error_controller, sdr_view_components/tab_link_controller, and sdr_view_components/tab_nav_controller. Pin and register each the same way, using the sdr-tab-error, sdr-tab-link, and sdr-tab-nav identifiers respectively:
pin "sdr_view_components/tab_error_controller", to: "sdr_view_components/tab_error_controller.js"
pin "sdr_view_components/tab_link_controller", to: "sdr_view_components/tab_link_controller.js"
pin "sdr_view_components/tab_nav_controller", to: "sdr_view_components/tab_nav_controller.js"import { application } from "controllers/application"
import TabErrorController from "sdr_view_components/tab_error_controller"
import TabLinkController from "sdr_view_components/tab_link_controller"
import TabNavController from "sdr_view_components/tab_nav_controller"
application.register("sdr-tab-error", TabErrorController)
application.register("sdr-tab-link", TabLinkController)
application.register("sdr-tab-nav", TabNavController)SdrViewComponents::Elements::Tabs::TabListComponent's collapse_below option (see General usage below) ships sdr_view_components/tab_select_controller, registered as sdr-tab-select:
pin "sdr_view_components/tab_select_controller", to: "sdr_view_components/tab_select_controller.js"import { application } from "controllers/application"
import TabSelectController from "sdr_view_components/tab_select_controller"
application.register("sdr-tab-select", TabSelectController)Its overflow_menu option similarly ships sdr_view_components/tab_overflow_controller, registered as sdr-tab-overflow:
pin "sdr_view_components/tab_overflow_controller", to: "sdr_view_components/tab_overflow_controller.js"import { application } from "controllers/application"
import TabOverflowController from "sdr_view_components/tab_overflow_controller"
application.register("sdr-tab-overflow", TabOverflowController)SdrViewComponents provides a wrapper for several ActionView::Helper::Tags.
Currently supported tags:
- Checkbox
- File
- RadioButton
- TextArea
- TextField
SdrViewComponents provides fully composed (label, help text, and validation) instances of the above components as:
- SdrViewComponents::Forms::CheckboxComponent
- SdrViewComponents::Forms::FileComponent
- SdrViewComponents::Forms::RadioButtonComponent
- SdrViewComponents::Forms::TextAreaComponent
- SdrViewComponents::Forms::TextFieldComponent
At a minimum, each of these components must be provided wih the form: and field_name: parameters. Additionally, you can provide prefixed parameters that will be passed the the individual parts of the components:
container_: is used to pass arguments to the surroundingdivfor the component.input_: is used to pass arguments to the actual input component.label_: is used to pass arguments to the label for the component.help_: is used to pass arguments to the help text available for the component.error_: is used to pass arguments to the validation output of the component.
An input_aria: hash is merged with the arias the component sets itself (aria-invalid, aria-required), and its describedby is added to the component's error and help text ids rather than replacing them. The component's own arias take precedence, so input_aria: can add attributes but can't override the field's error or required state.
Use SdrViewComponents::Forms::RadioButtonGroupComponent for a set of radio buttons that share a field. It renders the radio buttons in a FieldsetComponent and renders the field's validation error once for the group, rather than under each radio button. Each radio button gets aria-invalid and an aria-describedby pointing at that error. Arguments other than form: and field_name: (label:, tooltip:, mark_required:, help_text:, etc.) are passed to the FieldsetComponent; arguments to with_radio_button are passed to RadioButtonComponent.
A block given to with_radio_button is rendered as that radio button's additional container content, for example a field that applies only when that option is chosen.
<%= render SdrViewComponents::Forms::RadioButtonGroupComponent.new(form:, field_name: :delivery_method, label: 'Delivery method') do |group| %>
<% group.with_radio_button(input_value: 'email', label_text: 'Email') do %>
<%= render SdrViewComponents::Forms::TextFieldComponent.new(form:, field_name: :email_address, label_text: 'Email address') %>
<% end %>
<% group.with_radio_button(input_value: 'mail', label_text: 'Mail') %>
<% end %>For layouts the group component doesn't support (for example, showing the error next to one particular radio button), pass show_errors: false to each composed component to skip its validation error, and render the error once yourself with SdrViewComponents::Forms::InvalidFeedbackComponent.new(form:, field_name:). The inputs still point their aria-describedby at the error's id, so make sure it is rendered.
TODO:
- Outline the supported params for each prefix
- Provide an example instantiation and output
Each of the supported components above uses the provided "basic" components:
- SdrViewComponents::Forms::BasicCheckboxComponent
- SdrViewComponents::Forms::BasicFileComponent
- SdrViewComponents::Forms::BasicRadioButtonComponent
- SdrViewComponents::Forms::BasicTextAreaComponent
- SdrViewComponents::Forms::BasicTextFieldComponent
At a minimum, each of these components must be provided wih the form: and field_name: parameters. Additionally, you can provide additional parameters that will be passed to the standard ActionView::Helpers::Tag.
SdrViewComponents::Forms::FieldsetComponent renders a <fieldset> for grouping related fields (e.g. a set of checkboxes) that don't belong to a single bound form field. It supports a label: (or a legend slot for custom markup), tooltip:, mark_required:, help_text:, and a help_link slot. Any other keyword arguments (id:, data:, aria:, etc.) are passed through to the <fieldset> tag.
SdrViewComponents provides components for rendering a tabbed form, where a form's fields are split across tabs rather than shown all at once:
SdrViewComponents::TabForm::TabListComponent-- renders the tab navigation and tab panes. It doesn't render a<form>tag itself.SdrViewComponents::TabForm::TabComponent-- a single tab, rendered viaTabListComponent#with_tab.SdrViewComponents::TabForm::HiddenFieldsFormComponent-- renders the one real<form>tag backing the tabbed form. It only contains hidden fields; the visible fields (rendered insideTabListComponent's panes) associate with this form via the HTMLformattribute rather than DOM nesting, since panes aren't necessarily inside the<form>.SdrViewComponents::TabForm::TabbedFormBuilder-- aFormBuilderthat automatically sets theformattribute (pointing at theHiddenFieldsFormComponent's form id) on every field it builds.SdrViewComponents::TabForm::PaneComponent-- generic tab-pane chrome (optional header/help/footer, Bootstraptab-paneclasses, and the target wiring thesdr-tab-errorcontroller needs). Wrap it in an app-specific component if you need pane-level buttons or layout beyond what it provides.
Panes passed to TabListComponent#with_pane can be any renderable object (a ViewComponent, a block, etc.) -- the caller supplies its own pane component, whether that's PaneComponent directly or a wrapper around it. If the pane responds to active_tab_name=, TabListComponent sets it centrally, so panes don't need active_tab_name passed to them individually.
TabListComponent requires JavaScript to mark tabs containing invalid fields -- see the sdr-tab-error controller under JavaScript above.
A minimal example:
<%= render SdrViewComponents::TabForm::HiddenFieldsFormComponent.new(model: @work_form, id: 'tabbed_form', hidden_fields: %i[lock version]) %>
<%= render SdrViewComponents::TabForm::TabListComponent.new(id: 'tabbed_form', active_tab_name: :details) do |tab_list| %>
<% tab_list.with_tab(label: 'Details', tab_name: :details, mark_required: true) %>
<% tab_list.with_tab(label: 'Notes', tab_name: :notes) %>
<% form_with(model: @work_form, html: { id: 'tabbed_form' }, builder: SdrViewComponents::TabForm::TabbedFormBuilder) do |form| %>
<% tab_list.with_pane(SdrViewComponents::TabForm::PaneComponent.new(tab_name: :details, label: 'Details')) do %>
<%= render SdrViewComponents::Forms::TextFieldComponent.new(form:, field_name: :title) %>
<% end %>
<% tab_list.with_pane(SdrViewComponents::TabForm::PaneComponent.new(tab_name: :notes, label: 'Notes')) do %>
<%= render SdrViewComponents::Forms::TextAreaComponent.new(form:, field_name: :notes) %>
<% end %>
<% end %>
<% end %>See the Lookbook preview for SdrViewComponents::TabForm::TabListComponent for a runnable example.
SdrViewComponents::Elements::Tabs::TabListComponent accepts a collapse_below: option (one of :sm, :md, :lg, :xl, :xxl). When given, the tabs are replaced with a <select> below that Bootstrap breakpoint instead of wrapping or scrolling -- the <select> drives the same Bootstrap tab JavaScript as the tabs, so panes switch the same way regardless of which control is visible. Leave it unset (the default) to always render the tabs. This requires the sdr-tab-select controller -- see JavaScript above.
<%= render SdrViewComponents::Elements::Tabs::TabListComponent.new(variant: :underline, collapse_below: :xl) do |tab_list| %>
<% tab_list.with_tab(label: 'Details', id: 'details-tab', pane_id: 'details-pane', active: true) %>
<% tab_list.with_tab(label: 'History', id: 'history-tab', pane_id: 'history-pane') %>
<% tab_list.with_pane(id: 'details-pane', tab_id: 'details-tab', active: true) do %>
<p>Details pane content.</p>
<% end %>
<% tab_list.with_pane(id: 'history-pane', tab_id: 'history-tab') do %>
<p>History pane content.</p>
<% end %>
<% end %>TabListComponent also accepts overflow_menu: true. When given, tabs that don't fit on a single row are moved into a trailing "More" dropdown tab instead of wrapping, and moved back out as space allows. It can be combined with collapse_below:. This requires the sdr-tab-overflow controller -- see JavaScript above.
<% render SdrViewComponent::....>
SdrViewComponents::Elements::CollapsibleSectionComponent renders a heading that shows / hides the section content using Bootstrap's collapse. The id: is used for the collapsed content and must be unique on the page. It is expanded unless expanded: false is given.
<%= render SdrViewComponents::Elements::CollapsibleSectionComponent.new(id: 'pages', heading: 'Pages (12)', level: :h3,
heading_classes: 'text-uppercase') do %>
<p>Section content.</p>
<% end %>Instead of heading:, the heading can be provided with the heading_content slot:
<%= render SdrViewComponents::Elements::CollapsibleSectionComponent.new(id: 'pages') do |component| %>
<% component.with_heading_content do %>
Pages <span class="badge text-bg-secondary">12</span>
<% end %>
<p>Section content.</p>
<% end %>SdrViewComponents provides components for building navigation bar items and dropdown menus:
SdrViewComponents::Elements::Navigation::NavItemComponent-- a single navigation link (or plain text, if nopath:is given).SdrViewComponents::Elements::Navigation::DropdownMenuComponent-- a Bootstrap dropdown menu. Items are added viawith_item, and can be any renderable object -- typically one of the components below.SdrViewComponents::Elements::Navigation::DropdownLinkComponent-- a link within a dropdown menu (renders with thedropdown-itemclass vialink_to). Acceptslabel:,link:,classes:,disabled:(adds thedisabledclass), and any additional options passed on tolink_to.SdrViewComponents::Elements::Navigation::DropdownHeaderComponent-- a non-interactive section label within a dropdown menu.SdrViewComponents::Elements::Navigation::DropdownDividerComponent-- a horizontal divider between groups of items within a dropdown menu.
<ul class="navbar-nav">
<%= render SdrViewComponents::Elements::Navigation::NavItemComponent.new(text: 'Home', path: '/') %>
<%= render SdrViewComponents::Elements::Navigation::DropdownMenuComponent.new(text: 'Options') do |dropdown| %>
<% dropdown.with_item do %>
<%= render SdrViewComponents::Elements::Navigation::DropdownHeaderComponent.new(text: 'Section 1') %>
<% end %>
<% dropdown.with_item do %>
<%= render SdrViewComponents::Elements::Navigation::DropdownLinkComponent.new(label: 'Action', link: '#') %>
<% end %>
<% dropdown.with_item do %>
<%= render SdrViewComponents::Elements::Navigation::DropdownDividerComponent.new %>
<% end %>
<% dropdown.with_item do %>
<%= render SdrViewComponents::Elements::Navigation::DropdownLinkComponent.new(label: 'Another action', link: '#') %>
<% end %>
<% end %>
</ul>The component-library version is set in lib/sdr_view_components/configuration.rb.
def initialize
# Default URL for the component library assets
@component_library_url = 'https://cdn.jsdelivr.net/gh/sul-dlss/component-library@v2026-09-28'
end
Lookbook provides a component browser for the components.
For a component to appear in Lookbook, it must have a preview. See spec/components/previews/sdr_view_components
Previews can easily be created with the viewComponentPreview prompt. For example: /viewComponentPreview SpinnerComponent.
bin/rails s
Lookbook will then be available at: http://localhost:3000/lookbook
When performing development in an app that is using SdrViewComponents, it may be helpful to be running Lookbook in that app (instead of having to run a separate local instance of it).
To run Lookbook in that app:
- Add Lookbook to
Gemfile.rb:
group :development do
gem 'lookbook'
end
- Add routes to
routes.rb:
if Rails.env.development?
mount SdrViewComponents::Engine => '/sdr_view_components'
mount Lookbook::Engine, at: '/lookbook'
end
- Add
config/initializers/sdr_view_components.rb:
SdrViewComponents.configure do |config|
config.component_library_url = Settings.component_library.url
end
It is recommended to change the component library URL to a configuration in the app instead of hardcoding in layouts.
When your app is running locally, Lookbook will be available at: http://localhost:3000/lookbook