Slide 1

Slide 1 text

<%= link_to root_path, class: "group whitespace <%= tag.span(render("icons/home"), class: "text <%= tag.span("Dashboard", class: "text <% end %> <%= link_to contacts_path, class: "group whitespace <%= tag.span(render("icons/users"), class: "text <%= tag.span("Contacts", class: "text <%= tag.span(current_account.contacts, class: "text <% end %> <%= link_to companies_path, class: "group whitespace <%= tag.span(render("icons/building <%= tag.span("Companies", class: "text <%= tag.span(current_account.companies, class: "text <% end %> <%= link_to tasks_path, class: "group whitespace <%= tag.span(render("icons/pencil <%= tag.span("Tasks", class: "text <%= tag.span(current_account.tasks, class: "text <% end %> <% if current_account.plan <%= link_to reports_path, class: "group whitespace <%= tag.span(render("icons/chart <%= tag.span("Reports", class: "text <% end %> <% else %> <%= tag.span data: { controller: "tooltip", tooltip_content_value: "Reports are only available on the Pro plan." }, class: "group whitespace <%= tag.span(render("icons/chart <%= tag.span("Reports", class: "text <% end %> <% end %> <% if current_user.admin? %> <%= tag.span data: { controller: "toggle", toggle_toggle_class: "hidden" }, class: "relative" do %> <%= tag.button(class: "h <%= tag.span(render("icons/cog-6-tooth"), class: "text <%= tag.span("Settings", class: "text <%= tag.span(render("icons/chevron <% end %> <%= tag.div(class: "hidden z-40 absolute mt-1 w-60 rounded <%= link_to "Collaborators", settings_collaborators_path, class: "block w <%= link_to "Notifications", settings_notifications_path, class: "block w <% end %> <% end %> <% end %> <% end %> <% if current_account.enabled_feature?(:search) %> <%= tag.div(class: "flex <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w <% end %> <% end %> Refactoring Volatile Views into Cohesive Components

Slide 2

Slide 2 text

Jeremy Smith @jeremysmithco HYBRD One-person web studio IndieRails Podcast co-host Blue Ridge Ruby Previous organizer

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

No content

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

No content

Slide 7

Slide 7 text

I want to talk about views.

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

No content

Slide 10

Slide 10 text

No content

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

No content

Slide 13

Slide 13 text

No content

Slide 14

Slide 14 text

No content

Slide 15

Slide 15 text

No content

Slide 16

Slide 16 text

Frontend Backend Dif fi culty Easy 🥱 Hard 🤔

Slide 17

Slide 17 text

“Volatile?”

Slide 18

Slide 18 text

Churn Frequency of change

Slide 19

Slide 19 text

Complexity Variation More options or variants for an attribute Proliferation More instances throughout the system Accumulation More attributes or responsibilities

Slide 20

Slide 20 text

Dimensions of View Complexity Browsers/Clients Viewports Device Features Theming/Whitelabeling Dark/Light Mode Accessibility Internationalization SEO/Open Graph Authorization Entitlements Feature Flags Framework/Version Changes Execution Context Testing Affordances Design Systems/Tokens

Slide 21

Slide 21 text

🥲 <%= tag.nav(class: "flex justify - between space - x-2 items - start border - b-2 border - gray-400") do %> <%= tag.div(class: "flex - mb-0.5 space - x-2") do %> <%= link_to root_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400"}" do %> <%= tag.span(render("icons/home"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Dashboard", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> <%= link_to contacts_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(contacts_path) ? "border - b - red-400" : "border - b - gray-400"}" do % <%= tag.span(render("icons/users"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Contacts", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(contacts_path)}") %> <%= tag.span(current_account.contacts, class: "text - xs leading - none p-1 rounded - md bg - gray-50 text - gray-600") %> <% end %> <%= link_to companies_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(companies_path) ? "border - b - red-400" : "border - b - gray-400"}" do <%= tag.span(render("icons/building - office"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Companies", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(companies_path)}") %> <%= tag.span(current_account.companies, class: "text - xs leading - none p-1 rounded - md bg - gray-50 text - gray-600") %> <% end %> <%= link_to tasks_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-400"}" do %> <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Tasks", class: "text - gray-600 group - hover:text - gray-700 # { "font - semibold" if current_page?(tasks_path)}") %> <%= tag.span(current_account.tasks, class: "text - xs leading - none p-1 rounded - md # { current_account.tasks > 15 ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600"}") %> <% end %> <% if current_account.plan = = "pro" %> <%= link_to reports_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(reports_path) ? "border - b - red-400" : "border - b - gray-400"}" do % <%= tag.span(render("icons/chart - bar"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Reports", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(reports_path)}") %> <% end %> <% else %> <%= tag.span data: { controller: "tooltip", tooltip_content_value: "Reports are only available on the Pro plan." }, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-100 border - b-2 border - <%= tag.span(render("icons/chart - bar"), class: "text - gray-300") %> <%= tag.span("Reports", class: "text - gray-400") %> <% end %> <% end %> <% if current_user.admin? %> <%= tag.span data: { controller: "toggle", toggle_toggle_class: "hidden" }, class: "relative" do %> <%= tag.button(class: "h - full group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(settings_path) ? "border - b - red-400" : "border - b - gray-400"}", data <%= tag.span(render("icons/cog-6-tooth"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Settings", class: "text - gray-600 group - hover:text - gray-700 # { "font - semibold" if current_page?(settings_path)}") %> <%= tag.span(render("icons/chevron - down"), class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= tag.div(class: "hidden z-40 absolute mt-1 w-60 rounded - md border-2 border - gray-300 shadow - lg py-1 bg - gray-200 divide - y divide - gray-200", data: { toggle_target: "toggleable" }) do %> <%= link_to "Collaborators", settings_collaborators_path, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 hover:bg - gray-300" %> <%= link_to "Notifications", settings_notifications_path, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 hover:bg - gray-300" %> <% end %> <% end %> <% end %> <% end %> <% if current_account.enabled_feature?(:search) %> <%= tag.div(class: "flex - mb-0.5 space - x-2") do %> <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w - full text - sm border-2 border - gray-300 bg - gray-50 rounded - md shadow - inner placeholder:text - gray-400" %> <% end %> <% end %> <% end %> <% end %>

Slide 22

Slide 22 text

Enter: View Components

Slide 23

Slide 23 text

No content

Slide 24

Slide 24 text

No content

Slide 25

Slide 25 text

Types of Components Layout Used for composing and positioning Model-Speci fi c Coupled to domain models Utility General-purpose, common UI patterns

Slide 26

Slide 26 text

No content

Slide 27

Slide 27 text

No content

Slide 28

Slide 28 text

No content

Slide 29

Slide 29 text

class AlertComponent < ApplicationComponent CLASSES = { notice: { background: "bg - emerald-200", icon: "text - emerald-700", text: "text - emerald-800" }, info: { background: "bg - cyan-200", icon: "text - cyan-700", text: "text - cyan-800" }, warning: { background: "bg - yellow-200", icon: "text - yellow-700", text: "text - yellow-800" }, alert: { background: "bg - rose-200", icon: "text - rose-700", text: "text - rose-800" } }.freeze renders_one :message def initialize(type:, title: nil, icon: nil) @type, @title, @icon = type, title, icon end private attr_reader :type, :title, :icon def color(element) CLASSES.dig(type, element) end def wrapper_classes class_names(color(:background), "flex items - center space - x-4 p-4 mb-4 rounded - md") end …

Slide 30

Slide 30 text

<%= tag.div(class: wrapper_classes) do %> <%= tag.div(render("icons/ #{ icon}"), class: icon_classes) if icon.present? %> <%= tag.div do %> <%= tag.h3(title, class: title_classes) if title.present? %> <%= tag.p(message, class: message_classes) %> <% end %> <% end %>

Slide 31

Slide 31 text

<%= render AlertComponent.new(type: :notice) .with_message_content("Contact address successfully updated.") %> <%= render AlertComponent.new(type: :info, icon: "cake") .with_message_content("John’s birthday is tomorrow. Don’t forget to send him a message!") %> <%= render AlertComponent.new(type: :alert, title: "Danger Zone") do |c| %> <%= c.with_message do %> Please be careful in the section below, as these actions are not reversible strong>! <% end %> <% end %> <%= render AlertComponent.new(type: :warning, icon: "information - circle", title: "Contact Limit Reached") do |c| %> <%= c.with_message do %> You’ve reached the contact limit for your plan. Please <%= link_to "upgrade", root_path, class: "underline" %> to add more contacts. <% end %> <% end %>

Slide 32

Slide 32 text

No content

Slide 33

Slide 33 text

No content

Slide 34

Slide 34 text

class PageLayouts :: BasicComponent < ApplicationComponent renders_one :action renders_one :body def initialize(title:, parent_title: nil, parent_link: nil) @title = title @parent = Parent.new(parent_title, parent_link) if parent_title.present? end private attr_reader :title, :parent class Parent def initialize(title, link) @title = title @link = link end attr_reader :title, :link end end

Slide 35

Slide 35 text

<%= tag.div(class: "bg - white p-6 rounded - md mb-10") do %> <%= tag.div(class: "flex justify - between items - end pb-4 border - b-2 border - b - gray-200 mb-6") do %> <%= tag.div do %> <%= tag.h3(class: "flex items - center text - lg leading - none font - semibold h-8") do %> <% if parent.present? %> <%= link_to parent.title, parent.link, class: "text - cyan-500" %> <%= tag.span(render("icons/divider"), class: "text - gray-300") %> <% end %> <%= tag.span(title) %> <% end %> <% end %> <%= tag.div(action, class: "space - x-2") %> <% end %> <%= body %> <% end %>

Slide 36

Slide 36 text

<%= render PageLayouts :: BasicComponent.new(title: "Companies") do |c| %> <%= c.with_action do %> <%= link_to "New", new_company_path, class: "block bg - cyan-500 hover:bg - cyan-700 px-3 py-1 text - white <% end %> <%= c.with_body do %> Name th> Size th> Website th> tr> thead> <% @companies.each do |company| %> <%= link_to company.name, company_path(company), class: "text - cyan-50 … tr> <% end %> tbody> table> <% end %> <% end %>

Slide 37

Slide 37 text

No content

Slide 38

Slide 38 text

class ContactCardComponent < ApplicationComponent with_collection_parameter :contact def initialize(contact:) @contact = contact end private attr_reader :contact class AvatarComponent < ApplicationComponent def initialize(contact:) @contact = contact end def call if contact_avatar? image_tag(contact_avatar, class: image_classes, alt: contact.full_name) else tag.div(tag.div(contact.first_initial, class: fallback_classes), class: wrapper_classes) end end private

Slide 39

Slide 39 text

private attr_reader :contact def contact_avatar? contact.avatar.attached? && contact.avatar.variable? end def contact_avatar contact.avatar.variant(resize_to_fit: [200, 200]) end def image_classes "w-20 h-20 rounded - full" end def wrapper_classes "w-20 h-20 rounded - full bg - cyan-700 flex justify - center items - center" end def fallback_classes "text-5xl font - semibold text - cyan-100 text - center" end end end

Slide 40

Slide 40 text

<%= link_to contact_path(contact), class: "flex flex - col block bg - white shadow rounded - md p-4 divide - y divide - gray-200" do %> <%= tag.div(class: "flex justify - between space - x-2") do %> <%= tag.div do %> <%= tag.div(class: "flex items - baseline space - x-2") do %> <%= tag.span(contact.full_name, class: "text - xl font - semibold text - gray-600") %> <%= tag.span(contact.company.name, class: "text - gray-400") %> <% end %> <% if contact.location.present? %> <%= tag.div(class: "flex space - x-2 items - center mt-2") do %> <%= tag.span(render("icons/map - pin"), class: "text - gray-400") %> <%= tag.div(contact.location, class: "text - sm text - gray-500") %> <% end %> <% end %> <% if contact.email.present? %> <%= tag.div(class: "flex space - x-2 items - center mt-2") do %> <%= tag.span(render("icons/envelope"), class: "text - gray-400") %> <%= tag.div(contact.email, class: "text - sm text - cyan-500") %> <% end %> <% end %> <% if contact.phone.present? %> <%= tag.div(class: "flex space - x-2 items - center mt-2") do %> <%= tag.span(render("icons/phone"), class: "text - gray-400") %> <%= tag.div(contact.phone, class: "text - sm text - gray-500") %> <% end %> <% end %> <% end %> <%= tag.div(render(AvatarComponent.new(contact: contact))) %> <% end %> <% if contact.social? %> <%= tag.div(class: "flex space - x-3 items - center mt-4 pt-4") do %> <% if contact.linkedin.present? %> <%= tag.span(render("icons/linkedin"), class: "text - gray-400") %> <% end %> <% if contact.github.present? %> <%= tag.span(render("icons/github"), class: "text - gray-400") %> <% end %> <% if contact.twitter.present? %> <%= tag.span(render("icons/twitter"), class: "text - gray-400") %> <% end %> <% end %> <% end %> <% end %>

Slide 41

Slide 41 text

<%= tag.div(class: "flex flex - col space - y-4 max - w - xl") do %> <%= render ContactCardComponent.with_collection(@contacts) %> <% end %>

Slide 42

Slide 42 text

The Tab Nav: A Feature Story

Slide 43

Slide 43 text

No content

Slide 44

Slide 44 text

<%= tag.nav(class: "flex space - x-2 …") do %> <%= link_to "Dashboard", root_path, class: "whitespace - nowrap flex …" %> <%= link_to "Contacts", contacts_path, class: "whitespace - nowrap flex …" %> <%= link_to "Companies", companies_path, class: "whitespace - nowrap flex …" %> <%= link_to "Tasks", tasks_path, class: "whitespace - nowrap flex …" %> <% end %>

Slide 45

Slide 45 text

No content

Slide 46

Slide 46 text

No content

Slide 47

Slide 47 text

<%= tag.nav(class: "flex space - x-2 …") do %> <%= link_to "Dashboard", root_path, class: "whitespace - nowrap flex …" %> <%= link_to "Contacts", contacts_path, class: "whitespace - nowrap flex …" %> <%= link_to "Companies", companies_path, class: "whitespace - nowrap flex …" %> <%= link_to "Tasks", tasks_path, class: "whitespace - nowrap flex …" %> <% end %>

Slide 48

Slide 48 text

<%= tag.nav(class: "flex space - x-2 …") do %> <%= link_to root_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/home"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Dashboard", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to contacts_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/users"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Contacts", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to companies_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/building - office"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Companies", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to tasks_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Tasks", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <% end %>

Slide 49

Slide 49 text

No content

Slide 50

Slide 50 text

No content

Slide 51

Slide 51 text

<%= tag.nav(class: "flex space - x-2 …") do %> <%= link_to root_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/home"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Dashboard", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to contacts_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/users"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Contacts", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to companies_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/building - office"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Companies", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= link_to tasks_path, class: "group whitespace - nowrap …" do %> <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Tasks", class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <% end %>

Slide 52

Slide 52 text

<%= tag.nav(class: "flex border - b-2 …") do %> <%= tag.div(class: "flex - mb-0.5 …") do %> <%= link_to root_path, class: "… #{ current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400" <%= tag.span(render("icons/home"), class: "text - gray-400 …") %> <%= tag.span("Dashboard", class: "… #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> <%= link_to contacts_path, class: "… #{ current_page?(contacts_path) ? "border - b - red-400" : "border - b - <%= tag.span(render("icons/users"), class: "text - gray-400 …") %> <%= tag.span("Contacts", class: "… #{ "font - semibold" if current_page?(contacts_path)}") %> <% end %> <%= link_to companies_path, class: "… #{ current_page?(companies_path) ? "border - b - red-400" : "border - - <%= tag.span(render("icons/building - office"), class: "text - gray-400 …") %> <%= tag.span("Companies", class: "… #{ "font - semibold" if current_page?(companies_path)}") %> <% end %> <%= link_to tasks_path, class: "… #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-4 <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 …") %> <%= tag.span("Tasks", class: "… #{ "font - semibold" if current_page?(tasks_path)}") %> <% end %> <% end %> <% end %>

Slide 53

Slide 53 text

No content

Slide 54

Slide 54 text

No content

Slide 55

Slide 55 text

<%= tag.nav(class: "flex border - b-2 …") do %> <%= tag.div(class: "flex - mb-0.5 …") do %> <%= link_to root_path, class: "… #{ current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400" <%= tag.span(render("icons/home"), class: "text - gray-400 …") %> <%= tag.span("Dashboard", class: "… #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> <%= link_to contacts_path, class: "… #{ current_page?(contacts_path) ? "border - b - red-400" : "border - b - <%= tag.span(render("icons/users"), class: "text - gray-400 …") %> <%= tag.span("Contacts", class: "… #{ "font - semibold" if current_page?(contacts_path)}") %> <% end %> <%= link_to companies_path, class: "… #{ current_page?(companies_path) ? "border - b - red-400" : "border - - <%= tag.span(render("icons/building - office"), class: "text - gray-400 …") %> <%= tag.span("Companies", class: "… #{ "font - semibold" if current_page?(companies_path)}") %> <% end %> <%= link_to tasks_path, class: "… #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-4 <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 …") %> <%= tag.span("Tasks", class: "… #{ "font - semibold" if current_page?(tasks_path)}") %> <% end %> <% end %> <% end %>

Slide 56

Slide 56 text

<%= tag.nav(class: "flex border - b-2 …") do %> <%= tag.div(class: "flex - mb-0.5 …") do %> <%= link_to root_path, class: "… #{ current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400" <%= tag.span(render("icons/home"), class: "text - gray-400 …") %> <%= tag.span("Dashboard", class: "… #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> <%= link_to contacts_path, class: "… #{ current_page?(contacts_path) ? "border - b - red-400" : "border - b - <%= tag.span(render("icons/users"), class: "text - gray-400 …") %> <%= tag.span("Contacts", class: "… #{ "font - semibold" if current_page?(contacts_path)}") %> <%= tag.span(current_account.contacts, class: "text - xs leading - none …") %> <% end %> … <%= link_to tasks_path, class: "… #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-4 <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 …") %> <%= tag.span("Tasks", class: "… #{ "font - semibold" if current_page?(tasks_path)}") %> <%= tag.span(current_account.tasks, class: "text - xs leading - none … #{ current_account.tasks > 15 ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600"}") %> <% end %> <% end %> <% end %>

Slide 57

Slide 57 text

No content

Slide 58

Slide 58 text

No content

Slide 59

Slide 59 text

<%= tag.nav(class: "flex border - b-2 …") do %> <%= tag.div(class: "flex - mb-0.5 …") do %> … <%= link_to tasks_path, class: "… #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-4 <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 …") %> <%= tag.span("Tasks", class: "… #{ "font - semibold" if current_page?(tasks_path)}") %> <%= tag.span(current_account.tasks, class: "text - xs leading - none … #{ current_account.tasks > 15 ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600"}") %> <% end %> <% end %> <% end %>

Slide 60

Slide 60 text

<%= tag.nav(class: "flex border - b-2 …") do %> <%= tag.div(class: "flex - mb-0.5 …") do %> … <%= link_to tasks_path, class: "… #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-4 <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 …") %> <%= tag.span("Tasks", class: "… #{ "font - semibold" if current_page?(tasks_path)}") %> <%= tag.span(current_account.tasks, class: "text - xs leading - none … #{ current_account.tasks > 15 ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600"}") %> <% end %> <% if current_account.plan == "pro" %> <%= link_to reports_path, class: "… #{ current_page?(reports_path) ? "border - b - red-400" : "border - b - <%= tag.span(render("icons/chart - bar"), class: "text - gray-400 …") %> <%= tag.span("Reports", class: "… #{ "font - semibold" if current_page?(reports_path)}") %> <% end %> <% else %> <%= tag.span data: { controller: "tooltip", tooltip_content_value: "Reports are only available on t <%= tag.span(render("icons/chart - bar"), class: "text - gray-300") %> <%= tag.span("Reports", class: "text - gray-400") %> <% end %> <% end %> <% end %> <% end %>

Slide 61

Slide 61 text

No content

Slide 62

Slide 62 text

No content

Slide 63

Slide 63 text

<%= tag.nav(class: "flex border - b-2 …") do %> <%= tag.div(class: "flex - mb-0.5 …") do %> … <% if current_account.plan == "pro" %> … <% end %> <% end %> <% end %>

Slide 64

Slide 64 text

<%= tag.nav(class: "flex border - b-2 …") do %> <%= tag.div(class: "flex - mb-0.5 …") do %> … <% if current_account.plan == "pro" %> … <% end %> <% if current_user.admin? %> <%= tag.span data: { controller: "toggle", toggle_toggle_class: "hidden" }, class: "relative" do %> <%= tag.button(class: "h - full … #{ current_page?(settings_path) ? "border - b - red-400" : "border - b - g <%= tag.span(render("icons/cog-6-tooth"), class: "text - gray-400 …") %> <%= tag.span("Settings", class: "… #{ "font - semibold" if current_page?(settings_path)}") %> <%= tag.span(render("icons/chevron - down"), class: "…") %> <% end %> <%= tag.div(class: "hidden z-40 …", data: { toggle_target: "toggleable" }) do %> <%= link_to "Collaborators", settings_collaborators_path, class: "block w - full …" %> <%= link_to "Notifications", settings_notifications_path, class: "block w - full …" %> <% end %> <% end %> <% end %> <% end %> <% end %>

Slide 65

Slide 65 text

No content

Slide 66

Slide 66 text

No content

Slide 67

Slide 67 text

<%= tag.nav(class: "flex border - b-2 …") do %> <%= tag.div(class: "flex - mb-0.5 …") do %> <%= link_to root_path, class: "… #{ current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400" <%= tag.span(render("icons/home"), class: "text - gray-400 …") %> <%= tag.span("Dashboard", class: "… #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> … <% if current_user.admin? %> … <% end %> <% end %> <% end %>

Slide 68

Slide 68 text

<%= tag.nav(class: "flex justify - between …") do %> <%= tag.div(class: "flex - mb-0.5 …") do %> <%= link_to root_path, class: "… #{ current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400" <%= tag.span(render("icons/home"), class: "text - gray-400 …") %> <%= tag.span("Dashboard", class: "… #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> … <% if current_user.admin? %> … <% end %> <% end %> <% if current_account.enabled_feature?(:search) %> <%= tag.div(class: "flex - mb-0.5 space - x-2") do %> <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w - full text - sm <% end %> <% end %> <% end %> <% end %>

Slide 69

Slide 69 text

No content

Slide 70

Slide 70 text

<%= tag.nav(class: "flex justify - between space - x-2 items - start border - b-2 border - gray-400") do %> <%= tag.div(class: "flex - mb-0.5 space - x-2") do %> <%= link_to root_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(tab_path) ? "border - b - red-400" : "border - b - gray-400"}" do %> <%= tag.span(render("icons/home"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Dashboard", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(tab_path)}") %> <% end %> <%= link_to contacts_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(contacts_path) ? "border - b - red-400" : "border - b - gray-400"}" do % <%= tag.span(render("icons/users"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Contacts", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(contacts_path)}") %> <%= tag.span(current_account.contacts, class: "text - xs leading - none p-1 rounded - md bg - gray-50 text - gray-600") %> <% end %> <%= link_to companies_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(companies_path) ? "border - b - red-400" : "border - b - gray-400"}" do <%= tag.span(render("icons/building - office"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Companies", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(companies_path)}") %> <%= tag.span(current_account.companies, class: "text - xs leading - none p-1 rounded - md bg - gray-50 text - gray-600") %> <% end %> <%= link_to tasks_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(tasks_path) ? "border - b - red-400" : "border - b - gray-400"}" do %> <%= tag.span(render("icons/pencil - square"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Tasks", class: "text - gray-600 group - hover:text - gray-700 # { "font - semibold" if current_page?(tasks_path)}") %> <%= tag.span(current_account.tasks, class: "text - xs leading - none p-1 rounded - md # { current_account.tasks > 15 ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600"}") %> <% end %> <% if current_account.plan = = "pro" %> <%= link_to reports_path, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 # { current_page?(reports_path) ? "border - b - red-400" : "border - b - gray-400"}" do % <%= tag.span(render("icons/chart - bar"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Reports", class: "text - gray-600 group - hover:text - gray-700 #{ "font - semibold" if current_page?(reports_path)}") %> <% end %> <% else %> <%= tag.span data: { controller: "tooltip", tooltip_content_value: "Reports are only available on the Pro plan." }, class: "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-100 border - b-2 border - <%= tag.span(render("icons/chart - bar"), class: "text - gray-300") %> <%= tag.span("Reports", class: "text - gray-400") %> <% end %> <% end %> <% if current_user.admin? %> <%= tag.span data: { controller: "toggle", toggle_toggle_class: "hidden" }, class: "relative" do %> <%= tag.button(class: "h - full group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 bg - gray-300 hover:bg - gray-400 border - b-2 #{ current_page?(settings_path) ? "border - b - red-400" : "border - b - gray-400"}", data <%= tag.span(render("icons/cog-6-tooth"), class: "text - gray-400 group - hover:text - gray-500") %> <%= tag.span("Settings", class: "text - gray-600 group - hover:text - gray-700 # { "font - semibold" if current_page?(settings_path)}") %> <%= tag.span(render("icons/chevron - down"), class: "text - gray-600 group - hover:text - gray-700") %> <% end %> <%= tag.div(class: "hidden z-40 absolute mt-1 w-60 rounded - md border-2 border - gray-300 shadow - lg py-1 bg - gray-200 divide - y divide - gray-200", data: { toggle_target: "toggleable" }) do %> <%= link_to "Collaborators", settings_collaborators_path, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 hover:bg - gray-300" %> <%= link_to "Notifications", settings_notifications_path, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 hover:bg - gray-300" %> <% end %> <% end %> <% end %> <% end %> <% if current_account.enabled_feature?(:search) %> <%= tag.div(class: "flex - mb-0.5 space - x-2") do %> <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w - full text - sm border-2 border - gray-300 bg - gray-50 rounded - md shadow - inner placeholder:text - gray-400" %> <% end %> <% end %> <% end %> <% end %>

Slide 71

Slide 71 text

Variation Tab type, selected state, authorization, etc. Proliferation Team wants to use this in another section Accumulation Icon, counter, dropdown, non-tabs, etc. Churn Changed seven times, may not be done

Slide 72

Slide 72 text

No content

Slide 73

Slide 73 text

class TabNav :: BarComponent < ApplicationComponent renders_many :tabs renders_one :extra private def nav_classes "flex justify - between space - x-2 items - start border - b-2 border - gray-400" end def section_classes "flex - mb-0.5 space - x-2" end end

Slide 74

Slide 74 text

<%= tag.nav(class: nav_classes) do %> <%= tag.div(class: section_classes) do %> <% tabs.each do |tab| %> <%= tab %> <% end %> <% end %> <% if extra? %> <%= tag.div(class: section_classes) do %> <%= extra %> <% end %> <% end %> <% end %>

Slide 75

Slide 75 text

No content

Slide 76

Slide 76 text

class TabNav :: BarComponent < ApplicationComponent renders_many :tabs, types: { link: TabNav :: LinkTabComponent, disabled: TabNav :: DisabledTabComponent, dropdown: TabNav :: DropdownTabComponent } renders_one :extra private def nav_classes "flex justify - between space - x-2 items - start border - b-2 border - gray-400" end def section_classes "flex - mb-0.5 space - x-2" end end

Slide 77

Slide 77 text

class TabNav :: BaseTabComponent < ApplicationComponent private attr_reader :text, :icon, :selected def text_classes class_names("text - gray-600 group - hover:text - gray-700", "font - semibold": selected) end def icon_svg render(“icons/ #{ icon}") end def icon_classes "text - gray-400 group - hover:text - gray-500" end def tab_classes(*args) class_names(tab_base, * args) end … end

Slide 78

Slide 78 text

class TabNav :: LinkTabComponent < TabNav :: BaseTabComponent def initialize(link:, text:, icon: nil, selected: false, counter: nil, threshold: nil) @link, @text, @icon, @selected, @counter, @threshold = link, text, icon, selected, counter, threshold end private attr_reader :link, :counter, :threshold def tab_classes super(tab_enabled, " #{ tab_selected}": selected, " #{ tab_unselected}": !selected) end def counter_classes class_names("text - xs leading - none p-1 rounded - md", threshold_classes) end def threshold_classes exceeds_threshold? ? "bg - red-50 text - red-600" : "bg - gray-50 text - gray-600" end def exceeds_threshold? return false if counter.blank? || threshold.blank? counter > threshold end end

Slide 79

Slide 79 text

<%= link_to link, class: tab_classes do %> <%= tag.span(icon_svg, class: icon_classes) if icon.present? %> <%= tag.span(text, class: text_classes) %> <%= tag.span(counter, class: counter_classes) if counter.present? %> <% end %>

Slide 80

Slide 80 text

class TabNav :: DisabledTabComponent < TabNav :: BaseTabComponent def initialize(text:, icon: nil, tooltip: nil) @text, @icon, @tooltip = text, icon, tooltip end private attr_reader :tooltip def text_classes "text - gray-400" end def icon_classes "text - gray-300" end def tab_classes super(" #{ tab_disabled}": true, " #{ tab_unselected}": true) end def tab_disabled "bg - gray-100 cursor - not - allowed" end end

Slide 81

Slide 81 text

<%= tag.span data: { controller: "tooltip", tooltip_content_value: tooltip }, class: tab_classes do %> <%= tag.span(icon_svg, class: icon_classes) if icon.present? %> <%= tag.span(text, class: text_classes) %> <% end %>

Slide 82

Slide 82 text

class TabNav :: DropdownTabComponent < TabNav :: BaseTabComponent renders_many :items, "DropdownItemComponent" def initialize(text:, icon: nil, selected: false) @text, @icon, @selected = text, icon, selected end … class DropdownItemComponent < ApplicationComponent def initialize(text:, link:) @text, @link = text, link end def call link_to text, link, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 ho end private attr_reader :text, :link end end

Slide 83

Slide 83 text

<%= tag.span data: { controller: "toggle", toggle_toggle_class: hidden_class }, class: wrapper_classes do <%= tag.button(class: tab_classes, data: { action: "click -> toggle#toggle click@window -> toggle#hide page <%= tag.span(icon_svg, class: icon_classes) if icon.present? %> <%= tag.span(text, class: text_classes) %> <%= tag.span(caret_svg, class: caret_classes) %> <% end %> <%= tag.div(class: menu_classes, data: { toggle_target: "toggleable" }) do %> <% items.each do |item| %> <%= item %> <% end %> <% end %> <% end %>

Slide 84

Slide 84 text

<%= render TabNav : : BarComponent.new do |c| %> <% c.with_tab_link(text: "Dashboard", link: root_path, selected: current_page?(tab_path), icon: "home") %> <% c.with_tab_link(text: "Contacts", link: contacts_path, selected: current_page?(contacts_path), icon: "users <% c.with_tab_link(text: "Companies", link: companies_path, selected: current_page?(companies_path), icon: "bu <% c.with_tab_link(text: "Tasks", link: tasks_path, selected: current_page?(tasks_path), icon: "pencil - square" <% if current_account.plan == "pro" %> <% c.with_tab_link(text: "Reports", link: tasks_path, selected: current_page?(reports_path), icon: "chart - ba <% else %> <% c.with_tab_disabled(text: "Reports", icon: "chart - bar", tooltip: "Reports are only available on the Pro p <% end %> <% if current_user.admin? %> <% c.with_tab_dropdown(text: "Settings", selected: current_page?(settings_path), icon: "cog-6-tooth") do |d| <% d.with_item(text: "Collaborators", link: settings_collaborators_path) %> <% d.with_item(text: "Notifications", link: settings_notifications_path) %> <% end %> <% end %> <% if current_account.enabled_feature?(:search) %> <% c.with_extra do %> <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w - full text - sm border <% end %> <% end %> <% end %> <% end %>

Slide 85

Slide 85 text

“Can’t you just use partials and helpers?”

Slide 86

Slide 86 text

class TabNav :: DropdownTabComponent < TabNav :: BaseTabComponent renders_many :items, "DropdownItemComponent" def initialize(text:, icon: nil, selected: false) @text, @icon, @selected = text, icon, selected end … class DropdownItemComponent < ApplicationComponent def initialize(text:, link:) @text, @link = text, link end def call link_to text, link, class: "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 ho end private attr_reader :text, :link end end

Slide 87

Slide 87 text

<%= tag.span data: { controller: "toggle", toggle_toggle_class: hidden_class }, class: wrapper_classes do <%= tag.button(class: tab_classes, data: { action: "click -> toggle#toggle click@window -> toggle#hide page <%= tag.span(icon_svg, class: icon_classes) if icon.present? %> <%= tag.span(text, class: text_classes) %> <%= tag.span(caret_svg, class: caret_classes) %> <% end %> <%= tag.div(class: menu_classes, data: { toggle_target: "toggleable" }) do %> <% items.each do |item| %> <%= item %> <% end %> <% end %> <% end %>

Slide 88

Slide 88 text

app/views/tab_nav/_dropdown_tab.html.erb <%# locals: (text:, icon: nil, selected: false) -%> <%= tag.span data: { controller: "toggle", toggle_toggle_class: tab_nav_dropdown_hidden_class }, class: t <%= tag.button(class: tab_nav_tab_classes(dropdown: true, selected: selected), data: { action: "click -> <%= tag.span(tab_nav_icon_svg(icon), class: tab_nav_icon_classes) if icon.present? %> <%= tag.span(text, class: tab_nav_text_classes(selected: selected)) %> <%= tag.span(tab_nav_dropdown_caret_svg, class: tab_nav_dropdown_caret_classes) %> <% end %> <%= tag.div(class: tab_nav_dropdown_menu_classes, data: { toggle_target: "toggleable" }) do %> <%= yield %> <% end %> <% end %>

Slide 89

Slide 89 text

<%= render "tab_nav/bar", tabs: capture { %> <%= render "tab_nav/link_tab", text: "Dashboard", link: root_path, selected: current_page?(tab_path), i … <% }, extra: capture { %> <% if current_account.enabled_feature?(:search) %> <%= form_tag searches_path, method: :get do %> <%= text_field_tag :query, "", placeholder: "Search", autocomplete: "off", class: "w - full text - sm b <% end %> <% end %> <% } %>

Slide 90

Slide 90 text

module TabNavHelper def tab_nav_text_classes(selected: false, disabled: false) class_names( "text - gray-600 group - hover:text - gray-700": !disabled, "font - semibold": selected, "text - gray-400": disabled ) end def tab_nav_icon_svg(icon) render("icons/ #{ icon}") end def tab_nav_icon_classes(disabled: false) disabled ? "text - gray-300" : "text - gray-400 group - hover:text - gray-500" end def tab_nav_tab_classes(selected: false, disabled: false, dropdown: false) class_names( "group whitespace - nowrap flex items - center space - x-1 rounded - md rounded - b - none leading - none py-3 px-3 border - b-2", "h - full": dropdown, "bg - gray-300 hover:bg - gray-400": !disabled, "bg - gray-100 cursor - not - allowed": disabled, "border - b - red-400": selected, "border - b - gray-400": !selected ) end def tab_nav_dropdown_wrapper_classes "relative" end def tab_nav_dropdown_caret_svg render("icons/chevron - down") end def tab_nav_dropdown_caret_classes "text - gray-600 group - hover:text - gray-700" end def tab_nav_dropdown_menu_classes class_names( tab_nav_dropdown_hidden_class, "z-40 absolute mt-1 w-60 rounded - md border-2 border - gray-300 shadow - lg py-1 bg - gray-200 divide - y divide - gray-200" ) end def tab_nav_dropdown_hidden_class "hidden" end def tab_nav_dropdown_item_classes "block w - full text - left py-1.5 px-3 text - gray-500 hover:text - gray-600 hover:bg - gray-300" end end

Slide 91

Slide 91 text

Summary 1. Implement designs in traditional templates 2. Watch for volatility (high churn & complexity) 3. Extract view components and regain stability

Slide 92

Slide 92 text

require "test_helper" class TabNav :: BarComponentTest < ViewComponent :: TestCase def test_render_component render_inline(TabNav :: BarComponent.new) do |c| c.with_tab_link(text: "Home", link: "/home") c.with_tab_link(text: "Reports", link: "/reports", icon: "chart - bar") c.with_extra { "Something else. strong>".html_safe } end assert_selector("nav") assert_selector("a", count: 2) assert_link("Home", href: "/home") assert_link("Reports", href: "/reports") assert_selector("svg", count: 1) assert_selector("strong", text: "Something else.") end end

Slide 93

Slide 93 text

No content

Slide 94

Slide 94 text

No content

Slide 95

Slide 95 text

No content

Slide 96

Slide 96 text

No content

Slide 97

Slide 97 text

Other Libraries Phlex 
 https://github.com/phlex-ruby/phlex Nice Partials 
 https://github.com/bullet-train-co/ nice_partials Komponent 
 https://github.com/komposable/komponent Matestack 
 https://github.com/matestack Cells 
 https://github.com/trailblazer/cells Jumpstart Pro JumpstartComponent

Slide 98

Slide 98 text

Resources https://viewcomponent.org/ https://github.com/viewcomponent/view_component https://github.com/palkan/view_component-contrib https://github.com/pantographe/view_component-form https://github.com/phlex-ruby/phlex https://github.com/bullet-train-co/nice_partials https://github.com/trailblazer/cells https://github.com/komposable/komponent https://github.com/matestack https://evilmartians.com/chronicles/viewcomponent-in-the-wild-building-modern-rails-frontends https://evilmartians.com/chronicles/viewcomponent-in-the-wild-supercharging-your-components https://evilmartians.com/chronicles/viewcomponent-in-the-wild-embracing-tailwindcss-classes-and-html-attributes https://railsnotes.xyz/blog/rails-viewcomponent-tips https://dev.to/nejremeslnici/from-partials-to-viewcomponents-writing-reusable-front-end-code-in-rails-1c9o https://www.honeybadger.io/blog/rails-viewcomponent/ https://thoughtbot.com/blog/hotwire-turbo-streaming-viewcomponents https://tips.rstankov.com/p/tips-for-using-viewcomponents-in https://viewcomponent.org/viewcomponents-at-github.html https://www.codewithjason.com/the-problem-that-viewcomponent-solves-for-me/ https://evilmartians.com/chronicles/evil-front-part-1#block-mentality https://bigmedium.com/ideas/design-system-pace-layers-slow-fast.html https://github.com/whitesmith/rubycritic/blob/main/docs/core-metrics.md#churn-and-complexity https://www.railsinside.com/tutorials/487-how-to-score-your-rails-apps-complexity-before-refactoring.html https://www.youtube.com/watch?v=ar8RMbDPoSY Slides: https://speakerdeck.com/rstankov/component-driven-ui-with-viewcomponent-gem https://www.youtube.com/watch?v=sIxvxp7E0xg Slides: https://speakerdeck.com/palkan/railsconf-2021-frontendless-rails-frontend https://www.youtube.com/watch?v=YVYRus_2KZM https://www.youtube.com/watch?v=QoetqsBCsbE