1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 23:45:21 +02:00

Finalize button, link component API

This commit is contained in:
Zach Gollwitzer 2025-04-27 11:46:11 -04:00
parent 17d073e58d
commit 3f5522add3
31 changed files with 230 additions and 116 deletions

View file

@ -1,20 +1,11 @@
# frozen_string_literal: true
class ButtonComponent < ViewComponent::Base
include ButtonStylable
class ButtonComponent < ButtonishComponent
attr_reader :confirm
attr_reader :text, :icon, :icon_position
def initialize(text: nil, variant: "primary", size: "md", icon: nil, icon_position: "left", full_width: false, rounded: false, confirm: nil, **opts)
@text = text
@variant = variant.underscore.to_sym
@size = size.to_sym
@icon = icon
@icon_position = icon_position
@full_width = full_width
@rounded = rounded
def initialize(confirm: nil, **opts)
super(**opts)
@confirm = confirm
@opts = opts
end
def container(&block)
@ -26,12 +17,6 @@ class ButtonComponent < ViewComponent::Base
end
private
attr_reader :variant, :size, :rounded, :full_width, :confirm, :opts
def href
opts[:href]
end
def merged_opts
merged_opts = opts.dup || {}
extra_classes = merged_opts.delete(:class)

View file

@ -58,21 +58,28 @@ module ButtonStylable
}
}.freeze
def container_classes
attr_reader :variant, :size, :extra_classes
def initialize(opts = {})
@variant = opts.delete(:variant) || :primary
@size = opts.delete(:size) || :md
@extra_classes = opts.delete(:class)
end
def container_classes(override_classes = nil)
class_names(
"inline-flex items-center gap-1 font-medium whitespace-nowrap",
full_width ? "w-full justify-center" : "",
icon_only? ? SIZES.dig(size, :icon_padding_classes) : SIZES.dig(size, :padding_classes),
rounded ? "rounded-full" : SIZES.dig(size, :radius_classes),
SIZES.dig(size, :text_classes),
VARIANTS.dig(variant, :container_classes)
icon_only? ? size_data.dig(:icon_padding_classes) : size_data.dig(:padding_classes),
size_data.dig(:radius_classes),
size_data.dig(:text_classes),
variant_data.dig(:container_classes)
)
end
def icon_classes
class_names(
SIZES.dig(size, :icon_classes),
VARIANTS.dig(variant, :icon_classes)
size_data.dig(:icon_classes),
variant_data.dig(:icon_classes)
)
end
@ -81,19 +88,15 @@ module ButtonStylable
end
private
def full_width
@full_width ||= false
def variant_data
self.class::VARIANTS.dig(variant.to_s.underscore.to_sym)
end
def rounded
@rounded ||= false
def size_data
self.class::SIZES.dig(size.to_sym)
end
def variant
@variant ||= :primary
end
def size
@size ||= :md
def known_override_classes
[ "hidden", "rounded-full", "w-full", "justify-center", "justify-start" ]
end
end

View file

@ -0,0 +1,147 @@
class ButtonishComponent < ViewComponent::Base
VARIANTS = {
primary: {
container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
icon_classes: "fg-inverse"
},
secondary: {
container_classes: "text-secondary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
icon_classes: "fg-primary"
},
destructive: {
container_classes: "text-inverse bg-red-500 theme-dark:bg-red-400 hover:bg-red-600 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600",
icon_classes: "fg-white"
},
outline: {
container_classes: "text-primary border border-secondary bg-transparent hover:bg-surface-hover",
icon_classes: "fg-gray"
},
outline_destructive: {
container_classes: "text-destructive border border-secondary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
icon_classes: "fg-gray"
},
ghost: {
container_classes: "text-primary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
icon_classes: "fg-gray"
},
icon: {
container_classes: "hover:bg-gray-100 theme-dark:hover:bg-gray-700",
icon_classes: "fg-gray"
},
icon_inverse: {
container_classes: "bg-inverse hover:bg-inverse-hover",
icon_classes: "fg-inverse"
}
}.freeze
SIZES = {
sm: {
container_classes: "px-2 py-1",
icon_container_classes: "inline-flex items-center justify-center w-8 h-8",
radius_classes: "rounded-md",
text_classes: "text-sm",
icon_classes: "w-4 h-4"
},
md: {
container_classes: "px-3 py-2",
icon_container_classes: "inline-flex items-center justify-center w-9 h-9",
radius_classes: "rounded-lg",
text_classes: "text-sm",
icon_classes: "w-5 h-5"
},
lg: {
container_classes: "px-4 py-3",
icon_container_classes: "inline-flex items-center justify-center w-10 h-10",
radius_classes: "rounded-xl",
text_classes: "text-base",
icon_classes: "w-6 h-6"
}
}.freeze
attr_reader :variant, :size, :href, :icon, :icon_position, :text, :full_width, :extra_classes, :opts
def initialize(variant: :primary, size: :md, href: nil, text: nil, icon: nil, icon_position: :left, full_width: false, **opts)
@variant = variant.to_s.underscore.to_sym
@size = size.to_sym
@href = href
@icon = icon
@icon_position = icon_position.to_sym
@text = text
@full_width = full_width
@extra_classes = opts.delete(:class)
@opts = opts
end
def call
raise NotImplementedError, "ButtonishComponent is an abstract class and cannot be instantiated directly."
end
def container_classes(override_classes = nil)
class_names(
"font-medium whitespace-nowrap",
merged_base_classes,
full_width ? "w-full justify-center" : nil,
container_size_classes,
size_data.dig(:text_classes),
variant_data.dig(:container_classes)
)
end
def container_size_classes
icon_only? ? size_data.dig(:icon_container_classes) : size_data.dig(:container_classes)
end
def icon_classes
class_names(
size_data.dig(:icon_classes),
variant_data.dig(:icon_classes)
)
end
def icon_only?
variant.in?([ :icon, :icon_inverse ])
end
private
def variant_data
self.class::VARIANTS.dig(variant)
end
def size_data
self.class::SIZES.dig(size)
end
# Make sure that user can override common classes like `hidden`
def merged_base_classes
base_display_classes = "inline-flex items-center gap-1"
base_radius_classes = size_data.dig(:radius_classes)
extra_classes_list = (extra_classes || "").split
has_display_override = extra_classes_list.any? { |c| permitted_display_override_classes.include?(c) }
has_radius_override = extra_classes_list.any? { |c| permitted_radius_override_classes.include?(c) }
base_classes = []
unless has_display_override
base_classes << base_display_classes
end
unless has_radius_override
base_classes << base_radius_classes
end
class_names(
base_classes,
extra_classes
)
end
def permitted_radius_override_classes
[ "rounded-full" ]
end
def permitted_display_override_classes
[ "hidden", "flex" ]
end
end

View file

@ -1,41 +1,24 @@
class LinkComponent < ViewComponent::Base
include ButtonStylable
class LinkComponent < ButtonishComponent
attr_reader :frame
attr_reader :href, :variant, :size, :text, :icon, :icon_position, :open_in, :opts
VARIANTS = VARIANTS.merge(
VARIANTS = VARIANTS.reverse_merge(
default: {
container_classes: "",
text_classes: "text-primary",
icon_classes: "fg-gray"
},
link_destructive: {
container_classes: "",
text_classes: "text-destructive",
icon_classes: "fg-destructive"
}
).freeze
def initialize(href:, variant: "default", size: "md", text: nil, icon: nil, icon_position: "left", rounded: false, full_width: false, open_in: nil, **opts)
@href = href
@variant = variant.underscore.to_sym
@size = size.underscore.to_sym
@text = text
@icon = icon
@icon_position = icon_position
@rounded = rounded
@full_width = full_width
@open_in = open_in
@opts = opts
def initialize(frame: nil, **opts)
super(**opts)
@frame = frame
end
def merged_opts
merged_opts = opts.dup || {}
extra_classes = merged_opts.delete(:class)
data = merged_opts.delete(:data) || {}
if open_in
data = data.merge(turbo_frame: open_in)
if frame
data = data.merge(turbo_frame: frame)
end
merged_opts.merge(
@ -43,4 +26,9 @@ class LinkComponent < ViewComponent::Base
data: data
)
end
private
def container_size_classes
super unless variant == :default
end
end

View file

@ -56,14 +56,13 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
@template.render(
ButtonComponent.new(
text: value,
full_width: true,
data: { turbo_submits_with: "Submitting..." }
data: { turbo_submits_with: "Submitting..." },
full_width: true
)
)
end
private
def build_styled_field(label, field, options, remove_padding_right: false)
if options[:inline]
label + field

View file

@ -47,7 +47,7 @@
variant: "ghost",
href: new_account_path(step: "method_select", classification: "asset"),
icon: "plus",
open_in: "modal",
frame: :modal,
full_width: true,
class: "justify-start"
) %>
@ -65,7 +65,7 @@
variant: "ghost",
href: new_account_path(step: "method_select", classification: "liability"),
icon: "plus",
open_in: "modal",
frame: :modal,
full_width: true,
class: "justify-start"
) %>
@ -84,7 +84,7 @@
full_width: true,
href: new_account_path(step: "method_select"),
icon: "plus",
open_in: "modal",
frame: :modal,
class: "justify-start"
) %>

View file

@ -46,7 +46,7 @@
icon: "plus",
full_width: true,
variant: "ghost",
open_in: "modal",
frame: :modal,
class: "justify-start"
) %>
</details>

View file

@ -8,7 +8,8 @@
method: :post,
variant: "outline",
disabled: Current.family.syncing?,
icon: "refresh-cw"
icon: "refresh-cw",
class: ""
) %>
<%= render LinkComponent.new(
@ -16,7 +17,7 @@
href: new_account_path(return_to: accounts_path),
variant: "primary",
icon: "plus",
open_in: "modal"
frame: :modal
) %>
</div>
</div>

View file

@ -16,7 +16,7 @@
variant: "outline",
icon: "plus",
href: new_category_path,
open_in: "modal",
frame: :modal,
) %>
</div>
</div>

View file

@ -142,7 +142,7 @@
start_date: @budget.start_date,
end_date: @budget.end_date
}),
open_in: :_top
frame: :_top
) %>
<% else %>
<p class="text-secondary text-sm mb-4">

View file

@ -43,7 +43,7 @@
text: month_name,
href: budget_path(param_key),
full_width: true,
open_in: :_top
frame: :_top
) %>
<% else %>
<span class="px-3 py-2 text-subdued rounded-md"><%= month_name %></span>

View file

@ -17,7 +17,7 @@
variant: "primary",
icon: "plus",
href: new_category_path,
open_in: :modal
frame: :modal
) %>
</div>
</header>
@ -48,7 +48,7 @@
variant: "outline",
icon: "plus",
href: new_category_path,
open_in: :modal
frame: :modal
) %>
</div>
</div>

View file

@ -10,7 +10,7 @@
</div>
<%= render MenuComponent.new(icon_vertical: true) do |menu| %>
<% menu.with_item(variant: "link", text: "Edit chat", href: edit_chat_path(chat), icon: "pencil", open_in: dom_id(chat, "title")) %>
<% menu.with_item(variant: "link", text: "Edit chat", href: edit_chat_path(chat), icon: "pencil", frame: dom_id(chat, "title")) %>
<% menu.with_item(
variant: "button",
text: "Delete chat",

View file

@ -31,6 +31,6 @@
text: "Edit account details",
variant: "ghost",
href: edit_credit_card_path(account),
open_in: :modal
frame: :modal
) %>
</div>

View file

@ -5,7 +5,7 @@
text: "New merchant",
variant: "primary",
href: new_family_merchant_path,
open_in: :modal
frame: :modal
) %>
</header>

View file

@ -21,7 +21,7 @@
text: "Next step",
variant: "primary",
href: import_confirm_path(@import),
open_in: :_top,
frame: :_top,
class: "w-full md:w-auto"
) %>
</div>

View file

@ -15,7 +15,7 @@
text: "Create account",
variant: "primary",
href: new_account_path(return_to: import_confirm_path(import)),
open_in: :modal
frame: :modal
) %>
</div>
</div>
@ -29,7 +29,7 @@
text: t(".create_account"),
variant: "primary",
href: new_account_path(return_to: import_confirm_path(import)),
open_in: :modal
frame: :modal
) %>
</div>
</div>

View file

@ -7,7 +7,7 @@
variant: "primary",
href: new_import_path,
icon: "plus",
open_in: :modal
frame: :modal
) %>
</div>
</div>

View file

@ -6,7 +6,7 @@
href: new_import_path,
icon: "plus",
variant: "primary",
open_in: :modal
frame: :modal
) %>
</div>

View file

@ -49,6 +49,6 @@
text: "Edit loan details",
variant: "ghost",
href: edit_loan_path(account),
open_in: :modal
frame: :modal
) %>
</div>

View file

@ -1,19 +1,17 @@
<% content_for :page_header do %>
<div class="space-y-1 mb-6 flex justify-between">
<div class="space-y-1 mb-6 flex justify-between items-center">
<div class="space-y-1">
<h1 class="text-3xl font-medium text-primary">Welcome back, <%= Current.user.first_name %></h1>
<p class="text-gray-500">Here's what's happening with your finances</p>
</div>
<div class="md:hidden">
<%= render LinkComponent.new(
<%= render LinkComponent.new(
variant: "icon-inverse",
icon: "plus",
href: new_account_path(step: "method_select", classification: "asset"),
open_in: :modal,
rounded: true
frame: :modal,
class: "rounded-full md:hidden"
) %>
</div>
</div>
<% end %>

View file

@ -11,7 +11,7 @@
text: t(".new_account"),
href: new_account_path,
icon: "plus",
open_in: :modal
frame: :modal
) %>
</div>
</div>

View file

@ -33,6 +33,6 @@
text: "Edit account details",
href: edit_property_path(account),
variant: "ghost",
open_in: :modal
frame: :modal
) %>
</div>

View file

@ -15,7 +15,7 @@
<%= tag.div class:"flex gap-2 justify-end" do %>
<%= render ButtonComponent.new(text: "Dismiss", variant: "secondary") %>
<% rule_href = new_rule_path(resource_type: "transaction", action_type: "set_transaction_category", action_value: cta[:category_id]) %>
<%= render LinkComponent.new(text: "Create rule", variant: "primary", href: rule_href, open_in: :modal) %>
<%= render LinkComponent.new(text: "Create rule", variant: "primary", href: rule_href, frame: :modal) %>
<% end %>
<% end %>
<% end %>

View file

@ -19,7 +19,7 @@
variant: "primary",
href: new_rule_path(resource_type: "transaction"),
icon: "plus",
open_in: :modal
frame: :modal
) %>
</div>
</header>
@ -58,7 +58,7 @@
variant: "primary",
href: new_rule_path(resource_type: "transaction"),
icon: "plus",
open_in: :modal
frame: :modal
) %>
</div>
</div>

View file

@ -6,7 +6,7 @@
variant: "primary",
href: new_tag_path,
icon: "plus",
open_in: :modal
frame: :modal
) %>
</header>

View file

@ -20,29 +20,26 @@
icon: "download",
variant: "outline",
href: new_import_path,
open_in: :modal,
frame: :modal,
) %>
</div>
<div class="hidden md:flex">
<%= render LinkComponent.new(
<%= render LinkComponent.new(
text: "New transaction",
icon: "plus",
variant: "primary",
href: new_transaction_path,
open_in: :modal,
frame: :modal,
class: "hidden md:inline-flex"
) %>
</div>
<div class="md:hidden">
<%= render LinkComponent.new(
<%= render LinkComponent.new(
icon: "plus",
variant: "icon-inverse",
href: new_transaction_path,
open_in: :modal,
rounded: true
frame: :modal,
class: "rounded-full md:hidden"
) %>
</div>
</div>
</div>
</header>

View file

@ -130,7 +130,7 @@
icon: "arrow-left-right",
variant: "outline",
href: new_transaction_transfer_match_path(@entry),
open_in: :modal
frame: :modal
) %>
</div>

View file

@ -37,6 +37,6 @@
text: "Edit account details",
variant: "ghost",
href: edit_vehicle_path(account),
open_in: :modal
frame: :modal
) %>
</div>

View file

@ -3,15 +3,13 @@ class ButtonComponentPreview < ViewComponent::Preview
# @param size select {{ ButtonComponent::SIZES.keys }}
# @param disabled toggle
# @param icon select ["plus", "circle"]
# @param rounded toggle
def default(variant: "primary", size: "md", disabled: false, icon: "plus", rounded: false)
def default(variant: "primary", size: "md", disabled: false, icon: "plus")
render ButtonComponent.new(
text: "Sample button",
variant: variant,
size: size,
disabled: disabled,
icon: icon,
rounded: rounded,
data: { menu_target: "button" }
)
end

View file

@ -11,8 +11,7 @@ class LinkComponentPreview < ViewComponent::Preview
# @param icon select ["", "plus", "arrow-right"]
# @param icon_position select ["left", "right"]
# @param full_width toggle
# @param rounded toggle
def default(variant: "default", size: "md", icon: "plus", icon_position: "left", full_width: false, rounded: false)
def default(variant: "default", size: "md", icon: "plus", icon_position: "left", full_width: false)
render LinkComponent.new(
href: "#",
text: "Preview link",
@ -20,8 +19,7 @@ class LinkComponentPreview < ViewComponent::Preview
size: size,
icon: icon,
icon_position: icon_position,
full_width: full_width,
rounded: rounded
full_width: full_width
)
end
end