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
2 changes: 2 additions & 0 deletions app/assets/tailwind/shipwright/engine.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

/* Border colors */
--color-border-interactive: #14161c;
--color-border-primary: #eaeaea;

/* Utility colors (destructive, success, warning, info, etc.) */
--color-utility-negative-default: #b62e2e;
Expand All @@ -36,6 +37,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
9 changes: 9 additions & 0 deletions app/components/shipwright/accordion_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<%= content_tag :details, class: container_classes, open: open_attr, **html_attrs do %>
<summary class="<%= summary_classes %>">
<%= render Shipwright::IconComponent.new(name: :"chevron-down", size: :sm, class: chevron_classes) %>
<span class="flex-1"><%= title %></span>
</summary>
<div class="<%= body_classes %>">
<%= content %>
</div>
<% end %>
67 changes: 67 additions & 0 deletions app/components/shipwright/accordion_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
module Shipwright
# A single collapsible section. Uses native <details>/<summary> for
# toggle behavior, keyboard support, and accessibility — no JavaScript
# required. Stack multiple AccordionComponents to build a group.
#
# Usage:
# <%= render(Shipwright::AccordionComponent.new(title: "FAQ question")) do %>
# Answer content here.
# <% end %>
#
# <%# Initially open %>
# <%= render(Shipwright::AccordionComponent.new(title: "T", open: true)) { "..." } %>
class AccordionComponent < BaseComponent
# `class:` is a Ruby reserved word used as a keyword arg — requires
# binding.local_variable_get. Shipwright convention.
def initialize(title:, open: false, class: nil, **html_attrs)
@title = title
@open = open
@class = binding.local_variable_get(:class)
@html_attrs = html_attrs
end

attr_reader :title, :html_attrs

def open_attr
@open
end

def container_classes
classes(
"group block border border-border-primary rounded-md overflow-hidden font-sans",
# Height transition for open/close.
# Relies on interpolate-size + the ::details-content pseudo-element
# to animate from height 0 ↔ auto without JS measurement.
# Browsers without support (older Firefox) fall back to instant toggle.
"[interpolate-size:allow-keywords]",
"[&::details-content]:h-0 [&::details-content]:overflow-hidden",
"[&::details-content]:transition-[height,content-visibility] [&::details-content]:duration-200 [&::details-content]:ease-out",
"[&[open]::details-content]:h-auto",
consumer: @class
)
end

# Classes applied to <summary>. Hides the default marker cross-browser,
# adds padding, typography, and interactive state visuals.
def summary_classes
[
"flex items-center gap-3 px-4 py-3 cursor-pointer select-none",
"text-base leading-5 text-interactive-primary-default",
"transition-colors",
"hover:bg-background-secondary hover:text-interactive-primary-hover",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-interactive-primary-default focus-visible:ring-inset",
"list-none [&::-webkit-details-marker]:hidden"
].join(" ")
end

def body_classes
# pt-2 (8px / spacing-2xs) gives the body a small breathing gap below
# the summary's bottom padding. Matches Shipwright Pro's spacing scale.
"px-4 pb-3 pt-2 text-sm leading-5 text-text-primary"
end

def chevron_classes
"transition-transform duration-200 group-open:rotate-180"
end
end
end
2 changes: 2 additions & 0 deletions app/components/shipwright/base_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ class BaseComponent < ViewComponent::Base
interactive-secondary-default
interactive-disable
border-interactive
border-primary
utility-negative-default
text-primary
text-inverse
text-disabled
background-primary
background-secondary
],
"radius" => %w[md]
}
Expand Down
113 changes: 113 additions & 0 deletions app/components/shipwright/brand_icon_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
module Shipwright
# Renders a multi-color brand SVG (payment method, social platform, etc.)
# exported from Shipwright Pro's Figma source.
#
# Brand icons differ from Feather icons (rendered by IconComponent) in
# that they preserve the brand's own colors rather than inheriting via
# currentColor. The component loads SVGs from a directory, strips their
# intrinsic width/height, and applies a Tailwind height class — width
# auto-scales so non-square icons (e.g., Visa) keep their aspect ratio.
#
# Populate the SVG directory by running:
#
# FIGMA_ACCESS_TOKEN=xxx ruby script/export_brand_icons.rb
#
# Available names are discoverable via `BrandIconComponent.icon_names`.
class BrandIconComponent < BaseComponent
# Default SVG directory — overridable for tests via the asset_path class setting.
DEFAULT_ASSET_PATH = Pathname.new(__dir__).join("../../assets/images/shipwright/brand").expand_path.freeze

SIZES = {
sm: "h-4", # 16px
md: "h-6", # 24px
lg: "h-8" # 32px
}.freeze

class << self
attr_writer :asset_path

def asset_path
@asset_path ||= DEFAULT_ASSET_PATH
end

# Scan the asset directory and return a hash of name => inline SVG string.
# Memoized per-asset-path so tests can swap paths cleanly.
def icons
@icons ||= {}
@icons[asset_path.to_s] ||= begin
pattern = asset_path.join("*.svg")
Dir.glob(pattern).each_with_object({}) do |file, hash|
name = File.basename(file, ".svg")
hash[name] = File.read(file)
end.freeze
end
end

def icon_names
icons.keys.map(&:to_sym).sort.freeze
end

# Allow tests to reset the memoization.
def reset_cache!
@icons = nil
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
raw_svg = self.class.icons.fetch(@name) do
available = self.class.icon_names
hint = if available.empty?
" No brand icons loaded — run `ruby script/export_brand_icons.rb` with a FIGMA_ACCESS_TOKEN to populate app/assets/images/shipwright/brand/."
else
" Available: #{available.join(', ')}"
end
raise ArgumentError, "#{self.class}: unknown brand icon :#{@name}.#{hint}"
end

size_class = SIZES.fetch(@size) do |v|
raise ArgumentError, "#{self.class}: unknown size :#{v}. Valid: #{SIZES.keys.join(', ')}"
end

# Strip intrinsic dimensions so CSS controls size; w-auto preserves aspect ratio.
normalized = raw_svg
.sub(/\swidth="[^"]*"/, "")
.sub(/\sheight="[^"]*"/, "")

merged_class = classes("#{size_class} w-auto", consumer: @class)

# Inject / merge our class attribute into the root <svg>.
if normalized =~ /<svg([^>]*)\sclass="([^"]*)"/
normalized = normalized.sub(/<svg([^>]*)\sclass="([^"]*)"/) do
pre = Regexp.last_match(1)
existing = Regexp.last_match(2)
%(<svg#{pre} class="#{existing} #{merged_class}")
end
else
normalized = normalized.sub("<svg", %(<svg class="#{merged_class}"))
end

# aria-hidden by default unless consumer supplies aria-label.
aria_label = @html_attrs[:"aria-label"] || @html_attrs[:aria_label]
unless aria_label
normalized = normalized.sub("<svg", %(<svg aria-hidden="true"))
end

# Pass-through html attributes: append to the root <svg>.
attr_str = @html_attrs.map { |k, v| %(#{k.to_s.tr('_', '-')}="#{ERB::Util.html_escape(v)}") }.join(" ")
if !attr_str.empty?
normalized = normalized.sub("<svg", %(<svg #{attr_str}))
end

normalized.html_safe
end
end
end
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
Loading