| name | rails-ai:views |
| description | Use when building Rails view structure - partials, helpers, forms, nested forms, accessibility (WCAG 2.1 AA) |
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:
Tools:
- axe DevTools - Accessibility testing browser extension