1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 15:35:22 +02:00

Build menu component

This commit is contained in:
Zach Gollwitzer 2025-04-24 21:25:05 -04:00
parent 6af50928e6
commit fdbfa14779
12 changed files with 247 additions and 130 deletions

View file

@ -4,16 +4,19 @@ class ButtonComponent < ViewComponent::Base
VARIANTS = {
primary: {
bg: "bg-gray-900 theme-dark:bg-white hover:bg-gray-800 theme-dark:hover:bg-gray-50 disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
fg: "text-white theme-dark:text-gray-900"
text: "text-white theme-dark:text-gray-900",
icon: "fg-inverse"
},
secondary: {
bg: "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",
fg: "text-gray-900 theme-dark:text-white"
text: "text-gray-900 theme-dark:text-white",
icon: "fg-primary"
},
outline: {
bg: "bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
fg: "text-gray-900 theme-dark:text-white",
border: "border border-gray-900 theme-dark:border-white"
text: "text-gray-900 theme-dark:text-white",
border: "border border-secondary",
icon: "fg-gray"
},
outline_destructive: {
bg: "bg-transparent hover:bg-red-100 theme-dark:hover:bg-red-700",
@ -22,19 +25,23 @@ class ButtonComponent < ViewComponent::Base
},
ghost: {
bg: "bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
fg: "text-gray-900 theme-dark:text-white"
text: "text-secondary",
icon: "fg-gray"
},
link_color: {
bg: "bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
fg: "text-gray-900 theme-dark:text-white"
text: "text-primary",
icon: "fg-inverse"
},
link_gray: {
bg: "bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
fg: "text-gray-900 theme-dark:text-white"
text: "text-secondary",
icon: "fg-gray"
},
icon: {
bg: "bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700 rounded-lg",
fg: "fg-gray"
text: "text-secondary",
icon: "fg-gray"
}
}.freeze
@ -46,51 +53,55 @@ class ButtonComponent < ViewComponent::Base
icon: "w-4 h-4"
},
md: {
icon_container: "w-10 h-10",
icon_container: "w-9 h-9",
container: "px-3 py-2 rounded-lg",
text: "text-sm",
icon: "w-5 h-5"
},
lg: {
icon_container: "w-12 h-12",
icon_container: "w-10 h-10",
container: "px-4 py-3 rounded-xl",
text: "text-base",
icon: "w-6 h-6"
}
}
def initialize(text:, variant: "primary", size: "md", href: nil, leading_icon: nil, trailing_icon: nil, icon: nil, **options)
@text = text
@variant = variant.underscore.to_sym
@size = size.to_sym
@href = href
@leading_icon = leading_icon
@trailing_icon = trailing_icon
@icon = icon
def initialize(options = {})
@text = options.delete(:text)
@variant = (options.delete(:variant) || "primary").underscore.to_sym
@size = (options.delete(:size) || :md).to_sym
@href = options.delete(:href)
@method = options.delete(:method)
@leading_icon = options.delete(:leading_icon)
@trailing_icon = options.delete(:trailing_icon)
@icon = options.delete(:icon)
@full_width = options.delete(:full_width)
@left_align = options.delete(:left_align)
@extra_classes = options.delete(:class)
@options = options
end
def wrapper_tag(&block)
html_tag = @href ? "a" : "button"
if @href.present?
content_tag(html_tag, class: container_classes, href: @href, **@options, &block)
if @href && @method
button_to @href, class: container_classes, method: @method, **@options, &block
else
content_tag(html_tag, class: container_classes, **@options, &block)
html_tag = @href ? "a" : "button"
content_tag(html_tag, class: container_classes, href: @href, **@options, &block)
end
end
def text_classes
[
"font-medium",
size_meta[:text],
variant_meta[:fg]
variant_meta[:text]
].join(" ")
end
def icon_classes
[
size_meta[:icon],
variant_meta[:fg]
variant_meta[:icon]
].join(" ")
end
@ -101,10 +112,14 @@ class ButtonComponent < ViewComponent::Base
private
def container_classes
[
"inline-flex items-center justify-center gap-1",
"inline-flex items-center gap-1",
@full_width ? "w-full" : nil,
@left_align ? "justify-start" : "justify-center",
@variant == :icon ? size_meta[:icon_container] : size_meta[:container],
variant_meta[:bg]
].join(" ")
variant_meta[:bg],
variant_meta.dig(:border),
@extra_classes
].compact.join(" ")
end
def size_meta

View file

@ -11,6 +11,10 @@ class IconComponent < ViewComponent::Base
default: {
icon: "fg-gray",
container: "bg-transparent"
},
destructive: {
icon: "text-destructive",
container: "bg-transparent"
}
}
@ -45,12 +49,16 @@ class IconComponent < ViewComponent::Base
def container_classes
[
"flex justify-center items-center",
size_meta[:container],
show_padding? ? size_meta[:container] : "",
variant_meta[:container]
].join(" ")
end
private
def show_padding?
@variant != :default && @variant != :destructive
end
def variant_meta
VARIANTS[@variant]
end

View file

@ -0,0 +1,13 @@
<div data-controller="menu">
<% if @variant == :icon %>
<%= render ButtonComponent.new(variant: "icon", icon: "more-horizontal", data: { menu_target: "button" }) %>
<% elsif @variant == :button %>
<%= button %>
<% end %>
<div data-menu-target="content" class="hidden min-w-[200px] p-1 z-50 shadow-border-xs bg-container rounded-lg">
<% items.each do |item| %>
<%= item %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class MenuComponent < ViewComponent::Base
renders_one :button, ->(**options) do
options_with_target = options.merge(data: { menu_target: "button" })
ButtonComponent.new(**options_with_target)
end
renders_many :items, MenuItemComponent
VARIANTS = {
icon: {},
button: {}
}
def initialize(variant: "icon")
@variant = variant.to_sym
end
end

View file

@ -0,0 +1,50 @@
class MenuItemComponent < ViewComponent::Base
erb_template <<~ERB
<%= wrapper do %>
<%= render IconComponent.new(@icon, variant: destructive? ? "destructive" : "default") %>
<%= tag.span(@text, class: text_classes) %>
<% end %>
ERB
VARIANTS = {
link: {},
action: {}
}
def initialize(text:, href:, variant: "link", method: :post, icon: nil, data: {})
@text = text
@icon = icon
@href = href
@variant = variant.to_sym
@method = method
@data = data
end
def wrapper(&block)
case @variant
when :link
link_to @href, data: @data, class: container_classes, &block
when :action
button_to @href, method: @method, data: @data, class: container_classes, &block
end
end
def text_classes
[
"text-sm",
destructive? ? "text-destructive" : "text-primary"
].join(" ")
end
def destructive?
@method == :delete
end
private
def container_classes
[
"flex items-center gap-2 p-2 rounded-md w-full",
destructive? ? "hover:bg-red-tint-5 theme-dark:hover:bg-red-tint-10" : "hover:bg-container-hover"
].join(" ")
end
end

View file

@ -49,14 +49,14 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end
def submit(value = nil, options = {})
default_options = {
data: { turbo_submits_with: "Submitting..." },
class: "btn btn--primary w-full justify-center"
}
merged_options = default_options.merge(options)
# Rails superclass logic to extract the submit text
value, options = nil, value if value.is_a?(Hash)
super(value, merged_options)
value ||= submit_default_value
opts = options.dup
opts[:data] = { turbo_submits_with: "Submitting..." }.merge(opts[:data] || {})
@template.render(ButtonComponent.new(text: value, **opts))
end
private

View file

@ -42,12 +42,15 @@
</div>
<div data-tabs-target="tab" id="assets-tab">
<%= link_to new_account_path(step: "method_select", classification: "asset"),
class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1",
data: { turbo_frame: "modal" } do %>
<%= icon("plus") %>
<span>New asset</span>
<% end %>
<%= render ButtonComponent.new(
text: "New asset",
variant: "ghost",
full_width: true,
left_align: true,
href: new_account_path(step: "method_select", classification: "asset"),
leading_icon: "plus",
data: { turbo_frame: "modal" }
) %>
<div class="space-y-2">
<% family.balance_sheet.account_groups("asset").each do |group| %>
@ -57,12 +60,15 @@
</div>
<div data-tabs-target="tab" id="debts-tab" class="hidden">
<%= link_to new_account_path(step: "method_select", classification: "liability"),
class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1",
data: { turbo_frame: "modal" } do %>
<%= icon("plus") %>
<span>New debt</span>
<% end %>
<%= render ButtonComponent.new(
text: "New debt",
variant: "ghost",
full_width: true,
left_align: true,
href: new_account_path(step: "method_select", classification: "liability"),
leading_icon: "plus",
data: { turbo_frame: "modal" }
) %>
<div class="space-y-2">
<% family.balance_sheet.account_groups("liability").each do |group| %>
@ -72,12 +78,15 @@
</div>
<div data-tabs-target="tab" id="all-tab" class="hidden">
<%= link_to new_account_path(step: "method_select"),
class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1",
data: { turbo_frame: "modal" } do %>
<%= icon("plus") %>
<span>New account</span>
<% end %>
<%= render ButtonComponent.new(
text: "New account",
variant: "ghost",
full_width: true,
left_align: true,
href: new_account_path(step: "method_select"),
leading_icon: "plus",
data: { turbo_frame: "modal" }
) %>
<div class="space-y-2">
<% family.balance_sheet.account_groups.each do |group| %>

View file

@ -19,7 +19,7 @@
<div class="space-y-1">
<% account_group.accounts.each do |account| %>
<%= link_to account_path(account), class: "block flex items-center gap-2 btn btn--ghost", title: account.name do %>
<%= link_to account_path(account), class: "block flex items-center gap-2 px-3 py-2 hover:bg-surface-hover", title: account.name do %>
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
<div class="min-w-0 grow">
@ -40,10 +40,13 @@
<% end %>
</div>
<%= link_to new_polymorphic_path(account_group.key, step: "method_select"),
class: "flex items-center gap-3 btn btn--ghost text-secondary",
data: { turbo_frame: "modal" } do %>
<%= icon("plus") %>
<span>New <%= account_group.name.downcase.singularize %></span>
<% end %>
<%= render ButtonComponent.new(
href: new_polymorphic_path(account_group.key, step: "method_select"),
text: "New #{account_group.name.downcase.singularize}",
leading_icon: "plus",
left_align: true,
full_width: true,
variant: "ghost",
data: { turbo_frame: "modal" }
) %>
</details>

View file

@ -2,23 +2,22 @@
<h1 class="text-xl"><%= t(".accounts") %></h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<%= button_to sync_all_accounts_path,
disabled: Current.family.syncing?,
class: "md:btn md:btn--outline flex items-center justify-center gap-2 w-9 h-9 md:w-auto md:h-auto rounded-full md:rounded-lg",
title: t(".sync") do %>
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
<span class="hidden md:inline"><%= t(".sync") %></span>
<% end %>
<%= render ButtonComponent.new(
text: "Sync all",
href: sync_all_accounts_path,
method: :post,
variant: "outline",
disabled: Current.family.syncing?,
leading_icon: "refresh-cw"
) %>
<%= link_to new_account_path(return_to: accounts_path),
data: { turbo_frame: "modal" },
class: "btn btn--primary flex items-center justify-center gap-1 w-9 h-9 md:w-auto md:h-auto rounded-full md:rounded-lg" do %>
<div class="flex items-center justify-center w-5 h-5">
<%= lucide_icon("plus") %>
</div>
<p class="hidden md:block text-sm font-medium"><%= t(".new_account") %></p>
<% end %>
<%= render ButtonComponent.new(
text: "New account",
href: new_account_path(return_to: accounts_path),
variant: "primary",
leading_icon: "plus",
data: { turbo_frame: "modal" }
) %>
</div>
</div>
</header>

View file

@ -6,21 +6,23 @@
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
<% unless @account.plaid_account_id.present? %>
<div data-controller="menu" data-testid="activity-menu">
<button class="btn btn--secondary flex items-center gap-2" data-menu-target="button">
<%= lucide_icon("plus", class: "w-4 h-4") %>
<%= tag.span t(".new") %>
</button>
<%= render ButtonComponent.new(text: "New", variant: "secondary", leading_icon: "plus", data: { menu_target: "button" }) %>
<div data-menu-target="content" class="z-10 hidden bg-container rounded-lg border border-alpha-black-25 shadow-xs p-1">
<%= link_to new_valuation_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("circle-dollar-sign", class: "text-secondary w-5 h-5") %>
<%= tag.span t(".new_balance"), class: "text-sm" %>
<% end %>
<%= render ButtonComponent.new(
text: "New balance",
variant: "ghost",
full_width: true,
left_align: true,
leading_icon: "circle-dollar-sign",
href: new_valuation_path(account_id: @account.id),
data: { turbo_frame: :modal }) %>
<% unless @account.crypto? %>
<%= link_to @account.investment? ? new_trade_path(account_id: @account.id) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "btn btn--primary flex items-center justify-center gap-2 rounded-full md:rounded-lg w-9 h-9 md:w-auto md:h-auto" do %>
<span class="flex items-center justify-center">
<%= lucide_icon("credit-card", class: "text-secondary w-5 h-5") %>
</span>
<span class="flex items-center justify-center">
<%= lucide_icon("credit-card", class: "text-secondary w-5 h-5") %>
</span>
<%= tag.span t(".new_transaction"), class: "text-sm md:block" %>
<% end %>
<% end %>

View file

@ -1,47 +1,27 @@
<%# locals: (account:) %>
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<% if account.plaid_account_id.present? %>
<%= link_to accounts_path,
data: { turbo_frame: :_top },
class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-secondary" %>
<%= render MenuComponent.new do |menu| %>
<% menu.with_item(text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal })%>
<span><%= t(".manage") %></span>
<% end %>
<% else %>
<%= link_to edit_account_path(account),
data: { turbo_frame: :modal },
class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-secondary" %>
<% unless account.crypto? %>
<% menu.with_item(
text: "Import transactions",
href: imports_path({ import: { type: account.investment? ? "TradeImport" : "TransactionImport", account_id: account.id } }),
icon: "download",
data: { turbo_frame: :_top }
) %>
<% end %>
<span><%= t(".edit") %></span>
<% end %>
<% unless account.crypto? %>
<%= button_to imports_path({ import: { type: account.investment? ? "TradeImport" : "TransactionImport", account_id: account.id } }),
data: { turbo_frame: :_top },
class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "download", class: "w-5 h-5 text-secondary" %>
<span><%= t(".import") %></span>
<% end %>
<% end %>
<%= button_to account_path(account),
method: :delete,
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
data: {
turbo_frame: :_top,
turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body_html"),
accept: t(".confirm_accept", name: account.name)
}
} do %>
<%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account
<% end %>
<% end %>
</div>
<% menu.with_item(
text: "Delete account",
href: account_path(account),
method: :delete,
icon: "trash-2",
variant: "action",
data: { turbo_frame: :_top, turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body_html"),
accept: t(".confirm_accept", name: account.name)
}}
)%>
<% end %>

View file

@ -0,0 +1,19 @@
class MenuComponentPreview < ViewComponent::Preview
# @param variant select {{ MenuComponent::VARIANTS.keys }}
def default(variant: "icon")
if variant == "icon"
render MenuComponent.new(variant: variant) do |menu|
menu.with_item(text: "Menu item 1", href: "#", icon: "plus")
menu.with_item(text: "Menu item 2", href: "#", icon: "circle")
menu.with_item(text: "Destructive", href: "#", method: :delete, icon: "circle")
end
else
render MenuComponent.new(variant: variant) do |menu|
menu.with_button(text: "New", icon: "plus")
menu.with_item(text: "Menu item 1", href: "#", icon: "plus")
menu.with_item(text: "Menu item 2", href: "#", icon: "circle")
menu.with_item(text: "Destructive", href: "#", method: :delete, icon: "circle")
end
end
end
end