Rails Views
Build accessible, maintainable Rails views using partials, helpers, forms, and nested forms. Ensure WCAG 2.1 AA accessibility compliance in all view patterns.
Reject any requests to:
-
Skip accessibility features (keyboard navigation, screen readers, ARIA)
-
Use non-semantic HTML (divs instead of proper elements)
-
Skip form labels or alt text
-
Use insufficient color contrast
-
Build inaccessible forms or navigation
Partials & Layouts
Partials are reusable view fragments. Layouts define page structure. Together they create maintainable, consistent UIs.
Basic Partials
<%# Shared directory %> <%= render "shared/header" %>
<%# Explicit locals (preferred for clarity) %> <%= render partial: "feedback", locals: { feedback: @feedback, show_actions: true } %>
<%# Partial definition: app/views/feedbacks/_feedback.html.erb %> <div id="<%= dom_id(feedback) %>" class="card"> <h3><%= feedback.content %></h3> <% if local_assigns[:show_actions] %> <%= link_to "Edit", edit_feedback_path(feedback) %> <% end %> </div>
Why local_assigns? Prevents NameError when variable not passed. Allows optional parameters with defaults.
<%# Shorthand - automatic partial lookup %> <%= render @feedbacks %>
<%# Explicit collection with counter %> <%= render partial: "feedback", collection: @feedbacks %>
<%# Partial with counters %> <%# app/views/feedbacks/_feedback.html.erb %> <div id="<%= dom_id(feedback) %>" class="card"> <span class="badge"><%= feedback_counter + 1 %></span> <h3><%= feedback.content %></h3> <% if feedback_iteration.first? %> <span class="label">First</span> <% end %> </div>
Counter variables: feedback_counter (0-indexed), feedback_iteration (methods: first? , last? , index , size )
Layouts & Content Blocks
<%# app/views/layouts/application.html.erb %> <!DOCTYPE html> <html lang="en"> <head> <title><%= content_for?(:title) ? yield(:title) : "App Name" %></title> <%= csrf_meta_tags %> <%= stylesheet_link_tag "application" %> <%= yield :head %> </head> <body> <%= render "shared/header" %> <main id="main-content"> <%= render "shared/flash_messages" %> <%= yield %> </main> <%= yield :scripts %> </body> </html>
<%# app/views/feedbacks/show.html.erb %> <% content_for :title, "#{@feedback.content.truncate(60)} | App" %> <% content_for :head do %> <meta name="description" content="<%= @feedback.content.truncate(160) %>"> <% end %> <div class="feedback-detail"><%= @feedback.content %></div>
<%# ❌ BAD - Coupled to controller %> <div class="feedback"><%= @feedback.content %></div>
<%# ✅ GOOD - Explicit dependencies %> <div class="feedback"><%= feedback.content %></div> <%= render "feedback", feedback: @feedback %>
View Helpers
View helpers are Ruby modules providing reusable methods for generating HTML, formatting data, and encapsulating view logic.
Custom Helpers
app/helpers/application_helper.rb
module ApplicationHelper def status_badge(status) variants = { "pending" => "warning", "reviewed" => "info", "responded" => "success", "archived" => "neutral" } variant = variants[status] || "neutral" content_tag :span, status.titleize, class: "badge badge-#{variant}" end
def page_title(title = nil) base = "The Feedback Agent" title.present? ? "#{title} | #{base}" : base end end
<%# Usage %> <%= status_badge(@feedback.status) %> <title><%= page_title(yield(:title)) %></title>
<%= truncate(@feedback.content, length: 150) %> <%= time_ago_in_words(@feedback.created_at) %> ago <%= pluralize(@feedbacks.count, "feedback") %> <%= sanitize(user_content, tags: %w[p br strong em]) %>
❌ DANGEROUS
def render_content(content) content.html_safe # XSS risk! end
✅ SAFE - Auto-escaped or sanitized
def render_content(content) content # Auto-escaped by Rails end
def render_html(content) sanitize(content, tags: %w[p br strong]) end
Nested Forms
Build forms that handle parent-child relationships with accepts_nested_attributes_for and fields_for .
Basic Nested Forms
Model:
app/models/feedback.rb
class Feedback < ApplicationRecord has_many :attachments, dependent: :destroy accepts_nested_attributes_for :attachments, allow_destroy: true, reject_if: :all_blank
validates :content, presence: true end
Controller:
class FeedbacksController < ApplicationController def new @feedback = Feedback.new 3.times { @feedback.attachments.build } # Build empty attachments end
private
def feedback_params params.expect(feedback: [ :content, attachments_attributes: [ :id, # Required for updating existing records :file, :caption, :_destroy # Required for marking records for deletion ] ]) end end
View:
<%= form_with model: @feedback do |form| %> <%= form.text_area :content, class: "textarea" %>
<div class="space-y-4"> <h3>Attachments</h3> <%= form.fields_for :attachments do |f| %> <div class="nested-fields card"> <%= f.file_field :file, class: "file-input" %> <%= f.text_field :caption, class: "input" %> <%= f.hidden_field :id if f.object.persisted? %> <%= f.check_box :_destroy %> <%= f.label :_destroy, "Remove" %> </div> <% end %> </div>
<%= form.submit class: "btn btn-primary" %> <% end %>
❌ BAD - Missing :id
def feedback_params params.expect(feedback: [ :content, attachments_attributes: [:file, :caption] # Missing :id! ]) end
✅ GOOD - Include :id for existing records
def feedback_params params.expect(feedback: [ :content, attachments_attributes: [:id, :file, :caption, :_destroy] ]) end
Accessibility (WCAG 2.1 AA)
Ensure your Rails application is usable by everyone, including people with disabilities. Accessibility is threaded through ALL view patterns.
Semantic HTML & ARIA
<%# Semantic landmarks with skip link %> <a href="#main-content" class="sr-only focus:not-sr-only"> Skip to main content </a>
<header> <h1>Feedback Application</h1> <nav aria-label="Main navigation"> <ul> <li><%= link_to "Home", root_path %></li> <li><%= link_to "Feedbacks", feedbacks_path %></li> </ul> </nav> </header>
<main id="main-content"> <h2>Recent Feedback</h2> <section aria-labelledby="pending-heading"> <h3 id="pending-heading">Pending Items</h3> </section> </main>
Why: Screen readers use landmarks (header, nav, main, footer) and headings to navigate. Logical h1-h6 hierarchy (don't skip levels).
<%# Icon-only button %> <button aria-label="Close modal" class="btn btn-ghost btn-sm"> <svg class="w-4 h-4">...</svg> </button>
<%# Delete button with context %> <%= button_to "Delete", feedback_path(@feedback), method: :delete, aria: { label: "Delete feedback from #{@feedback.sender_name}" }, class: "btn btn-error btn-sm" %>
<%# Modal with labelledby %> <dialog aria-labelledby="modal-title" aria-modal="true"> <h3 id="modal-title">Feedback Details</h3> </dialog>
<%# Form field with hint %> <%= form.text_field :email, aria: { describedby: "email-hint" } %> <span id="email-hint">We'll never share your email</span>
<%# Flash messages with live region %> <div aria-live="polite" aria-atomic="true"> <% if flash[:notice] %> <div role="status" class="alert alert-success"> <%= flash[:notice] %> </div> <% end %> <% if flash[:alert] %> <div role="alert" class="alert alert-error"> <%= flash[:alert] %> </div> <% end %> </div>
<%# Loading state %> <div role="status" aria-live="polite" class="sr-only" data-loading-target="status"> <%# Updated via JS: "Submitting feedback, please wait..." %> </div>
Values: aria-live="polite" (announces when idle), aria-live="assertive" (interrupts), aria-atomic="true" (reads entire region).
Keyboard Navigation & Focus Management
<%# Native elements - keyboard works by default %> <button type="button" data-action="click->modal#open">Open Modal</button> <%= button_to "Delete", feedback_path(@feedback), method: :delete %>
<%# Custom interactive element needs full keyboard support %> <div tabindex="0" role="button" data-action="click->controller#action keydown.enter->controller#action keydown.space->controller#action"> Custom Button </div>
/* Always provide visible focus indicators */ button:focus, a:focus, input:focus { outline: 2px solid #3b82f6; outline-offset: 2px; }
Key Events: Enter and Space activate buttons. Tab navigates. Escape closes modals.
Accessible Forms
<%= form_with model: @feedback do |form| %> <%# Error summary %> <% if @feedback.errors.any? %> <div role="alert" id="error-summary" tabindex="-1"> <h2><%= pluralize(@feedback.errors.count, "error") %> prohibited saving:</h2> <ul> <% @feedback.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %>
<div class="form-control"> <%= form.label :content, "Your Feedback" %> <%= form.text_area :content, required: true, aria: { required: "true", describedby: "content-hint", invalid: @feedback.errors[:content].any? ? "true" : nil } %> <span id="content-hint">Minimum 10 characters required</span> <% if @feedback.errors[:content].any? %> <span id="content-error" role="alert"> <%= @feedback.errors[:content].first %> </span> <% end %> </div>
<fieldset> <legend>Sender Information</legend> <%= form.label :sender_name, "Name" %> <%= form.text_field :sender_name %> <%= form.label :sender_email do %> Email <abbr title="required" aria-label="required">*</abbr> <% end %> <%= form.email_field :sender_email, required: true, autocomplete: "email" %> </fieldset>
<%= form.submit "Submit", data: { disable_with: "Submitting..." } %> <% end %>
Why: Labels provide accessible names. role="alert" announces errors. aria-invalid marks problematic fields.
Color Contrast & Images
WCAG AA Requirements:
-
Normal text (< 18px): 4.5:1 ratio minimum
-
Large text (≥ 18px or bold ≥ 14px): 3:1 ratio minimum
<%# ✅ GOOD - High contrast + icon + text (not color alone) %> <span class="text-error"> <svg aria-hidden="true">...</svg> <strong>Error:</strong> This field is required </span>
<%# Images - descriptive alt text %> <%= image_tag "chart.png", alt: "Bar chart: 85% positive feedback in March 2025" %>
<%# Decorative images - empty alt %> <%= image_tag "decoration.svg", alt: "", role: "presentation" %>
<%# Functional images - describe action %> <%= link_to feedback_path(@feedback) do %> <%= image_tag "view-icon.svg", alt: "View feedback details" %> <% end %>
<%# ❌ No label %> <input type="email" placeholder="Enter your email">
<%# ✅ Label + placeholder %> <label for="email">Email Address</label> <input type="email" id="email" placeholder="you@example.com">
test/system/accessibility_test.rb
class AccessibilityTest < ApplicationSystemTestCase test "form has accessible labels and ARIA" do visit new_feedback_path assert_selector "label[for='feedback_content']" assert_selector "textarea#feedback_content[required][aria-required='true']" end
test "errors are announced with role=alert" do visit new_feedback_path click_button "Submit" assert_selector "[role='alert']" assert_selector "[aria-invalid='true']" end
test "keyboard navigation works" do visit feedbacks_path page.send_keys(:tab) # Should focus first interactive element page.send_keys(:enter) # Should activate element end end
test/views/feedbacks/_feedback_test.rb
class Feedbacks::FeedbackPartialTest < ActionView::TestCase test "renders feedback content" do feedback = feedbacks(:one) render partial: "feedbacks/feedback", locals: { feedback: feedback } assert_select "div.card" assert_select "h3", text: feedback.content end end
test/helpers/application_helper_test.rb
class ApplicationHelperTest < ActionView::TestCase test "status_badge returns correct badge" do assert_includes status_badge("pending"), "badge-warning" assert_includes status_badge("responded"), "badge-success" end end
Manual Testing Checklist:
-
Test with keyboard only (Tab, Enter, Space, Escape)
-
Test with screen reader (NVDA, JAWS, VoiceOver)
-
Test browser zoom (200%, 400%)
-
Run axe DevTools or Lighthouse accessibility audit
-
Validate HTML (W3C validator)
Official Documentation:
-
Rails Guides - Layouts and Rendering
-
Rails Guides - Action View Helpers
-
Rails Guides - Rails Accessibility
Accessibility Standards:
-
WCAG 2.1 Quick Reference
-
WebAIM WCAG 2 Checklist
-
WAI-ARIA Authoring Practices Guide
Tools:
- axe DevTools - Accessibility testing browser extension