Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/assets/tailwind/shipwright/engine.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

/* Background colors */
--color-background-primary: #fdfdfd;
--color-background-secondary: #f3f3f3;

/* Typography — font loading is the consuming app's responsibility */
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
Expand Down
20 changes: 20 additions & 0 deletions app/components/shipwright/banner_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<%= content_tag :div, class: container_classes, **html_attrs do %>
<% if header_present? %>
<div class="flex items-center gap-1 pr-6 shrink-0" data-banner-header>
<%= icon if icon? %>
<% if header.present? %>
<p class="text-sm leading-4 text-text-primary"><%= header %></p>
<% end %>
</div>
<% end %>

<p class="flex-1 text-sm leading-5 text-text-primary min-w-0">
<%= content %>
</p>

<% if link? %>
<div class="pl-4 shrink-0 self-stretch flex items-center">
<%= link %>
</div>
<% end %>
<% end %>
32 changes: 32 additions & 0 deletions app/components/shipwright/banner_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module Shipwright
class BannerComponent < BaseComponent
# Optional leading icon. Consumer-facing API:
# banner.with_icon(name: :info)
renders_one :icon, IconComponent

# Optional trailing link action.
# banner.with_link(href: "#") { "Link" }
renders_one :link, LinkComponent

# `class:` is a Ruby reserved word used as a keyword arg — requires
# binding.local_variable_get. Shipwright convention.
def initialize(header: nil, class: nil, **html_attrs)
@header = header
@class = binding.local_variable_get(:class)
@html_attrs = html_attrs
end

def header_present?
@header.present? || icon?
end

attr_reader :header, :html_attrs

def container_classes
classes(
"flex items-start gap-0 p-4 bg-background-secondary font-sans",
consumer: @class
)
end
end
end
1 change: 1 addition & 0 deletions app/components/shipwright/base_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class BaseComponent < ViewComponent::Base
text-inverse
text-disabled
background-primary
background-secondary
],
"radius" => %w[md]
}
Expand Down
55 changes: 55 additions & 0 deletions app/components/shipwright/icon_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
require_relative "icons"

module Shipwright
class IconComponent < BaseComponent
SIZES = {
sm: "w-4 h-4", # 16px
md: "w-6 h-6", # 24px — matches Feather native viewBox
lg: "w-8 h-8" # 32px
}.freeze

class << self
# Exposed for tests / discoverability. Returns symbol keys.
def icon_names
@icon_names ||= ICONS.keys.map(&:to_sym).freeze
end
end

# `class:` is a Ruby reserved word used as a keyword arg — requires
# binding.local_variable_get. Shipwright convention.
def initialize(name:, size: :md, class: nil, **html_attrs)
@name = name.to_s
@size = size
@class = binding.local_variable_get(:class)
@html_attrs = html_attrs
end

def call
paths = ICONS.fetch(@name) do
raise ArgumentError, "#{self.class}: unknown icon :#{@name}. Available: #{ICONS.keys.sort.join(', ')}"
end
size_classes = SIZES.fetch(@size) do |v|
raise ArgumentError, "#{self.class}: unknown size :#{v}. Valid: #{SIZES.keys.join(', ')}"
end

# Icons without an explicit aria-label are decorative and hidden from
# screen readers. When a consumer provides aria-label, treat the icon
# as meaningful and skip aria-hidden.
aria_label = @html_attrs[:"aria-label"] || @html_attrs[:aria_label]
accessibility_attrs = aria_label ? {} : { "aria-hidden": "true" }

content_tag :svg,
paths,
class: classes(size_classes, consumer: @class),
xmlns: "http://www.w3.org/2000/svg",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
**accessibility_attrs,
**@html_attrs
end
end
end
32 changes: 32 additions & 0 deletions app/components/shipwright/icons.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module Shipwright
# Feather Icons (https://feathericons.com) — MIT licensed, 24x24 viewBox,
# stroke-based design. Values are the inner SVG fragment; the wrapping
# <svg> with viewBox/stroke attributes is provided by IconComponent.
#
# To add an icon: copy its inner content from https://feathericons.com/
# and paste it below. Keep icon names in kebab-case matching Figma's
# icon name in the design file.
ICONS = {
"info" => %(<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>).html_safe,
"alert-circle" => %(<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>).html_safe,
"alert-triangle" => %(<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>).html_safe,
"check" => %(<polyline points="20 6 9 17 4 12"/>).html_safe,
"check-circle" => %(<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>).html_safe,
"x" => %(<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>).html_safe,
"x-circle" => %(<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>).html_safe,
"chevron-down" => %(<polyline points="6 9 12 15 18 9"/>).html_safe,
"chevron-up" => %(<polyline points="18 15 12 9 6 15"/>).html_safe,
"chevron-left" => %(<polyline points="15 18 9 12 15 6"/>).html_safe,
"chevron-right" => %(<polyline points="9 18 15 12 9 6"/>).html_safe,
"plus" => %(<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>).html_safe,
"minus" => %(<line x1="5" y1="12" x2="19" y2="12"/>).html_safe,
"search" => %(<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>).html_safe,
"menu" => %(<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>).html_safe,
"external-link" => %(<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>).html_safe,
"settings" => %(<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>).html_safe,
"arrow-right" => %(<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>).html_safe,
"arrow-left" => %(<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>).html_safe,
"help-circle" => %(<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>).html_safe,
"trash" => %(<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>).html_safe
}.freeze
end
59 changes: 59 additions & 0 deletions app/components/shipwright/link_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
module Shipwright
class LinkComponent < BaseComponent
# Decoration controls the text-decoration (underline). Maps to Figma's
# "Decoration" variant (Yes/No).
DECORATIONS = {
underline: "underline underline-offset-2",
none: "no-underline"
}.freeze

# Default state classes. Hover/active/focus states use Tailwind pseudo-class
# modifiers so browser events drive the visual changes, matching Figma's
# State variants (Default/Hover/Pressed/Focused).
ENABLED_CLASSES = [
"text-interactive-primary-default",
"hover:text-interactive-primary-hover",
"active:text-interactive-primary-pressed",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-interactive-primary-default focus-visible:ring-offset-2 focus-visible:ring-offset-background-primary",
"transition-colors"
].join(" ").freeze

# Disabled state — uses interactive-disable (matches Figma's Disabled
# variant which resolved to Interactive/Disable=#eaeaea).
DISABLED_CLASSES = "text-interactive-disable cursor-not-allowed".freeze

# `class:` is a Ruby reserved word used as a keyword arg — requires
# binding.local_variable_get. Shipwright convention.
def initialize(href: nil, decoration: :underline, disabled: false, class: nil, **html_attrs)
@href = href
@decoration = decoration
@disabled = disabled
@class = binding.local_variable_get(:class)
@html_attrs = html_attrs
end

def call
decoration_class = DECORATIONS.fetch(@decoration) do |v|
raise ArgumentError, "#{self.class}: unknown decoration :#{v}. Valid: #{DECORATIONS.keys.join(', ')}"
end

attrs = @html_attrs.dup
if @disabled
# Drop href so the link isn't navigable; signal state to AT.
attrs[:"aria-disabled"] = "true"
attrs[:tabindex] = "-1"
else
attrs[:href] = @href if @href
end

content_tag :a, content,
class: classes(
"inline text-sm font-sans font-normal leading-4",
decoration_class,
@disabled ? DISABLED_CLASSES : ENABLED_CLASSES,
consumer: @class
),
**attrs
end
end
end
32 changes: 32 additions & 0 deletions test/components/previews/shipwright/banner_component_preview.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module Shipwright
class BannerComponentPreview < Lookbook::Preview
# @label Default (icon + header + body + link)
def default
render_with_template
end

# @label Body only
def body_only
render(Shipwright::BannerComponent.new) do
"Place holder text for notifications. Enter text into this container."
end
end

# @label Icon + body
def icon_and_body
render_with_template
end

# @label Body + link (no icon)
def body_and_link
render_with_template
end

# @label Header + body (no icon)
def header_and_body
render(Shipwright::BannerComponent.new(header: "Alert")) do
"Place holder text for notifications."
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div style="padding: 2rem; width: 900px; max-width: 100%;">
<%= render Shipwright::BannerComponent.new do |banner| %>
<% banner.with_link(href: "#") { "Learn more" } %>
Place holder text for notifications. Enter text into this container.
<% end %>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div style="padding: 2rem; width: 900px; max-width: 100%;">
<%= render Shipwright::BannerComponent.new(header: "Alert") do |banner| %>
<% banner.with_icon(name: :info) %>
<% banner.with_link(href: "#") { "Link" } %>
Place holder text for notifications. Enter Text into this container.
<% end %>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<div style="padding: 2rem; display: flex; flex-direction: column; gap: 1rem; width: 900px; max-width: 100%;">
<%= render Shipwright::BannerComponent.new(header: "Info") do |banner| %>
<% banner.with_icon(name: :info) %>
Your session will expire in 5 minutes.
<% end %>

<%= render Shipwright::BannerComponent.new(header: "Warning") do |banner| %>
<% banner.with_icon(name: :"alert-triangle") %>
Some fields were missing before submission.
<% end %>

<%= render Shipwright::BannerComponent.new(header: "Success") do |banner| %>
<% banner.with_icon(name: :"check-circle") %>
Your changes were saved.
<% end %>
</div>
28 changes: 28 additions & 0 deletions test/components/previews/shipwright/icon_component_preview.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module Shipwright
class IconComponentPreview < Lookbook::Preview
# @label Default (info, md)
def default
render Shipwright::IconComponent.new(name: :info)
end

# @label All Icons
def gallery
render_with_template
end

# @label Sizes
def sizes
render_with_template
end

# @label Colored (via text-*)
def colored
render_with_template
end

# @label With aria-label (non-decorative)
def with_aria_label
render Shipwright::IconComponent.new(name: :info, "aria-label": "More information")
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div style="display: flex; gap: 1.5rem; align-items: center; padding: 2rem;">
<%= render Shipwright::IconComponent.new(name: :"alert-circle", class: "text-interactive-primary-default") %>
<%= render Shipwright::IconComponent.new(name: :"alert-triangle", class: "text-utility-negative-default") %>
<%= render Shipwright::IconComponent.new(name: :"check-circle", class: "text-interactive-primary-default") %>
<%= render Shipwright::IconComponent.new(name: :info, class: "text-interactive-primary-hover") %>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 1.5rem; padding: 2rem;">
<% Shipwright::IconComponent.icon_names.each do |name| %>
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.5rem;">
<%= render Shipwright::IconComponent.new(name: name) %>
<span style="font-size: 0.75rem; color: #555;"><%= name %></span>
</div>
<% end %>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div style="display: flex; gap: 1.5rem; align-items: center; padding: 2rem;">
<%= render Shipwright::IconComponent.new(name: :info, size: :sm) %>
<%= render Shipwright::IconComponent.new(name: :info, size: :md) %>
<%= render Shipwright::IconComponent.new(name: :info, size: :lg) %>
</div>
28 changes: 28 additions & 0 deletions test/components/previews/shipwright/link_component_preview.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module Shipwright
class LinkComponentPreview < Lookbook::Preview
# @label Default (underlined)
def default
render(Shipwright::LinkComponent.new(href: "#")) { "Link" }
end

# @label No decoration
def no_decoration
render(Shipwright::LinkComponent.new(href: "#", decoration: :none)) { "Link" }
end

# @label Disabled
def disabled
render(Shipwright::LinkComponent.new(href: "#", disabled: true)) { "Link" }
end

# @label All States
def all_states
render_with_template
end

# @label External Link
def external
render(Shipwright::LinkComponent.new(href: "https://example.com", target: "_blank", rel: "noopener")) { "External" }
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<div style="display: flex; flex-direction: column; gap: 0.75rem; padding: 2rem;">
<div>
<strong style="font-size: 0.75rem;">Decoration: underline</strong>
<div style="display: flex; gap: 1.5rem; align-items: center; margin-top: 0.25rem;">
<%= render(Shipwright::LinkComponent.new(href: "#")) { "Default" } %>
<%= render(Shipwright::LinkComponent.new(href: "#", disabled: true)) { "Disabled" } %>
</div>
</div>
<div>
<strong style="font-size: 0.75rem;">Decoration: none</strong>
<div style="display: flex; gap: 1.5rem; align-items: center; margin-top: 0.25rem;">
<%= render(Shipwright::LinkComponent.new(href: "#", decoration: :none)) { "Default" } %>
<%= render(Shipwright::LinkComponent.new(href: "#", decoration: :none, disabled: true)) { "Disabled" } %>
</div>
</div>
<div>
<strong style="font-size: 0.75rem;">Within paragraph text</strong>
<p style="max-width: 480px; margin-top: 0.25rem; font-size: 0.875rem;">
This is a sentence with <%= render(Shipwright::LinkComponent.new(href: "#")) { "an inline link" } %> that should wrap naturally and maintain the baseline of the surrounding text.
</p>
</div>
</div>
Loading