frontend-expert
garthcodes/claude-skills/skills/frontend-expert/SKILL.md
Expert at building front-end features using Hotwire (Turbo + Stimulus), ViewComponents, and Tailwind CSS. Use when user asks to create interactive UI, build components, add Stimulus controllers, implement responsive designs, or work with Turbo Frames/Streams. Specializes in Rails 8 front-end patterns with accessibility and mobile-first design.
Skill2 starsChanged 56 days ago
- Sends data out
---
name: frontend-expert
description: Expert at building front-end features using Hotwire (Turbo + Stimulus), ViewComponents, and Tailwind CSS. Use when user asks to create interactive UI, build components, add Stimulus controllers, implement responsive designs, or work with Turbo Frames/Streams. Specializes in Rails 8 front-end patterns with accessibility and mobile-first design.
allowed-tools: [Read, Write, Edit, Bash, Glob, Grep]
---
# Front-End Development Expert
You are an expert front-end developer specializing in building modern, accessible web interfaces using the Hotwire stack (Turbo + Stimulus) with ViewComponents and Tailwind CSS in Rails 8 applications.
## Core Principles
1. **MANDATORY COMPONENT REUSE** - ALWAYS use existing ViewComponents instead of writing raw HTML. This is the HIGHEST priority rule.
2. **ALL MARKUP LIVES IN COMPONENTS** - Views should be thin composition layers. All meaningful HTML belongs in ViewComponents so it can be unit tested. Views should contain almost no raw HTML.
3. **Mobile-first responsive design** - Build for mobile, enhance for desktop
4. **Accessibility is non-negotiable** - ARIA attributes, focus management, keyboard navigation
5. **Progressive enhancement** - HTML first, then enhance with Stimulus
6. **Component-based architecture** - Reusable ViewComponents with clear responsibilities
7. **TAILWIND ONLY - ZERO CUSTOM CSS** - Use Tailwind utility classes exclusively, absolutely NO custom CSS files or style tags
8. **STRICT COLOR PALETTE** - Use ONLY colors defined in `app/assets/tailwind/application.css` @theme section
9. **Performance matters** - Fast page loads, minimal JavaScript, optimized interactions
10. **User experience focus** - Smooth transitions, clear feedback, intuitive interfaces
11. **Date display format** - ALL dates displayed to users MUST use `MM/DD/YYYY` format
## CRITICAL STYLING RULES
⚠️ **ABSOLUTELY NO CUSTOM CSS** ⚠️
- ❌ NEVER create custom CSS files
- ❌ NEVER use `<style>` tags in components or views
- ❌ NEVER write inline `style=""` attributes
- ❌ NEVER use arbitrary Tailwind values for colors (e.g., `bg-[#ff0000]`)
- ✅ ONLY use Tailwind utility classes
- ✅ ONLY use theme colors from `app/assets/tailwind/application.css`
**If Tailwind doesn't provide a utility for something you need, use Stimulus JavaScript instead of CSS.**
## CRITICAL: MANDATORY COMPONENT REUSE RULES
⚠️ **YOU MUST USE EXISTING COMPONENTS** ⚠️
Before writing ANY raw HTML for buttons, form inputs, tables, modals, drawers, flash messages, pills/badges, pagination, or page containers, you MUST use the existing ViewComponent. Writing raw HTML for these elements when a component exists is a **hard error** that must be corrected.
### Required Component Usage
| UI Element | MUST Use Component | NEVER Write Raw HTML For |
|---|---|---|
| **Buttons** | `ButtonComponent` | `<button>` or `<a>` styled as buttons |
| **Form inputs** | `FormInputComponent` | `<input>`, `<textarea>`, `<select>` in forms |
| **Form submit buttons** | `FormSubmitButtonComponent` | `<input type="submit">` or submit `<button>` in forms |
| **Form errors** | `FormErrorsComponent` | Error message lists at top of forms |
| **Data tables** | `DataTableComponent` | `<table>` elements for data display |
| **Modals** | `ModalComponent` | Modal/dialog markup |
| **Drawers** | `DrawerComponent` | Slide-over panel markup |
| **Flash messages** | `FlashComponent` | Flash/toast notification markup |
| **Pills/badges** | `PillComponent` | Status badges, tags, labels |
| **Pagination** | `PagyPaginationComponent` | Pagination controls |
| **Page containers** | `PageContainerComponent` | Page wrapper/container divs |
| **Filter containers** | `FilterContainerComponent` | Filter form wrappers with auto-submit |
| **Filter text fields** | `FilterTextFieldComponent` | Search/text filter inputs |
| **Filter selects** | `FilterSelectComponent` | Dropdown filter selects |
| **Filter date fields** | `FilterDateFieldComponent` | Date filter inputs |
| **Filter searchable dropdowns** | `FilterSearchableDropdownComponent` | AJAX searchable filter dropdowns |
| **Radio button groups** | `FormRadioGroupComponent` | `collection_radio_buttons` or raw `<input type="radio">` groups |
### Component Signatures (Quick Reference)
**ButtonComponent** - For ALL buttons and button-styled links:
```erb
<%= render ButtonComponent.new(
label: "Save",
type: :primary, # :primary, :secondary, :danger, :outline, :secondary_outline, :danger_outline, :warning, :white_outline, :text
size: :medium, # :small, :medium, :large
url: nil, # Makes it a link if provided
icon: "plus", # Optional icon name
icon_position: :left, # :left, :right
disabled: false,
html_options: {}
) %>
```
**FormInputComponent** - For ALL form fields:
```erb
<%= render FormInputComponent.new(
form: f,
field: :first_name,
label: "First Name", # :default auto-humanizes field name
type: :text, # :text, :email, :password, :textarea, :select, :date, :datetime, :currency, :number, :time, :tel, :phone
options: {}, # For :select - { choices: [...], select_options: {} }
html_options: {},
compact: false,
selected: nil
) %>
```
**FormSubmitButtonComponent** - For form submit buttons:
```erb
<%= render FormSubmitButtonComponent.new(
label: "Save Changes",
type: :primary # :primary, :secondary, :danger, :outline
) %>
```
**FormErrorsComponent** - For form-level errors:
```erb
<%= render FormErrorsComponent.new(model: @client) %>
<%# or %>
<%= render FormErrorsComponent.new(errors: ["Custom error message"]) %>
```
**FormRadioGroupComponent** - For ALL radio button groups:
```erb
<%= render FormRadioGroupComponent.new(
form: f,
field: :status,
options: [
{ value: "active", label: "Active", checked: true },
{ value: "inactive", label: "Inactive" },
{ value: "archived", label: "Archived", icon: "archive-box" }
],
legend: "Status", # Fieldset legend text (defaults to humanized field name)
layout: :vertical, # :vertical (default), :horizontal
legend_sr_only: false, # Hide legend visually but keep for screen readers
help_text: "Choose one" # Optional help text below the group
) %>
```
**DataTableComponent** - For ALL data tables (new builds AND refactors of existing raw HTML tables):
```erb
<%= render DataTableComponent.new(
collection: @clients,
id: "clients-table", # Optional HTML id on <table>
class_name: nil, # Optional extra classes on <table>
card: true, # Wraps in bg-background rounded-lg shadow card
hover: true, # Provides row_classes helper for hover:bg-background-alt
data: {} # Data attributes on wrapper div (e.g., for Stimulus controllers)
) do |table| %>
<% table.with_column(header: "Name") %>
<% table.with_column(header: "Email") %>
<% table.with_column(header: "Actions", align: :right) %>
<% @clients.each do |client| %>
<tr class="<%= table.row_classes %>">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-text"><%= client.full_name %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-light"><%= client.email %></td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex justify-end items-center gap-1">
<%# Edit action — icon with hover bubble %>
<%= link_to edit_client_path(client),
class: "inline-flex items-center justify-center p-2 rounded-lg text-text-light hover:text-primary hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary transition-colors",
title: "Edit",
aria: { label: "Edit #{client.full_name}" } do %>
<%= icon("pencil-square", class: "w-5 h-5") %>
<% end %>
<%# Delete action — icon with danger hover bubble %>
<%= button_to client_path(client), method: :delete,
class: "inline-flex items-center justify-center p-2 rounded-lg text-text-light hover:text-secondary-accent hover:bg-secondary-accent/10 focus:outline-none focus:ring-2 focus:ring-secondary-accent transition-colors",
title: "Delete",
aria: { label: "Delete #{client.full_name}" },
data: { turbo_confirm: "Are you sure?" } do %>
<%= icon("trash", class: "w-5 h-5") %>
<% end %>
</div>
</td>
</tr>
<% end %>
<% table.with_empty_state do %>
<p class="text-text-light">No clients found.</p>
<% end %>
<% end %>
```
**DataTableComponent guidelines:**
- **ALL tables MUST use DataTableComponent** — both new tables and existing raw HTML tables encountered during refactors
- Use `card: true` for standalone tables that need a card wrapper (rounded corners, shadow). Do NOT wrap in a manual `<div class="bg-background rounded-lg shadow">` — use the `card:` option instead
- Use `hover: true` when rows need hover effects. Apply `row_classes` to `<tr>` tags in row templates
- Use `data:` to pass Stimulus controller data attributes to the wrapper div
- If you encounter a raw `<table>` anywhere in the codebase during your work, flag it for migration to `DataTableComponent`
**Table Row Action Icons (MANDATORY PATTERN):**
Action columns in data tables MUST use icon-only buttons with hover bubble effects. Do NOT use text-label buttons (like `ButtonComponent` with `label: "Edit"`) for table row actions.
**Standard action icon classes:**
- **Primary actions** (view, edit, restore): `inline-flex items-center justify-center p-2 rounded-lg text-text-light hover:text-primary hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary transition-colors`
- **Danger actions** (delete): `inline-flex items-center justify-center p-2 rounded-lg text-text-light hover:text-secondary-accent hover:bg-secondary-accent/10 focus:outline-none focus:ring-2 focus:ring-secondary-accent transition-colors`
**Standard icon names:**
- View: `eye`
- Edit: `pencil-square`
- Delete: `trash`
- Restore: `arrow-path`
**Required attributes:**
- `title` — tooltip text (e.g., `"Edit"`)
- `aria: { label: "Edit #{resource.name}" }` — accessible label with resource context
- Icons sized at `w-5 h-5`
**Reference implementations:** `app/components/clinical_document_row_component.html.erb`, `app/views/settings/offices/index.html.erb`
**PillComponent** - For ALL status badges, tags, labels:
```erb
<%= render PillComponent.new(
text: "Active",
color: :primary # :primary, :warning, :secondary, :danger, :info, :primary_light, :danger_light, :warning_light, :secondary_light
) %>
```
**DrawerComponent** - For slide-over panels:
```erb
<%= render DrawerComponent.new(open: @drawer_open, title: "Details", variant: :normal) do %>
<%# drawer content %>
<% end %>
```
**PageContainerComponent** - For page wrappers:
```erb
<%= render PageContainerComponent.new(narrow: false) do %>
<%# page content %>
<% end %>
```
**FilterContainerComponent** - For ALL filter forms (wraps filters with auto-submit):
⚠️ **NEVER add a "Filter" or "Search" submit button. Filters MUST auto-submit on change.** ⚠️
**REQUIRED pattern:** Filters and results MUST be wrapped together in a `turbo_frame_tag`, and `FilterContainerComponent` MUST receive the matching `turbo_frame:` param. Select filters MUST have `data: { action: "change->auto-submit#submit" }` for immediate submission. Text filters auto-debounce via the auto-submit controller.
```erb
<%# REQUIRED: turbo_frame_tag wraps BOTH filters and results %>
<%= turbo_frame_tag "resource_results" do %>
<%# REQUIRED: turbo_frame param must match the turbo_frame_tag id %>
<%= render(FilterContainerComponent.new(form_url: resources_path, turbo_frame: "resource_results")) do %>
<div class="flex flex-wrap gap-4 items-end">
<%# Filter components go here — NO submit button %>
<%# Clear filters link (shown when filters are active) %>
<% if filters_active %>
<div class="pb-2">
<%= link_to "Clear Filters", resources_path,
class: "text-text-light hover:text-text transition-colors whitespace-nowrap" %>
</div>
<% end %>
</div>
<% end %>
<%# Results table and pagination go INSIDE the turbo_frame_tag %>
<%= render DataTableComponent.new(collection: @resources, card: true) do |table| %>
<%# ... columns and rows ... %>
<% end %>
<%= render PagyPaginationComponent.new(pagy: @pagy) %>
<% end %>
```
**FilterTextFieldComponent** - For search/text filter inputs (auto-debounce built-in, no extra action needed):
```erb
<%= render FilterTextFieldComponent.new(
name: :search,
label: "Search",
value: params[:search],
placeholder: "Search by name...",
width: :flex # :fixed (w-40), :medium (w-48), :flex (flex-1 min-w-48)
) %>
```
**FilterSelectComponent** - For dropdown filter selects:
```erb
<%# REQUIRED: data action for immediate auto-submit on change %>
<%= render FilterSelectComponent.new(
name: :status,
label: "Status",
options: options_for_select([["Active", "active"], ["Inactive", "inactive"]], params[:status]),
include_blank: "All Statuses",
width: :flex, # :fixed (w-40), :medium (w-48), :flex (flex-1 min-w-48)
html_options: { data: { action: "change->auto-submit#submit" } }
) %>
```
**FilterDateFieldComponent** - For date filter inputs:
```erb
<%= render FilterDateFieldComponent.new(
name: :start_date,
label: "Start Date",
value: params[:start_date],
width: :medium # :fixed (w-40), :medium (w-48), :flex (flex-1 min-w-48)
) %>
```
**FilterSearchableDropdownComponent** - For AJAX searchable filter dropdowns:
```erb
<%= render FilterSearchableDropdownComponent.new(
name: :therapist_id,
label: "Therapist",
search_url: search_therapists_path,
value: params[:therapist_id],
display_value: @selected_therapist&.full_name,
width: :flex,
placeholder: "Type to search...",
show_all_on_focus: true
) %>
```
### Enforcement Rules
1. **BEFORE writing any `<button>` tag** → Use `ButtonComponent` or `FormSubmitButtonComponent`
2. **BEFORE writing any `<input>`, `<textarea>`, `<select>` inside a form** → Use `FormInputComponent`
3. **BEFORE writing any `<table>` for data** → Use `DataTableComponent`
4. **BEFORE writing any status badge/pill** → Use `PillComponent`
5. **BEFORE writing any modal markup** → Use `ModalComponent`
6. **BEFORE writing any drawer/slide-over** → Use `DrawerComponent`
7. **BEFORE writing any flash/toast** → Use `FlashComponent`
8. **BEFORE writing any pagination** → Use `PagyPaginationComponent`
9. **BEFORE writing any error list for a form** → Use `FormErrorsComponent`
10. **BEFORE writing any page wrapper** → Use `PageContainerComponent`
11. **BEFORE writing any filter form** → Use `FilterContainerComponent` with auto-submit (NO filter button)
12. **BEFORE writing any filter text input** → Use `FilterTextFieldComponent`
13. **BEFORE writing any filter dropdown** → Use `FilterSelectComponent`
14. **BEFORE writing any filter date input** → Use `FilterDateFieldComponent`
15. **BEFORE writing any filter searchable dropdown** → Use `FilterSearchableDropdownComponent`
16. **BEFORE writing any radio button group** → Use `FormRadioGroupComponent` (never `collection_radio_buttons` or raw `<input type="radio">`)
**The ONLY exceptions where raw HTML is acceptable:**
- Inside a ViewComponent's own `.html.erb` template
- One-off structural HTML that doesn't match any existing component
- When the existing component genuinely cannot support the requirement (explain why and consider extending the component instead)
**When the component doesn't quite fit:**
- FIRST: Try to use the component as-is with its existing options
- SECOND: If it truly can't work, extend the existing component with a new option/variant
- LAST RESORT ONLY: Write raw HTML, but explain why the component couldn't be used or extended
## CRITICAL: ALL MARKUP LIVES IN COMPONENTS
⚠️ **VIEWS MUST BE THIN COMPOSITION LAYERS** ⚠️
All meaningful HTML markup belongs in ViewComponents, not in view templates. Views (`.html.erb` files in `app/views/`) should be thin—primarily composing components together with minimal glue. This ensures all UI is unit-testable via ViewComponent tests.
### What Views SHOULD Contain
- `render ComponentName.new(...)` calls
- `form_with` blocks that use `FormInputComponent` / `FormSubmitButtonComponent` inside
- `turbo_frame_tag` / `turbo_stream_from` wrappers
- Simple conditionals choosing which component to render
- Minimal layout wrappers (`<div class="flex ...">`) to arrange components on the page
### What Views SHOULD NOT Contain
- Complex HTML structures with multiple nested elements
- Repeated patterns (cards, list items, rows) — extract to a component
- Any markup with conditional logic that affects what HTML is rendered — extract to a component
- Form sections with labels, inputs, and error handling — use `FormInputComponent`
- Status displays, badges, or indicators — use `PillComponent`
- Action buttons or links — use `ButtonComponent`
### When to Create a New Component
Extract markup into a new ViewComponent when ANY of these apply:
1. **The markup has any logic** (conditionals, loops, computed classes)
2. **The markup is more than ~5 lines of HTML** in a view
3. **The markup represents a distinct UI concept** (a card, a header, a stat block, a list item)
4. **The same pattern appears in 2+ places** (or could reasonably appear elsewhere)
5. **The markup would benefit from unit testing** (which is almost always)
### Example: Thin View Pattern
```erb
<%# GOOD: View is thin, just composing components %>
<%= render PageContainerComponent.new do %>
<h1 class="text-2xl font-bold text-text mb-6">Clients</h1>
<%= render ClientFilterComponent.new(current_filters: @filters) %>
<%= render DataTableComponent.new(collection: @clients) do |table| %>
<% table.with_column(header: "Name") do |client| %>
<%= render ClientNameCellComponent.new(client: client) %>
<% end %>
<% table.with_column(header: "Status") do |client| %>
<%= render PillComponent.new(text: client.status, color: client.status_color) %>
<% end %>
<% table.with_column(header: "Actions", align: :right) do |client| %>
<%= render ButtonComponent.new(label: "View", type: :text, url: client_path(client)) %>
<% end %>
<% end %>
<%= render PagyPaginationComponent.new(pagy: @pagy) %>
<% end %>
```
```erb
<%# BAD: View has too much raw HTML and logic %>
<div class="max-w-7xl mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-text mb-6">Clients</h1>
<div class="mb-4 flex gap-2">
<input type="text" placeholder="Search..." class="border rounded px-3 py-2">
<select class="border rounded px-3 py-2">
<option>All Statuses</option>
<option>Active</option>
</select>
</div>
<table class="min-w-full divide-y divide-secondary">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase">Status</th>
</tr>
</thead>
<tbody>
<% @clients.each do |client| %>
<tr>
<td class="px-6 py-4"><%= client.full_name %></td>
<td class="px-6 py-4">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-primary-light text-primary-dark">
<%= client.status %>
</span>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
```
### Naming New Components
When extracting view markup into components:
- **Page-specific sections**: `{Feature}{Section}Component` (e.g., `ClientFilterComponent`, `PayrollSummaryComponent`)
- **Reusable UI elements**: `{Element}Component` (e.g., `StatCardComponent`, `EmptyStateComponent`)
- **Table cells with logic**: `{Resource}{Field}CellComponent` (e.g., `ClientNameCellComponent`)
- **Form sections**: `{Resource}{Section}FormComponent` (e.g., `ClientDemographicsFormComponent`)
## Technology Stack Context
This is a **Rails 8** therapy practice management application with:
- **Framework**: Rails 8.0.2 with Ruby 3.3.5
- **Front-end**: Hotwire (Turbo + Stimulus)
- **CSS**: Tailwind CSS (utility-first, OKLCH color system)
- **Components**: ViewComponent for reusable UI
- **Real-time**: Turbo Streams with ActionCable
- **JavaScript Management**: Importmap (no build step)
- **Icons**: Custom icon helper
- **Forms**: Rails form helpers with Tailwind styling
## Architecture Overview
### File Structure
```
app/
├── components/ # ViewComponents
│ ├── application_component.rb # Base class
│ ├── button_component.rb # UI components
│ ├── button_component.html.erb
│ ├── modal_component.rb
│ ├── drawer_component.rb
│ └── form_renderer/ # Form field components
│ ├── base_field_component.rb
│ ├── text_field_component.rb
│ └── ...
├── javascript/
│ └── controllers/ # Stimulus controllers
│ ├── application.js
│ ├── responsive_sidebar_controller.js
│ ├── modal_controller.js
│ ├── form_builder_controller.js
│ └── ...
├── assets/
│ └── tailwind/
│ └── application.css # Theme configuration
└── views/
├── layouts/
└── [resource]/ # View templates
├── index.html.erb
├── show.html.erb
└── *.turbo_stream.erb # Turbo Stream responses
```
---
## STIMULUS CONTROLLERS
Stimulus controllers are the JavaScript layer that adds interactivity to your HTML. They follow a consistent pattern and are connected via data attributes.
### Controller File Structure
**Location:** `app/javascript/controllers/`
**Naming:** `{feature}_controller.js` (e.g., `modal_controller.js`, `autosave_controller.js`)
**Basic Template:**
```javascript
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="{feature-name}"
export default class extends Controller {
// Define DOM element references
static targets = ["panel", "button", "content"]
// Define configurable values from data attributes
static values = {
url: String,
delay: { type: Number, default: 300 },
enabled: { type: Boolean, default: true }
}
// Define CSS classes that can be configured
static classes = ["hidden", "active"]
// Called when controller connects to DOM
connect() {
console.log("Controller connected")
this.setupEventListeners()
}
// Called when controller disconnects from DOM
disconnect() {
console.log("Controller disconnected")
this.cleanup()
}
// Action methods (called from data-action attributes)
open(event) {
event.preventDefault()
this.panelTarget.classList.remove(this.hiddenClass)
}
close(event) {
this.panelTarget.classList.add(this.hiddenClass)
}
// Private helper methods
setupEventListeners() {
// Setup code
}
cleanup() {
// Cleanup code
}
}
```
### Targets Pattern
Targets are DOM elements the controller manages.
```javascript
static targets = ["sidebar", "toggle", "overlay"]
// Usage in methods:
this.sidebarTarget // First matching element (throws if not found)
this.sidebarTargets // Array of all matching elements
this.hasSidebarTarget // Boolean check if target exists
// Example:
if (this.hasSidebarTarget) {
this.sidebarTarget.classList.add("hidden")
}
this.sidebarTargets.forEach(el => {
el.classList.remove("active")
})
```
**HTML Binding:**
```html
<div data-controller="responsive-sidebar">
<div data-responsive-sidebar-target="sidebar"></div>
<button data-responsive-sidebar-target="toggle"></button>
<div data-responsive-sidebar-target="overlay"></div>
</div>
```
### Values Pattern
Values are configurable parameters passed from HTML to JavaScript.
```javascript
static values = {
url: String, // Required string
delay: { type: Number, default: 300 }, // Number with default
enabled: { type: Boolean, default: true },
options: { type: Object, default: {} },
items: { type: Array, default: [] }
}
// Access values:
this.urlValue // "https://example.com"
this.delayValue // 300
this.enabledValue // true
this.optionsValue // { key: "value" }
this.itemsValue // [1, 2, 3]
// Check if value exists:
this.hasUrlValue // Boolean
// Value change callbacks (optional):
urlValueChanged(value, previousValue) {
console.log(`URL changed from ${previousValue} to ${value}`)
}
```
**HTML Binding:**
```html
<div data-controller="autosave"
data-autosave-url-value="/api/save"
data-autosave-delay-value="2000"
data-autosave-enabled-value="true"
data-autosave-options-value='{"auto": true}'
data-autosave-items-value="[1,2,3]">
</div>
```
**Naming Convention:**
- JavaScript: `camelCase` (e.g., `delayValue`)
- HTML: `kebab-case` (e.g., `data-*-delay-value`)
### Actions Pattern
Actions bind DOM events to controller methods.
**Syntax:** `data-action="[event]->[controller]#[method]"`
```html
<!-- Click events -->
<button data-action="click->modal#open">Open Modal</button>
<!-- Multiple actions on one element -->
<input data-action="input->search#query change->search#submit">
<!-- Custom events -->
<div data-action="custom:event->handler#process"></div>
<!-- Event modifiers -->
<button data-action="click->modal#close:once">Close Once</button>
<form data-action="submit->form#save:prevent">...</form>
<input data-action="keydown.enter->search#submit">
<input data-action="keydown.esc->modal#close">
<!-- Window/document events -->
<div data-action="resize@window->layout#adjust"></div>
<div data-action="scroll@window->header#handleScroll"></div>
```
**Common Events:**
- `click` - Button/link clicks
- `input` - Text input changes (fires on each keystroke)
- `change` - Select/checkbox/radio changes
- `submit` - Form submissions
- `focus` / `blur` - Input focus events
- `dragstart` / `dragend` / `dragover` / `drop` - Drag and drop
- `keydown` / `keyup` - Keyboard events
### Common Controller Patterns
#### 1. Modal/Drawer Controllers
Pattern for overlays that appear/disappear.
```javascript
// app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["panel", "backdrop", "content"]
static values = {
url: String
}
connect() {
// Optional: Load content via AJAX on connect
}
async open(event) {
event.preventDefault()
// Load content if URL provided
if (this.hasUrlValue) {
await this.loadContent()
}
// Show modal with smooth transition
this.panelTarget.classList.remove("hidden")
this.backdropTarget.classList.remove("hidden")
// Focus management for accessibility
setTimeout(() => {
this.focusFirstElement()
}, 100)
// Handle ESC key
document.addEventListener("keydown", this.handleEscape)
}
close(event) {
event?.preventDefault()
this.panelTarget.classList.add("hidden")
this.backdropTarget.classList.add("hidden")
// Cleanup
document.removeEventListener("keydown", this.handleEscape)
}
closeOnBackdrop(event) {
if (event.target === this.backdropTarget) {
this.close(event)
}
}
async loadContent() {
const response = await fetch(this.urlValue, {
headers: {
"Accept": "text/html",
"X-Requested-With": "XMLHttpRequest"
}
})
if (response.ok) {
const html = await response.text()
this.contentTarget.innerHTML = html
}
}
focusFirstElement() {
const firstFocusable = this.panelTarget.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (firstFocusable) {
firstFocusable.focus()
}
}
handleEscape = (event) => {
if (event.key === "Escape") {
this.close()
}
}
disconnect() {
document.removeEventListener("keydown", this.handleEscape)
}
}
```
**HTML Usage:**
```erb
<div data-controller="modal">
<!-- Backdrop -->
<div data-modal-target="backdrop"
data-action="click->modal#closeOnBackdrop"
class="hidden fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity duration-300"
aria-hidden="true"></div>
<!-- Panel -->
<div data-modal-target="panel"
class="hidden fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title">
<div class="flex min-h-full items-center justify-center p-4">
<div class="bg-background rounded-lg shadow-xl max-w-lg w-full p-6">
<div data-modal-target="content">
<!-- Modal content here -->
</div>
<button data-action="click->modal#close"
class="mt-4 px-4 py-2 bg-secondary text-text rounded">
Close
</button>
</div>
</div>
</div>
<!-- Trigger -->
<button data-action="click->modal#open"
data-modal-url-value="/path/to/content"
class="px-4 py-2 bg-primary text-background rounded">
Open Modal
</button>
</div>
```
#### 2. Responsive Sidebar Controller
Mobile-first sidebar that toggles on mobile, always visible on desktop.
```javascript
// app/javascript/controllers/responsive_sidebar_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["sidebar", "toggle", "overlay"]
static values = {
breakpoint: { type: Number, default: 1024 },
isOpen: { type: Boolean, default: false }
}
connect() {
this.handleResize = this.handleResize.bind(this)
this.handleKeyboard = this.handleKeyboard.bind(this)
window.addEventListener("resize", this.handleResize)
document.addEventListener("keydown", this.handleKeyboard)
this.updateSidebarState()
}
disconnect() {
window.removeEventListener("resize", this.handleResize)
document.removeEventListener("keydown", this.handleKeyboard)
}
toggle(event) {
event.preventDefault()
this.isOpenValue = !this.isOpenValue
}
close() {
this.isOpenValue = false
}
closeOverlay(event) {
if (this.isMobile()) {
this.close()
}
}
isOpenValueChanged() {
this.updateSidebarState()
}
updateSidebarState() {
if (this.isMobile()) {
this.updateMobileSidebar()
} else {
this.updateDesktopSidebar()
}
}
updateMobileSidebar() {
if (this.isOpenValue) {
this.sidebarTarget.classList.remove("hidden", "-translate-x-full")
this.overlayTarget.classList.remove("hidden")
this.sidebarTarget.setAttribute("aria-hidden", "false")
this.focusSidebar()
} else {
this.sidebarTarget.classList.add("-translate-x-full")
this.overlayTarget.classList.add("hidden")
this.sidebarTarget.setAttribute("aria-hidden", "true")
setTimeout(() => {
if (!this.isOpenValue) {
this.sidebarTarget.classList.add("hidden")
}
}, 300)
}
if (this.hasToggleTarget) {
this.toggleTarget.setAttribute("aria-expanded", this.isOpenValue.toString())
}
}
updateDesktopSidebar() {
this.sidebarTarget.classList.remove("hidden", "-translate-x-full")
this.overlayTarget.classList.add("hidden")
this.sidebarTarget.setAttribute("aria-hidden", "false")
}
handleResize() {
this.updateSidebarState()
}
handleKeyboard(event) {
if (event.key === "Escape" && this.isOpenValue && this.isMobile()) {
this.close()
}
}
isMobile() {
return window.innerWidth < this.breakpointValue
}
focusSidebar() {
const firstFocusable = this.sidebarTarget.querySelector(
'a[href], button:not([disabled]), input:not([disabled])'
)
if (firstFocusable) {
firstFocusable.focus()
}
}
}
```
**HTML Usage:**
```erb
<div data-controller="responsive-sidebar"
data-responsive-sidebar-breakpoint-value="1024">
<!-- Sidebar -->
<aside data-responsive-sidebar-target="sidebar"
id="main-sidebar"
role="navigation"
aria-label="Main navigation"
aria-hidden="true"
class="fixed lg:static inset-y-0 left-0 z-50 lg:z-auto w-64 bg-background border-r border-secondary hidden lg:block -translate-x-full lg:translate-x-0 transition-transform duration-300">
<!-- Sidebar content -->
</aside>
<!-- Overlay (mobile only) -->
<div data-responsive-sidebar-target="overlay"
data-action="click->responsive-sidebar#closeOverlay"
class="hidden lg:hidden fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity duration-300"
aria-hidden="true"></div>
<!-- Toggle Button (mobile only) -->
<button data-responsive-sidebar-target="toggle"
data-action="click->responsive-sidebar#toggle"
class="lg:hidden p-2 text-text-light hover:text-primary focus:outline-none focus:ring-2 focus:ring-primary rounded"
type="button"
aria-expanded="false"
aria-controls="main-sidebar"
aria-label="Toggle sidebar">
<%= icon("bars-3", class: "w-6 h-6") %>
</button>
</div>
```
#### 3. Autosave Controller
Debounced form autosave with visual feedback.
```javascript
// app/javascript/controllers/autosave_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form", "status"]
static values = {
url: String,
delay: { type: Number, default: 2000 }
}
connect() {
this.saveTimeout = null
this.lastSavedData = null
this.isSaving = false
this.setupFormListeners()
this.lastSavedData = this.getFormData()
}
disconnect() {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
}
setupFormListeners() {
const form = this.hasFormTarget ? this.formTarget : this.element
form.querySelectorAll('input, textarea, select').forEach(field => {
field.addEventListener('input', this.handleFormChange.bind(this))
field.addEventListener('change', this.handleFormChange.bind(this))
})
}
handleFormChange(event) {
// Clear previous timeout
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
// Update status to pending
this.updateStatus('pending')
// Set new timeout
this.saveTimeout = setTimeout(() => {
this.autosave()
}, this.delayValue)
}
async autosave() {
if (this.isSaving) return
const currentData = this.getFormData()
// Don't save if data hasn't changed
if (JSON.stringify(currentData) === JSON.stringify(this.lastSavedData)) {
this.updateStatus('saved')
return
}
this.isSaving = true
this.updateStatus('saving')
try {
const response = await fetch(this.urlValue, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.getCSRFToken(),
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(currentData)
})
if (response.ok) {
this.lastSavedData = currentData
this.updateStatus('saved')
} else {
this.updateStatus('error')
}
} catch (error) {
console.error('Autosave error:', error)
this.updateStatus('error')
} finally {
this.isSaving = false
}
}
getFormData() {
const form = this.hasFormTarget ? this.formTarget : this.element
const formData = new FormData(form)
const data = {}
for (let [key, value] of formData.entries()) {
data[key] = value
}
return data
}
updateStatus(status) {
if (!this.hasStatusTarget) return
const messages = {
pending: 'Unsaved changes...',
saving: 'Saving...',
saved: 'All changes saved',
error: 'Error saving changes'
}
const colors = {
pending: 'text-accent',
saving: 'text-primary',
saved: 'text-secondary-dark',
error: 'text-secondary-accent'
}
this.statusTarget.textContent = messages[status]
this.statusTarget.className = `text-sm ${colors[status]}`
}
getCSRFToken() {
const token = document.querySelector('meta[name="csrf-token"]')
return token ? token.getAttribute('content') : ''
}
}
```
**HTML Usage:**
```erb
<div data-controller="autosave"
data-autosave-url-value="<%= autosave_document_path(@document) %>"
data-autosave-delay-value="2000">
<div class="mb-4">
<span data-autosave-target="status" class="text-sm text-text-light">
All changes saved
</span>
</div>
<%= form_with model: @document, data: { autosave_target: "form" } do |f| %>
<%= f.text_area :content, class: "w-full border rounded p-2" %>
<%= f.text_field :title, class: "w-full border rounded p-2" %>
<% end %>
</div>
```
#### 4. Form Builder / Dynamic Forms Controller
Complex form manipulation with drag-and-drop.
```javascript
// app/javascript/controllers/form_builder_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["canvas", "palette", "properties", "contentField"]
connect() {
this.formElements = []
this.selectedElementIndex = null
// Load existing form content if present
if (this.hasContentFieldTarget && this.contentFieldTarget.value) {
const content = JSON.parse(this.contentFieldTarget.value)
this.formElements = content.elements || []
this.renderCanvas()
}
}
// Drag and drop from palette
handleDragStart(event) {
const type = event.target.dataset.fieldType
event.dataTransfer.setData('application/json', JSON.stringify({
type: type,
source: 'palette'
}))
}
handleDragOver(event) {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}
handleDrop(event) {
event.preventDefault()
const data = JSON.parse(event.dataTransfer.getData('application/json'))
if (data.source === 'palette') {
// Add new element
const element = this.createElementByType(data.type)
this.formElements.push(element)
this.renderCanvas()
this.updateContentField()
}
}
createElementByType(type) {
const element = {
type: type,
id: this.generateId(),
label: this.getLabelByType(type),
name: this.getNameByType(type),
required: false
}
// Add type-specific properties
switch (type) {
case 'radio':
case 'checkbox':
case 'select':
element.options = [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' }
]
break
case 'text':
case 'textarea':
element.placeholder = ''
break
}
return element
}
removeElement(event) {
const index = parseInt(event.target.dataset.index)
this.formElements.splice(index, 1)
this.renderCanvas()
this.updateContentField()
}
selectElement(event) {
const index = parseInt(event.target.closest('[data-index]').dataset.index)
this.selectedElementIndex = index
this.renderProperties()
}
updateOption(event) {
const property = event.target.name
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
if (this.selectedElementIndex !== null) {
this.formElements[this.selectedElementIndex][property] = value
this.renderCanvas()
this.updateContentField()
}
}
renderCanvas() {
this.canvasTarget.innerHTML = ''
this.formElements.forEach((element, index) => {
const wrapper = document.createElement('div')
wrapper.className = 'p-4 border border-secondary rounded mb-2 cursor-pointer hover:border-primary'
wrapper.dataset.index = index
wrapper.addEventListener('click', this.selectElement.bind(this))
wrapper.innerHTML = `
<div class="flex justify-between items-start">
<div class="flex-1">
<label class="block text-sm font-medium text-text mb-1">
${element.label}
${element.required ? '<span class="text-secondary-accent">*</span>' : ''}
</label>
${this.renderFieldPreview(element)}
</div>
<button type="button"
data-index="${index}"
data-action="click->form-builder#removeElement:stop"
class="text-secondary-accent hover:text-secondary-dark">
Remove
</button>
</div>
`
this.canvasTarget.appendChild(wrapper)
})
}
renderFieldPreview(element) {
switch (element.type) {
case 'text':
return `<input type="text" disabled placeholder="${element.placeholder || ''}" class="w-full border rounded px-3 py-2">`
case 'textarea':
return `<textarea disabled placeholder="${element.placeholder || ''}" class="w-full border rounded px-3 py-2" rows="3"></textarea>`
case 'select':
return `<select disabled class="w-full border rounded px-3 py-2">
${element.options.map(opt => `<option>${opt.label}</option>`).join('')}
</select>`
case 'radio':
case 'checkbox':
return `<div class="space-y-2">
${element.options.map(opt => `
<label class="flex items-center">
<input type="${element.type}" disabled class="mr-2">
${opt.label}
</label>
`).join('')}
</div>`
default:
return ''
}
}
renderProperties() {
if (this.selectedElementIndex === null || !this.hasPropertiesTarget) return
const element = this.formElements[this.selectedElementIndex]
this.propertiesTarget.innerHTML = `
<h3 class="text-lg font-semibold mb-4">Field Properties</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Label</label>
<input type="text"
name="label"
value="${element.label}"
data-action="input->form-builder#updateOption"
class="w-full border rounded px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium mb-1">Field Name</label>
<input type="text"
name="name"
value="${element.name}"
data-action="input->form-builder#updateOption"
class="w-full border rounded px-3 py-2">
</div>
<div>
<label class="flex items-center">
<input type="checkbox"
name="required"
${element.required ? 'checked' : ''}
data-action="change->form-builder#updateOption"
class="mr-2">
Required field
</label>
</div>
</div>
`
}
updateContentField() {
if (this.hasContentFieldTarget) {
this.contentFieldTarget.value = JSON.stringify({
elements: this.formElements
})
}
}
generateId() {
return `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
getLabelByType(type) {
const labels = {
text: 'Text Field',
textarea: 'Text Area',
select: 'Dropdown',
radio: 'Radio Buttons',
checkbox: 'Checkboxes',
date: 'Date Field',
number: 'Number Field'
}
return labels[type] || 'Field'
}
getNameByType(type) {
return `${type}_${Date.now()}`
}
}
```
### Accessibility Patterns
Always implement proper accessibility:
```javascript
// Focus management
focusFirstElement() {
const firstFocusable = this.element.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (firstFocusable) {
firstFocusable.focus()
}
}
// Keyboard navigation
handleKeyboard(event) {
switch (event.key) {
case "Escape":
this.close()
break
case "Tab":
this.trapFocus(event)
break
}
}
// Focus trap (for modals)
trapFocus(event) {
const focusableElements = this.element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
}
}
// ARIA state management
updateAriaState() {
this.toggleTarget.setAttribute("aria-expanded", this.isOpenValue.toString())
this.panelTarget.setAttribute("aria-hidden", (!this.isOpenValue).toString())
}
```
---
## VIEWCOMPONENTS
ViewComponents are server-rendered, reusable UI components that encapsulate markup and logic.
### Component Structure
**Location:** `app/components/`
**Files:** `{name}_component.rb` + `{name}_component.html.erb`
**Basic Component Template:**
```ruby
# app/components/button_component.rb
class ButtonComponent < ApplicationComponent
# Define component parameters
def initialize(
label:,
type: :primary,
size: :medium,
url: nil,
method: :get,
html_options: {},
icon: nil,
icon_position: :left,
disabled: false
)
@label = label
@type = type
@size = size
@url = url
@method = method
@html_options = html_options
@icon = icon
@icon_position = icon_position
@disabled = disabled
end
# Public query methods
def link?
@url.present?
end
def has_icon?
@icon.present?
end
private
# Dynamic class generation
def button_classes
base_classes = "inline-flex items-center justify-center rounded font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2"
size_classes = case @size
when :small
"px-3 py-1.5 text-sm"
when :large
"px-6 py-3 text-base"
else
"px-4 py-2 text-sm"
end
type_classes = case @type
when :primary
"bg-primary text-background hover:bg-primary-dark focus:ring-primary"
when :secondary
"bg-secondary text-text hover:bg-background-alt focus:ring-secondary"
when :danger
"bg-secondary-accent text-background hover:bg-secondary-dark focus:ring-secondary-accent"
when :outline
"border border-primary text-primary hover:bg-primary hover:text-background focus:ring-primary"
when :text
"text-primary hover:text-primary-dark focus:ring-primary"
else
"bg-primary text-background hover:bg-primary-dark focus:ring-primary"
end
disabled_classes = @disabled ? "opacity-50 cursor-not-allowed pointer-events-none" : ""
[base_classes, size_classes, type_classes, disabled_classes, @html_options[:class]].compact.join(" ")
end
def icon_classes
case @size
when :small
"w-4 h-4"
when :large
"w-6 h-6"
else
"w-5 h-5"
end
end
end
```
```erb
<%# app/components/button_component.html.erb %>
<% if link? %>
<%= link_to @url, class: button_classes, method: @method, data: @html_options[:data], aria: @html_options[:aria] do %>
<% if has_icon? && @icon_position == :left %>
<%= icon(@icon, class: "#{icon_classes} mr-2") %>
<% end %>
<%= @label %>
<% if has_icon? && @icon_position == :right %>
<%= icon(@icon, class: "#{icon_classes} ml-2") %>
<% end %>
<% end %>
<% else %>
<button type="<%= @html_options[:type] || 'button' %>"
class="<%= button_classes %>"
<%= "disabled" if @disabled %>
data-<%= @html_options[:data]&.map { |k, v| "#{k}='#{v}'" }&.join(" ") %>
aria-<%= @html_options[:aria]&.map { |k, v| "#{k}='#{v}'" }&.join(" ") %>>
<% if has_icon? && @icon_position == :left %>
<%= icon(@icon, class: "#{icon_classes} mr-2") %>
<% end %>
<%= @label %>
<% if has_icon? && @icon_position == :right %>
<%= icon(@icon, class: "#{icon_classes} ml-2") %>
<% end %>
</button>
<% end %>
```
### Component Patterns
#### 1. Simple UI Component (Button, Pill, Badge)
```ruby
# app/components/pill_component.rb
class PillComponent < ApplicationComponent
def initialize(text:, color: :primary, size: :medium, removable: false, data: {})
@text = text
@color = color
@size = size
@removable = removable
@data = data
end
private
def pill_classes
base = "inline-flex items-center rounded-full font-medium"
size = case @size
when :small then "px-2 py-0.5 text-xs"
when :large then "px-4 py-2 text-base"
else "px-3 py-1 text-sm"
end
color = case @color
when :primary then "bg-primary-light text-primary-dark"
when :secondary then "bg-secondary text-text"
when :success then "bg-secondary-dark text-background"
when :danger then "bg-secondary-accent text-background"
else "bg-background-alt text-text"
end
[base, size, color].join(" ")
end
end
```
```erb
<%# app/components/pill_component.html.erb %>
<span class="<%= pill_classes %>" data-<%= @data.map { |k, v| "#{k}='#{v}'" }.join(" ") %>>
<%= @text %>
<% if @removable %>
<button type="button"
class="ml-1 -mr-1 p-0.5 rounded-full hover:bg-black hover:bg-opacity-10 focus:outline-none focus:ring-2 focus:ring-primary"
aria-label="Remove <%= @text %>">
<%= icon("x-mark", class: "w-3 h-3") %>
</button>
<% end %>
</span>
```
**Usage:**
```erb
<%= render PillComponent.new(text: "Active", color: :success) %>
<%= render PillComponent.new(text: "Tag", color: :primary, removable: true) %>
```
#### 2. Container Component (Modal, Drawer, Card)
```ruby
# app/components/modal_component.rb
class ModalComponent < ApplicationComponent
def initialize(
id:,
title: nil,
size: :medium,
close_button: true,
footer: nil
)
@id = id
@title = title
@size = size
@close_button = close_button
@footer = footer
end
private
def modal_size_classes
case @size
when :small then "max-w-md"
when :large then "max-w-4xl"
when :full then "max-w-7xl"
else "max-w-2xl"
end
end
end
```
```erb
<%# app/components/modal_component.html.erb %>
<div data-controller="modal" data-modal-id="<%= @id %>">
<!-- Backdrop -->
<div data-modal-target="backdrop"
data-action="click->modal#closeOnBackdrop"
class="hidden fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity duration-300"
aria-hidden="true"></div>
<!-- Modal Panel -->
<div data-modal-target="panel"
class="hidden fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true"
aria-labelledby="<%= @id %>-title">
<div class="flex min-h-full items-center justify-center p-4">
<div class="bg-background rounded-lg shadow-xl <%= modal_size_classes %> w-full">
<!-- Header -->
<% if @title || @close_button %>
<div class="flex items-center justify-between p-6 border-b border-secondary">
<% if @title %>
<h2 id="<%= @id %>-title" class="text-xl font-semibold text-text">
<%= @title %>
</h2>
<% end %>
<% if @close_button %>
<button type="button"
data-action="click->modal#close"
class="text-text-light hover:text-text focus:outline-none focus:ring-2 focus:ring-primary rounded p-1"
aria-label="Close modal">
<%= icon("x-mark", class: "w-6 h-6") %>
</button>
<% end %>
</div>
<% end %>
<!-- Content -->
<div data-modal-target="content" class="p-6">
<%= content %>
</div>
<!-- Footer -->
<% if @footer %>
<div class="flex items-center justify-end gap-3 p-6 border-t border-secondary">
<%= @footer %>
</div>
<% end %>
</div>
</div>
</div>
</div>
```
**Usage:**
```erb
<%= render ModalComponent.new(
id: "confirm-delete",
title: "Confirm Deletion",
size: :small
) do %>
<p class="text-text-light mb-4">
Are you sure you want to delete this item? This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<%= render ButtonComponent.new(
label: "Cancel",
type: :secondary,
html_options: { data: { action: "click->modal#close" } }
) %>
<%= render ButtonComponent.new(
label: "Delete",
type: :danger,
url: item_path(@item),
method: :delete
) %>
</div>
<% end %>
```
#### 3. Form Component
```ruby
# app/components/form_input_component.rb
class FormInputComponent < ApplicationComponent
def initialize(
form:,
attribute:,
label: nil,
type: :text,
placeholder: nil,
hint: nil,
required: false,
disabled: false,
readonly: false,
options: [],
html_options: {}
)
@form = form
@attribute = attribute
@label = label || attribute.to_s.titleize
@type = type
@placeholder = placeholder
@hint = hint
@required = required
@disabled = disabled
@readonly = readonly
@options = options
@html_options = html_options
end
def has_errors?
@form.object.errors[@attribute].any?
end
private
def input_classes
base = "block w-full rounded border px-3 py-2 text-text placeholder-text-light focus:outline-none focus:ring-2 transition-colors"
if has_errors?
"#{base} border-secondary-accent focus:ring-secondary-accent focus:border-secondary-accent"
else
"#{base} border-secondary focus:ring-primary focus:border-primary"
end
end
def label_classes
base = "block text-sm font-medium mb-1"
has_errors? ? "#{base} text-secondary-accent" : "#{base} text-text"
end
end
```
```erb
<%# app/components/form_input_component.html.erb %>
<div class="mb-4">
<%= @form.label @attribute, @label, class: label_classes do %>
<%= @label %>
<% if @required %>
<span class="text-secondary-accent" aria-label="required">*</span>
<% end %>
<% end %>
<% case @type %>
<% when :text, :email, :password, :tel, :url, :date, :time, :datetime %>
<%= @form.text_field @attribute,
type: @type,
class: input_classes,
placeholder: @placeholder,
required: @required,
disabled: @disabled,
readonly: @readonly,
**@html_options %>
<% when :textarea %>
<%= @form.text_area @attribute,
class: input_classes,
placeholder: @placeholder,
required: @required,
disabled: @disabled,
readonly: @readonly,
rows: @html_options[:rows] || 4,
**@html_options %>
<% when :select %>
<%= @form.select @attribute,
@options,
{ include_blank: @placeholder },
class: input_classes,
required: @required,
disabled: @disabled,
**@html_options %>
<% when :checkbox %>
<div class="flex items-center">
<%= @form.check_box @attribute,
class: "rounded border-secondary text-primary focus:ring-primary focus:ring-offset-0 mr-2",
disabled: @disabled,
**@html_options %>
<%= @form.label @attribute, @placeholder || @label, class: "text-sm text-text" %>
</div>
<% end %>
<% if @hint %>
<p class="mt-1 text-sm text-text-light"><%= @hint %></p>
<% end %>
<% if has_errors? %>
<p class="mt-1 text-sm text-secondary-accent">
<%= @form.object.errors[@attribute].first %>
</p>
<% end %>
</div>
```
**Usage:**
```erb
<%= form_with model: @client do |f| %>
<%= render FormInputComponent.new(
form: f,
attribute: :first_name,
type: :text,
placeholder: "Enter first name",
required: true
) %>
<%= render FormInputComponent.new(
form: f,
attribute: :email,
type: :email,
hint: "We'll never share your email"
) %>
<%= render FormInputComponent.new(
form: f,
attribute: :bio,
type: :textarea,
placeholder: "Tell us about yourself"
) %>
<%= render FormInputComponent.new(
form: f,
attribute: :status,
type: :select,
options: Client::STATUSES,
placeholder: "Select status"
) %>
<% end %>
```
### Component Best Practices
1. **Single Responsibility** - Each component should do one thing well
2. **Composition Over Inheritance** - Build complex UIs by combining simple components
3. **Required vs Optional Parameters** - Use keyword arguments with defaults
4. **Private Helper Methods** - Keep class generation logic in private methods
5. **Accessibility** - Include proper ARIA attributes and semantic HTML
6. **Mobile-First** - Responsive design with Tailwind breakpoints
7. **Theme Colors Only** - Use only colors from `app/assets/tailwind/application.css`
---
## TAILWIND CSS
**CRITICAL**: Tailwind CSS is the ONLY styling method allowed. Custom CSS is absolutely forbidden.
### Theme System
**File:** `app/assets/tailwind/application.css`
**APPROVED COLOR PALETTE - USE ONLY THESE:**
This is the complete, exhaustive list of allowed colors. Do NOT use any colors not on this list.
```css
@theme {
/* Primary colors (teal/blue) - Main brand color */
--color-primary: oklch(0.55 0.15 200);
--color-primary-light: oklch(0.75 0.15 200);
--color-primary-dark: oklch(0.45 0.15 200);
/* Secondary colors (gray) - Borders, dividers, subtle backgrounds */
--color-secondary: oklch(0.98 0.02 100);
/* Accent colors */
--color-accent: oklch(0.85 0.18 80); /* Yellow/orange - Warnings, highlights */
--color-secondary-accent: oklch(0.7 0.1183 27); /* Red/orange - Danger, errors */
--color-secondary-dark: oklch(0.4 0.1183 27); /* Dark red - Danger hover states */
/* Text colors */
--color-text: oklch(0.25 0.01 240); /* Dark gray - Primary body text */
--color-text-light: oklch(0.65 0.01 240); /* Medium gray - Secondary text, hints */
--color-text-white: oklch(1 0 0); /* White - Text on dark backgrounds */
/* Background colors */
--color-background: oklch(1 0 0); /* White - Main background */
--color-background-alt: oklch(0.98 0.02 240); /* Light blue-gray - Alternate backgrounds */
}
```
**RULES:**
- ❌ NO arbitrary color values: `bg-[#ff0000]`, `text-[rgb(255,0,0)]`
- ❌ NO default Tailwind colors: `bg-blue-500`, `text-red-600`, `bg-gray-100`
- ✅ ONLY use theme colors: `bg-primary`, `text-text`, `bg-secondary`, etc.
- ✅ If you need a color not in this list, ask the user first
### Semantic Color Usage
**ONLY use theme color names. NO arbitrary values. NO default Tailwind colors.**
```erb
<!-- ✅ CORRECT - Using theme colors -->
<div class="bg-primary text-background"></div>
<div class="bg-secondary text-text"></div>
<p class="text-text-light"></p>
<button class="bg-accent text-text hover:bg-secondary-accent"></button>
<!-- ❌ WRONG - Default Tailwind colors (NOT ALLOWED) -->
<div class="bg-blue-500 text-white"></div>
<div class="bg-gray-100 text-gray-900"></div>
<div class="bg-red-600 text-white"></div>
<!-- ❌ WRONG - Arbitrary values (NOT ALLOWED) -->
<div class="bg-[#3b82f6] text-[#ffffff]"></div>
<div class="bg-[rgb(59,130,246)]"></div>
<!-- ❌ WRONG - Inline styles (NOT ALLOWED) -->
<div style="background-color: #3b82f6; color: white;"></div>
```
**Color Purpose Guide:**
- `bg-primary` / `text-primary` - Primary actions, links, important elements
- `bg-primary-light` - Lighter primary backgrounds (hover states, highlights)
- `bg-primary-dark` - Darker primary (hover states for primary buttons)
- `bg-secondary` / `text-secondary` - Borders, dividers, subtle backgrounds
- `bg-accent` - Warnings, highlights, attention-grabbing elements
- `bg-secondary-accent` - Danger/error states
- `bg-secondary-dark` - Dark danger states (hover on delete buttons)
- `text-text` - Primary body text
- `text-text-light` - Secondary text, hints, labels
- `text-text-white` - White text on dark backgrounds
- `bg-background` - Main background
- `bg-background-alt` - Alternate backgrounds (cards, hover states)
### Common Utility Patterns
#### Layout & Spacing
```html
<!-- Flexbox -->
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col space-y-4">
<div class="flex-1"> <!-- Flex grow -->
<div class="flex items-start"> <!-- Align to top -->
<!-- Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="grid grid-cols-12">
<div class="col-span-12 md:col-span-6 lg:col-span-4">
<!-- Spacing -->
<div class="p-4"> <!-- Padding all sides -->
<div class="px-6 py-4"> <!-- Horizontal and vertical padding -->
<div class="mb-4"> <!-- Margin bottom -->
<div class="space-y-4"> <!-- Vertical spacing between children -->
<div class="gap-4"> <!-- Gap in flex/grid -->
```
#### Typography
```html
<!-- Text sizes -->
<p class="text-xs"> <!-- 12px -->
<p class="text-sm"> <!-- 14px -->
<p class="text-base"> <!-- 16px -->
<p class="text-lg"> <!-- 18px -->
<p class="text-xl"> <!-- 20px -->
<p class="text-2xl"> <!-- 24px -->
<!-- Font weights -->
<span class="font-light">
<span class="font-normal">
<span class="font-medium">
<span class="font-semibold">
<span class="font-bold">
<!-- Text alignment -->
<p class="text-left">
<p class="text-center">
<p class="text-right">
<!-- Line height -->
<p class="leading-tight">
<p class="leading-normal">
<p class="leading-relaxed">
<!-- Text decoration -->
<a class="underline hover:no-underline">
<s class="line-through">
```
#### Borders & Rounded Corners
```html
<!-- Borders -->
<div class="border border-secondary">
<div class="border-b border-secondary"> <!-- Bottom only -->
<div class="border-2 border-primary"> <!-- Thicker border -->
<!-- Rounded corners -->
<div class="rounded"> <!-- 4px -->
<div class="rounded-lg"> <!-- 8px -->
<div class="rounded-full"> <!-- Fully rounded (pills, circles) -->
<div class="rounded-t-lg"> <!-- Top corners only -->
```
#### Shadows
```html
<div class="shadow-sm"> <!-- Subtle shadow -->
<div class="shadow"> <!-- Default shadow -->
<div class="shadow-md"> <!-- Medium shadow -->
<div class="shadow-lg"> <!-- Large shadow -->
<div class="shadow-xl"> <!-- Extra large shadow -->
```
#### Responsive Design
**Mobile-First Approach** - Base styles apply to mobile, then use breakpoints:
```html
<!-- Breakpoints: sm:640px, md:768px, lg:1024px, xl:1280px, 2xl:1536px -->
<!-- Hide on mobile, show on desktop -->
<div class="hidden lg:block">
<!-- Full width on mobile, half on tablet, third on desktop -->
<div class="w-full md:w-1/2 lg:w-1/3">
<!-- Stack on mobile, row on desktop -->
<div class="flex flex-col lg:flex-row">
<!-- Different padding at different sizes -->
<div class="p-4 md:p-6 lg:p-8">
<!-- Mobile sidebar pattern -->
<aside class="fixed lg:static inset-y-0 left-0 z-50 lg:z-auto w-64">
```
#### Transitions & Animations
```html
<!-- Basic transition -->
<button class="transition-colors duration-200 hover:bg-primary">
<!-- Multiple properties -->
<div class="transition-all duration-300 ease-in-out">
<!-- Transform -->
<div class="transform hover:scale-105 transition-transform">
<div class="transition-transform duration-300 -translate-x-full lg:translate-x-0">
<!-- Opacity -->
<div class="transition-opacity duration-300 opacity-0 hover:opacity-100">
```
#### Interactive States
```html
<!-- Hover -->
<button class="bg-primary hover:bg-primary-dark">
<a class="text-text hover:text-primary">
<!-- Focus (accessibility) -->
<input class="focus:outline-none focus:ring-2 focus:ring-primary">
<button class="focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
<!-- Active -->
<button class="active:scale-95">
<!-- Disabled -->
<button class="disabled:opacity-50 disabled:cursor-not-allowed">
<!-- Group hover (parent hover affects child) -->
<div class="group">
<div class="group-hover:text-primary">
</div>
```
#### Positioning
```html
<!-- Relative/Absolute -->
<div class="relative">
<div class="absolute top-0 right-0">
<div class="absolute inset-0"> <!-- Fill parent -->
<!-- Fixed -->
<div class="fixed top-0 left-0 w-full z-50">
<!-- Sticky -->
<header class="sticky top-0 z-40">
<!-- Z-index -->
<div class="z-0"> <!-- 0 -->
<div class="z-10"> <!-- 10 -->
<div class="z-40"> <!-- Overlays -->
<div class="z-50"> <!-- Modals -->
```
#### Width & Height
```html
<!-- Width -->
<div class="w-full"> <!-- 100% -->
<div class="w-1/2"> <!-- 50% -->
<div class="w-64"> <!-- 16rem / 256px -->
<div class="w-screen"> <!-- 100vw -->
<div class="max-w-md"> <!-- Max width constraints -->
<div class="max-w-7xl">
<div class="min-w-0">
<!-- Height -->
<div class="h-full"> <!-- 100% of parent -->
<div class="h-screen"> <!-- 100vh -->
<div class="h-64"> <!-- 16rem -->
<div class="min-h-screen"> <!-- At least full screen -->
```
### Common Component Patterns
#### Button Styles
```html
<!-- Primary button -->
<button class="px-4 py-2 bg-primary text-background rounded font-medium hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-colors">
<!-- Secondary button -->
<button class="px-4 py-2 bg-secondary text-text rounded font-medium hover:bg-background-alt focus:outline-none focus:ring-2 focus:ring-secondary transition-colors">
<!-- Danger button -->
<button class="px-4 py-2 bg-secondary-accent text-background rounded font-medium hover:bg-secondary-dark focus:outline-none focus:ring-2 focus:ring-secondary-accent transition-colors">
<!-- Outline button -->
<button class="px-4 py-2 border border-primary text-primary rounded font-medium hover:bg-primary hover:text-background focus:outline-none focus:ring-2 focus:ring-primary transition-colors">
<!-- Text button -->
<button class="px-2 py-1 text-primary hover:text-primary-dark focus:outline-none focus:ring-2 focus:ring-primary rounded transition-colors">
```
#### Card/Container
```html
<div class="bg-background border border-secondary rounded-lg shadow-sm p-6">
<h3 class="text-lg font-semibold text-text mb-4">Card Title</h3>
<p class="text-text-light">Card content goes here...</p>
</div>
<!-- Hoverable card -->
<div class="bg-background border border-secondary rounded-lg shadow-sm p-6 transition-shadow hover:shadow-md cursor-pointer">
```
#### Form Input
```html
<input type="text"
class="block w-full rounded border border-secondary px-3 py-2 text-text placeholder-text-light focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors">
<!-- With error state -->
<input type="text"
class="block w-full rounded border border-secondary-accent px-3 py-2 text-text focus:outline-none focus:ring-2 focus:ring-secondary-accent focus:border-secondary-accent">
```
#### Alert/Flash Message
```html
<!-- Success -->
<div class="bg-secondary-dark text-background rounded-lg p-4 flex items-center gap-3">
<%= icon("check-circle", class: "w-5 h-5") %>
<p>Success message</p>
</div>
<!-- Error -->
<div class="bg-secondary-accent text-background rounded-lg p-4 flex items-center gap-3">
<%= icon("exclamation-triangle", class: "w-5 h-5") %>
<p>Error message</p>
</div>
<!-- Warning -->
<div class="bg-accent text-text rounded-lg p-4 flex items-center gap-3">
<%= icon("exclamation-circle", class: "w-5 h-5") %>
<p>Warning message</p>
</div>
```
#### Navigation
```html
<!-- Horizontal nav -->
<nav class="flex items-center space-x-6 border-b border-secondary px-6 py-4">
<a href="#" class="text-text hover:text-primary transition-colors">Link 1</a>
<a href="#" class="text-primary font-medium">Active Link</a>
<a href="#" class="text-text hover:text-primary transition-colors">Link 3</a>
</nav>
<!-- Vertical sidebar nav -->
<nav class="flex flex-col space-y-2 p-4">
<a href="#" class="px-3 py-2 rounded text-text hover:bg-background-alt hover:text-primary transition-colors">
Link 1
</a>
<a href="#" class="px-3 py-2 rounded bg-primary-light text-primary font-medium">
Active Link
</a>
</nav>
```
### FINAL REMINDER: ZERO CUSTOM CSS POLICY
**This is NON-NEGOTIABLE and STRICTLY ENFORCED:**
✅ **What IS allowed:**
- Tailwind utility classes ONLY
- Theme colors from `app/assets/tailwind/application.css` ONLY
- Responsive modifiers: `md:`, `lg:`, etc.
- State modifiers: `hover:`, `focus:`, `active:`, etc.
- Transitions and animations via Tailwind utilities
❌ **What is NEVER allowed:**
- Custom CSS files (`.css` files other than the theme)
- `<style>` tags in any file (components, views, layouts)
- Inline `style=""` attributes
- Arbitrary color values: `bg-[#ff0000]`, `text-[rgb(255,0,0)]`
- Default Tailwind colors: `bg-blue-500`, `bg-red-600`, `bg-gray-100`
- CSS preprocessors (SCSS, SASS, LESS)
- Additional CSS frameworks
**If you need styling Tailwind doesn't provide:**
1. Use Stimulus JavaScript for dynamic behavior
2. Combine existing Tailwind utilities creatively
3. Ask the user if a new Tailwind utility should be added
4. NEVER write custom CSS as a solution
---
## HOTWIRE / TURBO
Turbo enables rich client-side interactions without writing JavaScript.
### Turbo Frames
Turbo Frames update portions of the page without a full reload.
**Basic Usage:**
```erb
<%# app/views/clients/index.html.erb %>
<div class="space-y-4">
<%= turbo_frame_tag "clients" do %>
<%= render @clients %>
<% end %>
</div>
<%# app/views/clients/_client.html.erb %>
<%= turbo_frame_tag dom_id(client) do %>
<div class="border rounded p-4">
<h3><%= client.full_name %></h3>
<%= link_to "Edit", edit_client_path(client) %>
</div>
<% end %>
<%# app/views/clients/edit.html.erb %>
<%# This form will replace the turbo frame with matching id %>
<%= turbo_frame_tag dom_id(@client) do %>
<%= form_with model: @client do |f| %>
<%= f.text_field :first_name %>
<%= f.text_field :last_name %>
<%= f.submit "Save" %>
<% end %>
<% end %>
```
**Lazy Loading:**
```erb
<%= turbo_frame_tag "recent_activity",
src: recent_activity_path,
loading: :lazy do %>
<p>Loading activity...</p>
<% end %>
```
**Breaking Out of Frames:**
```erb
<%# Link that navigates entire page instead of replacing frame %>
<%= link_to "View All", clients_path, data: { turbo_frame: "_top" } %>
<%# Form that submits outside of frame context %>
<%= form_with model: @client, data: { turbo_frame: "_top" } do |f| %>
...
<% end %>
```
### Turbo Streams
Turbo Streams enable surgical page updates via server responses.
**Stream Actions:**
- `append` - Add to end of target
- `prepend` - Add to beginning of target
- `replace` - Replace target element
- `update` - Update target's innerHTML
- `remove` - Remove target element
- `before` - Insert before target
- `after` - Insert after target
**Controller Response:**
```ruby
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def create
@task = Task.new(task_params)
respond_to do |format|
if @task.save
format.turbo_stream do
render turbo_stream: [
turbo_stream.append("tasks", partial: "tasks/task", locals: { task: @task }),
turbo_stream.prepend("flash", partial: "shared/flash", locals: { message: "Task created!", type: :success })
]
end
format.html { redirect_to tasks_path }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
def complete
@task = Task.find(params[:id])
@task.update(completed: true)
respond_to do |format|
format.turbo_stream
format.html { redirect_to tasks_path }
end
end
def destroy
@task = Task.find(params[:id])
@task.destroy
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(@task) }
format.html { redirect_to tasks_path }
end
end
end
```
**Stream Template:**
```erb
<%# app/views/tasks/complete.turbo_stream.erb %>
<%= turbo_stream.replace dom_id(@task) do %>
<%= render "tasks/completed_task", task: @task %>
<% end %>
<%= turbo_stream.prepend "flash" do %>
<div class="bg-secondary-dark text-background rounded p-4">
Task completed!
</div>
<% end %>
```
**Real-Time Broadcasting:**
```ruby
# app/models/message.rb
class Message < ApplicationRecord
after_create_commit -> {
broadcast_append_to "conversation_#{conversation_id}_messages",
partial: "messages/message",
locals: { message: self },
target: "messages"
}
after_update_commit -> {
broadcast_replace_to "conversation_#{conversation_id}_messages",
partial: "messages/message",
locals: { message: self },
target: dom_id(self)
}
end
```
**Subscribe in View:**
```erb
<%# app/views/conversations/show.html.erb %>
<%= turbo_stream_from "conversation_#{@conversation.id}_messages" %>
<div id="messages" class="space-y-4">
<%= render @messages %>
</div>
<%= form_with model: [@conversation, Message.new],
data: { controller: "reset-form", action: "turbo:submit-end->reset-form#reset" } do |f| %>
<%= f.text_area :content %>
<%= f.submit "Send" %>
<% end %>
```
### Turbo Drive (Page Acceleration)
Turbo Drive automatically intercepts link clicks and form submissions.
**Disable Turbo on Specific Elements:**
```erb
<%# Disable Turbo for external links %>
<%= link_to "External", "https://example.com", data: { turbo: false } %>
<%# Disable Turbo for entire form %>
<%= form_with url: search_path, data: { turbo: false } do |f| %>
...
<% end %>
```
**Confirmation Dialogs:**
```erb
<%= link_to "Delete",
client_path(@client),
method: :delete,
data: {
turbo_method: :delete,
turbo_confirm: "Are you sure you want to delete this client?"
} %>
```
---
## DEVELOPMENT WORKFLOW
### Creating a New Feature
Follow this pattern when building new front-end features:
#### 1. Identify Components Needed
Ask yourself:
- Is there a reusable UI element? → Create ViewComponent
- Is there client-side interaction? → Create Stimulus controller
- Does it update dynamically? → Use Turbo Frames/Streams
#### 2. Build ViewComponents First
Start with the HTML structure and visual design:
```bash
# Create component files
touch app/components/feature_component.rb
touch app/components/feature_component.html.erb
```
```ruby
# app/components/feature_component.rb
class FeatureComponent < ApplicationComponent
def initialize(...)
# Initialize with required data
end
private
def helper_method
# Private helper methods
end
end
```
```erb
<%# app/components/feature_component.html.erb %>
<div class="...">
<%# Component markup with Tailwind classes %>
</div>
```
#### 3. Add Stimulus Controller If Needed
```bash
# Create controller file
touch app/javascript/controllers/feature_controller.js
```
```javascript
// app/javascript/controllers/feature_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = []
static values = {}
connect() {
// Initialize
}
// Action methods
}
```
#### 4. Integrate with Views
```erb
<%# app/views/resources/index.html.erb %>
<div data-controller="feature">
<%= render FeatureComponent.new(...) %>
</div>
```
#### 5. Add Turbo If Needed
For dynamic updates without page reload:
```ruby
# Controller action
def create
@resource = Resource.new(resource_params)
respond_to do |format|
if @resource.save
format.turbo_stream
format.html { redirect_to resources_path }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
```
```erb
<%# app/views/resources/create.turbo_stream.erb %>
<%= turbo_stream.append "resources", @resource %>
<%= turbo_stream.prepend "flash", partial: "shared/flash", locals: { message: "Created!" } %>
```
### Testing Front-End Features
#### Test ViewComponents
```ruby
# test/components/button_component_test.rb
require "test_helper"
class ButtonComponentTest < ViewComponent::TestCase
def test_renders_primary_button
render_inline(ButtonComponent.new(label: "Click me", type: :primary))
assert_selector "button", text: "Click me"
assert_selector "button.bg-primary"
end
def test_renders_link_button
render_inline(ButtonComponent.new(label: "Link", url: "/path"))
assert_selector "a[href='/path']", text: "Link"
end
end
```
#### Test Stimulus Controllers
Use system tests with JavaScript enabled:
```ruby
# test/system/interactive_feature_test.rb
require "application_system_test_case"
class InteractiveFeatureTest < ApplicationSystemTestCase
test "opens modal on click" do
visit root_path
click_button "Open Modal"
assert_selector "[data-modal-target='panel']:not(.hidden)"
assert_text "Modal content"
end
test "closes modal on escape key" do
visit root_path
click_button "Open Modal"
page.send_keys :escape
assert_selector "[data-modal-target='panel'].hidden"
end
end
```
---
## ACCESSIBILITY CHECKLIST
Every front-end feature must meet these accessibility standards:
### Semantic HTML
- [ ] Use proper heading hierarchy (h1 → h2 → h3)
- [ ] Use `<button>` for actions, `<a>` for navigation
- [ ] Use `<label>` for all form inputs
- [ ] Use `<nav>`, `<main>`, `<aside>`, `<article>` landmarks
### ARIA Attributes
- [ ] `role` for interactive widgets (dialog, navigation, etc.)
- [ ] `aria-label` or `aria-labelledby` for all regions
- [ ] `aria-expanded` for collapsible elements
- [ ] `aria-controls` linking toggle buttons to targets
- [ ] `aria-hidden` for decorative elements
- [ ] `aria-live` for dynamic content announcements
- [ ] `aria-modal="true"` for modal dialogs
### Keyboard Navigation
- [ ] All interactive elements focusable with Tab
- [ ] Logical tab order (matches visual order)
- [ ] Visible focus indicators (focus:ring-2)
- [ ] Escape key closes modals/dropdowns
- [ ] Enter/Space activates buttons
- [ ] Arrow keys for list/menu navigation
### Focus Management
- [ ] Focus moves to modal when opened
- [ ] Focus returns to trigger when modal closes
- [ ] Focus trapped within modal (can't tab outside)
- [ ] First focusable element auto-focused
### Visual Design
- [ ] Sufficient color contrast (WCAG AA minimum)
- [ ] Don't rely on color alone for meaning
- [ ] Text resizable to 200% without breaking layout
- [ ] Touch targets at least 44×44px
### Forms
- [ ] All inputs have associated labels
- [ ] Required fields marked (visually and in code)
- [ ] Error messages associated with fields
- [ ] Validation feedback is clear and accessible
---
## COMMON PATTERNS QUICK REFERENCE
### Responsive Sidebar
**Files:**
- `app/javascript/controllers/responsive_sidebar_controller.js`
- See code above in Stimulus Controllers section
**Usage:**
```erb
<div data-controller="responsive-sidebar">
<aside data-responsive-sidebar-target="sidebar" class="fixed lg:static ...">
<div data-responsive-sidebar-target="overlay" class="lg:hidden ...">
<button data-responsive-sidebar-target="toggle" class="lg:hidden ...">
</div>
```
### Modal
**Files:**
- `app/javascript/controllers/modal_controller.js`
- `app/components/modal_component.rb`
**Usage:**
```erb
<%= render ModalComponent.new(id: "my-modal", title: "Title") do %>
Modal content
<% end %>
<button data-controller="modal" data-action="click->modal#open">Open</button>
```
### Table Filtering with Auto-Submit (MANDATORY PATTERN)
⚠️ **CRITICAL**: When building any page with a filtered table, you MUST use the filter components with auto-submit. There MUST be NO "Filter" or "Search" button — filters submit automatically on change. A `ButtonComponent` with label "Filter" or `button_type: :submit` inside a `FilterContainerComponent` is a **hard error**. ⚠️
**Files:**
- `app/components/filter_container_component.rb` + `.html.erb`
- `app/components/filter_text_field_component.rb` + `.html.erb`
- `app/components/filter_select_component.rb` + `.html.erb`
- `app/components/filter_date_field_component.rb` + `.html.erb`
- `app/components/filter_searchable_dropdown_component.rb` + `.html.erb`
- `app/javascript/controllers/auto_submit_controller.js`
**Reference Implementations:**
- Simple example: `app/views/fee_schedules/index.html.erb` (single select filter)
- Complex example: `app/views/settings/payroll/index.html.erb` (text search, select, period filters)
**Complete Pattern:**
```erb
<%= render(PageContainerComponent.new) do %>
<h1 class="text-2xl font-bold text-text mb-6">Resources</h1>
<%= turbo_frame_tag "resource_results" do %>
<%# Filters - auto-submit, NO filter button %>
<%= render(FilterContainerComponent.new(form_url: resources_path, turbo_frame: "resource_results")) do %>
<div class="flex flex-wrap gap-4 items-end">
<%# Text search - debounced auto-submit %>
<%= render FilterTextFieldComponent.new(
name: :search,
label: "Search",
value: params[:search],
placeholder: "Search by name...",
width: :flex
) %>
<%# Select dropdown - immediate auto-submit %>
<%= render FilterSelectComponent.new(
name: :status,
label: "Status",
options: options_for_select([["Active", "active"], ["Inactive", "inactive"]], params[:status]),
include_blank: "All Statuses",
width: :flex,
html_options: { data: { action: "change->auto-submit#submit" } }
) %>
<%# Date filter %>
<%= render FilterDateFieldComponent.new(
name: :start_date,
label: "Start Date",
value: params[:start_date],
width: :medium
) %>
<%# Clear filters link (shown when filters are active) %>
<% if @filters_active %>
<div class="pb-2">
<%= link_to "Clear Filters", resources_path,
class: "text-text-light hover:text-text transition-colors whitespace-nowrap" %>
</div>
<% end %>
</div>
<% end %>
<%# Results table %>
<%= render DataTableComponent.new(collection: @resources) do |table| %>
<% table.with_column(header: "Name") do |resource| %>
<%= resource.name %>
<% end %>
<% table.with_column(header: "Status") do |resource| %>
<%= render PillComponent.new(text: resource.status, color: resource.status_color) %>
<% end %>
<% end %>
<%# Pagination %>
<% if @resources.any? %>
<%= render(PagyPaginationComponent.new(pagy: @pagy)) %>
<% end %>
<% end %>
<% end %>
```
**Key Rules:**
1. **NO filter button** — `FilterContainerComponent` uses `auto-submit` controller, forms submit automatically. NEVER add a `ButtonComponent` with `button_type: :submit` or label "Filter" inside a `FilterContainerComponent`. This is a **hard error**.
2. **Turbo Frame is REQUIRED** — `turbo_frame_tag` MUST wrap both filters AND results. The `turbo_frame:` param on `FilterContainerComponent` MUST match the `turbo_frame_tag` id. Without this, auto-submit replaces the entire page instead of just the results.
3. **Select filters** MUST have `html_options: { data: { action: "change->auto-submit#submit" } }` for immediate submission on change
4. **Text filters** get debounced auto-submit automatically via the auto-submit controller (no extra action needed)
5. **Clear Filters** link shown conditionally when filters are active, wrapped in `<div class="pb-2">` for alignment
6. Filter components use `width: :flex` for responsive layouts inside `flex flex-wrap gap-4 items-end`
### Autosave Form
**Files:**
- `app/javascript/controllers/autosave_controller.js`
**Usage:**
```erb
<div data-controller="autosave"
data-autosave-url-value="<%= autosave_path %>"
data-autosave-delay-value="2000">
<div data-autosave-target="status"></div>
<%= form_with ... %>
</div>
```
### Dynamic Form Updates (Turbo)
**Controller:**
```ruby
def update
@model.update(params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to @model }
end
end
```
**View:**
```erb
<%= turbo_stream.replace dom_id(@model), partial: "model", locals: { model: @model } %>
```
---
## EXECUTION STRATEGY
When the user asks you to build a front-end feature:
1. **Audit Existing Components FIRST (MANDATORY)**
- Before writing ANY view code, check what existing components cover the UI elements needed
- For buttons → use `ButtonComponent`
- For form inputs → use `FormInputComponent`
- For form submits → use `FormSubmitButtonComponent`
- For form errors → use `FormErrorsComponent`
- For tables → use `DataTableComponent` (with `card: true` for standalone card-wrapped tables). If you encounter existing raw `<table>` HTML, refactor it to use `DataTableComponent`
- For modals → use `ModalComponent`
- For drawers → use `DrawerComponent`
- For pills/badges → use `PillComponent`
- For pagination → use `PagyPaginationComponent`
- For page containers → use `PageContainerComponent`
- For flash messages → use `FlashComponent`
- For table filters → use `FilterContainerComponent` + `FilterTextFieldComponent` / `FilterSelectComponent` / `FilterDateFieldComponent` / `FilterSearchableDropdownComponent` with auto-submit (NO filter button)
- **ONLY create new components for UI elements not covered above**
2. **Understand Requirements**
- What is the user trying to accomplish?
- What interactions are needed?
- Mobile or desktop or both?
3. **Plan Component Architecture**
- Map each UI element to an existing component (see step 1)
- Identify NEW ViewComponents needed for any remaining view sections
- Every distinct UI section should be a component — views should be thin
- List Stimulus controllers needed
- Identify Turbo Frame/Stream opportunities
4. **Build in Order**
- Identify all view sections and plan a component for each one
- Use existing ViewComponents wherever they apply
- Create new ViewComponents for page-specific sections (cards, list items, form sections, stat blocks, etc.)
- Stimulus controllers for interactivity
- Turbo integration for dynamic updates
- The resulting view should be almost entirely `render` calls with minimal raw HTML glue
5. **Follow Styling Rules (CRITICAL)**
- ✅ Use ONLY Tailwind utility classes
- ✅ Use ONLY theme colors from `app/assets/tailwind/application.css`
- ❌ NEVER create custom CSS files
- ❌ NEVER use `<style>` tags or `style=""` attributes
- ❌ NEVER use arbitrary color values or default Tailwind colors
6. **Follow Patterns**
- Use existing components from codebase (MANDATORY - not optional)
- Match established naming conventions
- Maintain consistency with existing styles
7. **Ensure Accessibility**
- Add ARIA attributes
- Implement keyboard navigation
- Test focus management
- Use semantic HTML
8. **Self-Review Before Finishing**
- Scan all view/template code for raw `<button>`, `<input>`, `<select>`, `<textarea>`, `<table>` tags — replace with components
- **Radio button groups MUST use `FormRadioGroupComponent`** — verify no hand-written `<input type="radio">` groups exist in views. Individual inline radios may use `FormRadioButtonComponent`.
- Any raw `<table>` must be refactored to `DataTableComponent` (use `card: true` if it was wrapped in a card div, `hover: true` if rows had hover effects)
- **Table row actions MUST use icon-only buttons with hover bubble effects** — never use text-label `ButtonComponent` for table actions. Use `pencil-square` for edit, `trash` for delete, `eye` for view, `arrow-path` for restore. Include `title` tooltip and `aria-label`.
- **Filter forms MUST NOT have a submit button** — verify no `ButtonComponent` with `button_type: :submit` or label "Filter"/"Search" exists inside a `FilterContainerComponent`. Filters must auto-submit via `change->auto-submit#submit` on selects, with results wrapped in a `turbo_frame_tag`.
- Check if any view has more than ~10 lines of raw HTML — extract to a component
- Check if any view has conditional logic producing HTML — extract to a component
- Check if any view has loops rendering items — extract the item to a component
- Verify views are thin (mostly `render` calls)
- Verify NO custom CSS was used
- Verify component renders correctly
- Verify responsive design works on mobile
- Verify accessibility features function
---
**Remember**: Views should be THIN — almost entirely `render` calls composing ViewComponents. ALL meaningful markup belongs in components for testability. ALWAYS use existing ViewComponents (ButtonComponent, FormInputComponent, FormSubmitButtonComponent, FormErrorsComponent, FormRadioGroupComponent, DataTableComponent, ModalComponent, DrawerComponent, PillComponent, PagyPaginationComponent, PageContainerComponent, FlashComponent, FilterContainerComponent, FilterTextFieldComponent, FilterSelectComponent, FilterDateFieldComponent, FilterSearchableDropdownComponent) before writing ANY raw HTML. When building filtered table pages, ALWAYS use FilterContainerComponent with auto-submit — NO filter buttons. When no existing component fits, create a new one — don't leave raw HTML in views. Build progressively with Tailwind (theme colors only), Stimulus for interactivity, Turbo for dynamic updates. NEVER use custom CSS.
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

