mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-04 21:15:19 +02:00
Pre-launch design sync with Figma spec (#2154)
* Add lookbook + viewcomponent, organize design system file * Build menu component * Button updates * More button fixes * Replace all menus with new ViewComponent * Checkpoint: fix tests, all buttons and menus converted * Split into Link and Button components for clarity * Button cleanup * Simplify custom confirmation configuration in views * Finalize button, link component API * Add toggle field to custom form builder + Component * Basic tabs component * Custom tabs, convert all menu / tab instances in app * Gem updates * Centralized icon helper * Update all icon usage to central helper * Lint fixes * Centralize all disclosure instances * Dialog replacements * Consolidation of all dialog styles * Test fixes * Fix app layout issues, move to component with slots * Layout simplification * Flakey test fix * Fix dashboard mobile issues * Finalize homepage * Lint fixes * Fix shadows and borders in dark mode * Fix tests * Remove stale class * Fix filled icon logic * Move transparent? to public interface
This commit is contained in:
parent
1aafed5f8b
commit
90a9546f32
291 changed files with 4143 additions and 3104 deletions
13
app/components/button_component.html.erb
Normal file
13
app/components/button_component.html.erb
Normal file
|
@ -0,0 +1,13 @@
|
|||
<%= container do %>
|
||||
<% if icon && (icon_position != "right") %>
|
||||
<%= lucide_icon(icon, class: icon_classes) %>
|
||||
<% end %>
|
||||
|
||||
<% unless icon_only? %>
|
||||
<%= text %>
|
||||
<% end %>
|
||||
|
||||
<% if icon && icon_position == "right" %>
|
||||
<%= lucide_icon(icon, class: icon_classes) %>
|
||||
<% end %>
|
||||
<% end %>
|
41
app/components/button_component.rb
Normal file
41
app/components/button_component.rb
Normal file
|
@ -0,0 +1,41 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# An extension to `button_to` helper. All options are passed through to the `button_to` helper with some additional
|
||||
# options available.
|
||||
class ButtonComponent < ButtonishComponent
|
||||
attr_reader :confirm
|
||||
|
||||
def initialize(confirm: nil, **opts)
|
||||
super(**opts)
|
||||
@confirm = confirm
|
||||
end
|
||||
|
||||
def container(&block)
|
||||
if href.present?
|
||||
button_to(href, **merged_opts, &block)
|
||||
else
|
||||
content_tag(:button, **merged_opts, &block)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def merged_opts
|
||||
merged_opts = opts.dup || {}
|
||||
extra_classes = merged_opts.delete(:class)
|
||||
href = merged_opts.delete(:href)
|
||||
data = merged_opts.delete(:data) || {}
|
||||
|
||||
if confirm.present?
|
||||
data = data.merge(turbo_confirm: confirm.to_data_attribute)
|
||||
end
|
||||
|
||||
if frame.present?
|
||||
data = data.merge(turbo_frame: frame)
|
||||
end
|
||||
|
||||
merged_opts.merge(
|
||||
class: class_names(container_classes, extra_classes),
|
||||
data: data
|
||||
)
|
||||
end
|
||||
end
|
148
app/components/buttonish_component.rb
Normal file
148
app/components/buttonish_component.rb
Normal file
|
@ -0,0 +1,148 @@
|
|||
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, :frame, :opts
|
||||
|
||||
def initialize(variant: :primary, size: :md, href: nil, text: nil, icon: nil, icon_position: :left, full_width: false, frame: nil, **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)
|
||||
@frame = frame
|
||||
@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
|
38
app/components/dialog_component.html.erb
Normal file
38
app/components/dialog_component.html.erb
Normal file
|
@ -0,0 +1,38 @@
|
|||
<turbo-frame id="<%= frame %>">
|
||||
<%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay #{drawer? ? "lg:p-3" : "lg:p-1"}", **merged_opts do %>
|
||||
<%= tag.div class: dialog_outer_classes do %>
|
||||
<%= tag.div class: dialog_inner_classes, data: { dialog_target: "content" } do %>
|
||||
<div class="grow overflow-y-auto py-4 space-y-4">
|
||||
<% if header? %>
|
||||
<%= header %>
|
||||
<% end %>
|
||||
|
||||
<% if body? %>
|
||||
<div class="px-4">
|
||||
<%= body %>
|
||||
|
||||
<% if sections.any? %>
|
||||
<div class="space-y-4">
|
||||
<% sections.each do |section| %>
|
||||
<%= section %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Optional, for customizing dialogs %>
|
||||
<%= content %>
|
||||
</div>
|
||||
|
||||
<% if actions? %>
|
||||
<div class="flex items-center gap-2 justify-end p-4">
|
||||
<% actions.each do |action| %>
|
||||
<%= action %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</turbo-frame>
|
105
app/components/dialog_component.rb
Normal file
105
app/components/dialog_component.rb
Normal file
|
@ -0,0 +1,105 @@
|
|||
class DialogComponent < ViewComponent::Base
|
||||
renders_one :header, ->(title: nil, subtitle: nil, hide_close_icon: false, **opts, &block) do
|
||||
content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do
|
||||
title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do
|
||||
title = content_tag(:h2, title, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title
|
||||
close_icon = render ButtonComponent.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "dialog#close" }) unless hide_close_icon
|
||||
safe_join([ title, close_icon ].compact)
|
||||
end
|
||||
|
||||
subtitle = content_tag(:p, subtitle, class: "text-sm text-secondary") if subtitle
|
||||
|
||||
block_content = capture(&block) if block
|
||||
|
||||
safe_join([ title_div, subtitle, block_content ].compact)
|
||||
end
|
||||
end
|
||||
|
||||
renders_one :body
|
||||
|
||||
renders_many :actions, ->(cancel_action: false, **button_opts) do
|
||||
merged_opts = if cancel_action
|
||||
button_opts.merge(type: "button", data: { action: "modal#close" })
|
||||
else
|
||||
button_opts
|
||||
end
|
||||
|
||||
render ButtonComponent.new(**merged_opts)
|
||||
end
|
||||
|
||||
renders_many :sections, ->(title:, **disclosure_opts, &block) do
|
||||
render DisclosureComponent.new(title: title, align: :right, **disclosure_opts) do
|
||||
block.call
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :variant, :auto_open, :reload_on_close, :frame, :width, :opts
|
||||
|
||||
VARIANTS = %w[modal drawer].freeze
|
||||
WIDTHS = {
|
||||
sm: "lg:max-w-[300px]",
|
||||
md: "lg:max-w-[550px]",
|
||||
lg: "lg:max-w-[700px]",
|
||||
full: "lg:max-w-full"
|
||||
}.freeze
|
||||
|
||||
def initialize(variant: "modal", auto_open: true, reload_on_close: false, frame: nil, width: "md", **opts)
|
||||
@variant = variant.to_sym
|
||||
@auto_open = auto_open
|
||||
@reload_on_close = reload_on_close
|
||||
@frame = frame
|
||||
@width = width.to_sym
|
||||
@opts = opts
|
||||
end
|
||||
|
||||
def frame
|
||||
@frame || variant
|
||||
end
|
||||
|
||||
def dialog_outer_classes
|
||||
variant_classes = if drawer?
|
||||
"items-end justify-end"
|
||||
else
|
||||
"items-center justify-center"
|
||||
end
|
||||
|
||||
class_names(
|
||||
"flex h-full w-full",
|
||||
variant_classes
|
||||
)
|
||||
end
|
||||
|
||||
def dialog_inner_classes
|
||||
variant_classes = if drawer?
|
||||
"lg:w-[550px] h-full"
|
||||
else
|
||||
class_names(
|
||||
"max-h-full",
|
||||
WIDTHS[width]
|
||||
)
|
||||
end
|
||||
|
||||
class_names(
|
||||
"flex flex-col bg-container lg:rounded-xl lg:shadow-border-xs w-full overflow-hidden",
|
||||
variant_classes
|
||||
)
|
||||
end
|
||||
|
||||
def merged_opts
|
||||
merged_opts = opts.dup
|
||||
data = merged_opts.delete(:data) || {}
|
||||
|
||||
data[:controller] = [ "dialog", "hotkey", data[:controller] ].compact.join(" ")
|
||||
data[:dialog_auto_open_value] = auto_open
|
||||
data[:dialog_reload_on_close_value] = reload_on_close
|
||||
data[:action] = [ "mousedown->dialog#clickOutside", data[:action] ].compact.join(" ")
|
||||
data[:hotkey] = "esc:dialog#close"
|
||||
merged_opts[:data] = data
|
||||
|
||||
merged_opts
|
||||
end
|
||||
|
||||
def drawer?
|
||||
variant == :drawer
|
||||
end
|
||||
end
|
33
app/components/dialog_controller.js
Normal file
33
app/components/dialog_controller.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="dialog"
|
||||
export default class extends Controller {
|
||||
static targets = ["content"]
|
||||
|
||||
static values = {
|
||||
autoOpen: { type: Boolean, default: false },
|
||||
reloadOnClose: { type: Boolean, default: false },
|
||||
};
|
||||
|
||||
connect() {
|
||||
if (this.element.open) return;
|
||||
if (this.autoOpenValue) {
|
||||
this.element.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
// If the user clicks anywhere outside of the visible content, close the dialog
|
||||
clickOutside(e) {
|
||||
if (!this.contentTarget.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.close();
|
||||
|
||||
if (this.reloadOnCloseValue) {
|
||||
Turbo.visit(window.location.href);
|
||||
}
|
||||
}
|
||||
}
|
25
app/components/disclosure_component.html.erb
Normal file
25
app/components/disclosure_component.html.erb
Normal file
|
@ -0,0 +1,25 @@
|
|||
<details class="group" <%= "open" if open %>>
|
||||
<%= tag.summary class: class_names(
|
||||
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
|
||||
) do %>
|
||||
<div class="flex items-center gap-3">
|
||||
<% if align == :left %>
|
||||
<%= lucide_icon "chevron-right", class: "fg-gray w-5 h-5 group-open:transform group-open:rotate-90" %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %>
|
||||
<%= title %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if align == :right %>
|
||||
<%= lucide_icon "chevron-down", class: "fg-gray w-5 h-5 group-open:transform group-open:rotate-180" %>
|
||||
<% elsif summary_content? %>
|
||||
<%= summary_content %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-2">
|
||||
<%= content %>
|
||||
</div>
|
||||
</details>
|
12
app/components/disclosure_component.rb
Normal file
12
app/components/disclosure_component.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
class DisclosureComponent < ViewComponent::Base
|
||||
renders_one :summary_content
|
||||
|
||||
attr_reader :title, :align, :open, :opts
|
||||
|
||||
def initialize(title:, align: "right", open: false, **opts)
|
||||
@title = title
|
||||
@align = align.to_sym
|
||||
@open = open
|
||||
@opts = opts
|
||||
end
|
||||
end
|
8
app/components/filled_icon_component.html.erb
Normal file
8
app/components/filled_icon_component.html.erb
Normal file
|
@ -0,0 +1,8 @@
|
|||
<%= tag.div style: transparent? ? container_styles : nil,
|
||||
class: container_classes do %>
|
||||
<% if icon %>
|
||||
<%= helpers.icon(icon, size: icon_size, color: "current") %>
|
||||
<% elsif text %>
|
||||
<%= tag.span text.first, class: text_classes %>
|
||||
<% end %>
|
||||
<% end %>
|
97
app/components/filled_icon_component.rb
Normal file
97
app/components/filled_icon_component.rb
Normal file
|
@ -0,0 +1,97 @@
|
|||
class FilledIconComponent < ViewComponent::Base
|
||||
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant
|
||||
|
||||
VARIANTS = %i[default text surface container].freeze
|
||||
|
||||
SIZES = {
|
||||
sm: {
|
||||
container_size: "w-6 h-6",
|
||||
container_radius: "rounded-md",
|
||||
icon_size: "sm",
|
||||
text_size: "text-xs"
|
||||
},
|
||||
md: {
|
||||
container_size: "w-8 h-8",
|
||||
container_radius: "rounded-lg",
|
||||
icon_size: "md",
|
||||
text_size: "text-xs"
|
||||
},
|
||||
lg: {
|
||||
container_size: "w-9 h-9",
|
||||
container_radius: "rounded-xl",
|
||||
icon_size: "lg",
|
||||
text_size: "text-sm"
|
||||
}
|
||||
}.freeze
|
||||
|
||||
def initialize(variant: :default, icon: nil, text: nil, hex_color: nil, size: "md", rounded: false)
|
||||
@variant = variant.to_sym
|
||||
@icon = icon
|
||||
@text = text
|
||||
@hex_color = hex_color
|
||||
@size = size.to_sym
|
||||
@rounded = rounded
|
||||
end
|
||||
|
||||
def container_classes
|
||||
class_names(
|
||||
"flex justify-center items-center",
|
||||
size_classes,
|
||||
radius_classes,
|
||||
transparent? ? "border" : solid_bg_class
|
||||
)
|
||||
end
|
||||
|
||||
def icon_size
|
||||
SIZES[size][:icon_size]
|
||||
end
|
||||
|
||||
def text_classes
|
||||
class_names(
|
||||
"text-center font-medium uppercase",
|
||||
SIZES[size][:text_size]
|
||||
)
|
||||
end
|
||||
|
||||
def container_styles
|
||||
<<~STYLE.strip
|
||||
background-color: #{transparent_bg_color};
|
||||
border-color: #{transparent_border_color};
|
||||
color: #{custom_fg_color};
|
||||
STYLE
|
||||
end
|
||||
|
||||
def transparent?
|
||||
variant.in?(%i[default text])
|
||||
end
|
||||
|
||||
private
|
||||
def solid_bg_class
|
||||
case variant
|
||||
when :surface
|
||||
"bg-surface-inset"
|
||||
when :container
|
||||
"bg-container-inset"
|
||||
end
|
||||
end
|
||||
|
||||
def size_classes
|
||||
SIZES[size][:container_size]
|
||||
end
|
||||
|
||||
def radius_classes
|
||||
rounded ? "rounded-full" : SIZES[size][:container_radius]
|
||||
end
|
||||
|
||||
def custom_fg_color
|
||||
hex_color || "var(--color-gray-500)"
|
||||
end
|
||||
|
||||
def transparent_bg_color
|
||||
"color-mix(in oklab, #{custom_fg_color} 10%, transparent)"
|
||||
end
|
||||
|
||||
def transparent_border_color
|
||||
"color-mix(in oklab, #{custom_fg_color} 10%, transparent)"
|
||||
end
|
||||
end
|
13
app/components/link_component.html.erb
Normal file
13
app/components/link_component.html.erb
Normal file
|
@ -0,0 +1,13 @@
|
|||
<%= link_to href, **merged_opts do %>
|
||||
<% if icon && (icon_position != "right") %>
|
||||
<%= lucide_icon(icon, class: icon_classes) %>
|
||||
<% end %>
|
||||
|
||||
<% unless icon_only? %>
|
||||
<%= text %>
|
||||
<% end %>
|
||||
|
||||
<% if icon && icon_position == "right" %>
|
||||
<%= lucide_icon(icon, class: icon_classes) %>
|
||||
<% end %>
|
||||
<% end %>
|
31
app/components/link_component.rb
Normal file
31
app/components/link_component.rb
Normal file
|
@ -0,0 +1,31 @@
|
|||
# An extension to `link_to` helper. All options are passed through to the `link_to` helper with some additional
|
||||
# options available.
|
||||
class LinkComponent < ButtonishComponent
|
||||
attr_reader :frame
|
||||
|
||||
VARIANTS = VARIANTS.reverse_merge(
|
||||
default: {
|
||||
container_classes: "",
|
||||
icon_classes: "fg-gray"
|
||||
}
|
||||
).freeze
|
||||
|
||||
def merged_opts
|
||||
merged_opts = opts.dup || {}
|
||||
data = merged_opts.delete(:data) || {}
|
||||
|
||||
if frame
|
||||
data = data.merge(turbo_frame: frame)
|
||||
end
|
||||
|
||||
merged_opts.merge(
|
||||
class: class_names(container_classes, extra_classes),
|
||||
data: data
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def container_size_classes
|
||||
super unless variant == :default
|
||||
end
|
||||
end
|
27
app/components/menu_component.html.erb
Normal file
27
app/components/menu_component.html.erb
Normal file
|
@ -0,0 +1,27 @@
|
|||
<%= tag.div data: { controller: "menu", menu_placement_value: placement, menu_offset_value: offset, testid: testid } do %>
|
||||
<% if variant == :icon %>
|
||||
<%= render ButtonComponent.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { menu_target: "button" }) %>
|
||||
<% elsif variant == :button %>
|
||||
<%= button %>
|
||||
<% elsif variant == :avatar %>
|
||||
<button data-menu-target="button">
|
||||
<div class="w-9 h-9 cursor-pointer">
|
||||
<%= render "settings/user_avatar", avatar_url: avatar_url %>
|
||||
</div>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<div data-menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
|
||||
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
|
||||
<%= header %>
|
||||
|
||||
<%= tag.div class: class_names("py-1" => !no_padding) do %>
|
||||
<% items.each do |item| %>
|
||||
<%= item %>
|
||||
<% end %>
|
||||
|
||||
<%= custom_content %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
37
app/components/menu_component.rb
Normal file
37
app/components/menu_component.rb
Normal file
|
@ -0,0 +1,37 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MenuComponent < ViewComponent::Base
|
||||
attr_reader :variant, :avatar_url, :placement, :offset, :icon_vertical, :no_padding, :testid
|
||||
|
||||
renders_one :button, ->(**button_options, &block) do
|
||||
options_with_target = button_options.merge(data: { menu_target: "button" })
|
||||
|
||||
if block
|
||||
content_tag(:button, **options_with_target, &block)
|
||||
else
|
||||
ButtonComponent.new(**options_with_target)
|
||||
end
|
||||
end
|
||||
|
||||
renders_one :header, ->(&block) do
|
||||
content_tag(:div, class: "border-b border-tertiary", &block)
|
||||
end
|
||||
|
||||
renders_one :custom_content
|
||||
|
||||
renders_many :items, MenuItemComponent
|
||||
|
||||
VARIANTS = %i[icon button avatar].freeze
|
||||
|
||||
def initialize(variant: "icon", avatar_url: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil)
|
||||
@variant = variant.to_sym
|
||||
@avatar_url = avatar_url
|
||||
@placement = placement
|
||||
@offset = offset
|
||||
@icon_vertical = icon_vertical
|
||||
@no_padding = no_padding
|
||||
@testid = testid
|
||||
|
||||
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
|
||||
end
|
||||
end
|
117
app/components/menu_controller.js
Normal file
117
app/components/menu_controller.js
Normal file
|
@ -0,0 +1,117 @@
|
|||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
/**
|
||||
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ["button", "content"];
|
||||
|
||||
static values = {
|
||||
show: Boolean,
|
||||
placement: { type: String, default: "bottom-end" },
|
||||
offset: { type: Number, default: 6 },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.show = this.showValue;
|
||||
this.boundUpdate = this.update.bind(this);
|
||||
this.addEventListeners();
|
||||
this.startAutoUpdate();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.removeEventListeners();
|
||||
this.stopAutoUpdate();
|
||||
this.close();
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
this.buttonTarget.addEventListener("click", this.toggle);
|
||||
this.element.addEventListener("keydown", this.handleKeydown);
|
||||
document.addEventListener("click", this.handleOutsideClick);
|
||||
document.addEventListener("turbo:load", this.handleTurboLoad);
|
||||
}
|
||||
|
||||
removeEventListeners() {
|
||||
this.buttonTarget.removeEventListener("click", this.toggle);
|
||||
this.element.removeEventListener("keydown", this.handleKeydown);
|
||||
document.removeEventListener("click", this.handleOutsideClick);
|
||||
document.removeEventListener("turbo:load", this.handleTurboLoad);
|
||||
}
|
||||
|
||||
handleTurboLoad = () => {
|
||||
if (!this.show) this.close();
|
||||
};
|
||||
|
||||
handleOutsideClick = (event) => {
|
||||
if (this.show && !this.element.contains(event.target)) this.close();
|
||||
};
|
||||
|
||||
handleKeydown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
this.close();
|
||||
this.buttonTarget.focus();
|
||||
}
|
||||
};
|
||||
|
||||
toggle = () => {
|
||||
this.show = !this.show;
|
||||
this.contentTarget.classList.toggle("hidden", !this.show);
|
||||
if (this.show) {
|
||||
this.update();
|
||||
this.focusFirstElement();
|
||||
}
|
||||
};
|
||||
|
||||
close() {
|
||||
this.show = false;
|
||||
this.contentTarget.classList.add("hidden");
|
||||
}
|
||||
|
||||
focusFirstElement() {
|
||||
const focusableElements =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const firstFocusableElement =
|
||||
this.contentTarget.querySelectorAll(focusableElements)[0];
|
||||
if (firstFocusableElement) {
|
||||
firstFocusableElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
startAutoUpdate() {
|
||||
if (!this._cleanup) {
|
||||
this._cleanup = autoUpdate(
|
||||
this.buttonTarget,
|
||||
this.contentTarget,
|
||||
this.boundUpdate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stopAutoUpdate() {
|
||||
if (this._cleanup) {
|
||||
this._cleanup();
|
||||
this._cleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
computePosition(this.buttonTarget, this.contentTarget, {
|
||||
placement: this.placementValue,
|
||||
middleware: [offset(this.offsetValue), flip(), shift({ padding: 5 })],
|
||||
}).then(({ x, y }) => {
|
||||
Object.assign(this.contentTarget.style, {
|
||||
position: "fixed",
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
12
app/components/menu_item_component.html.erb
Normal file
12
app/components/menu_item_component.html.erb
Normal file
|
@ -0,0 +1,12 @@
|
|||
<% if variant == :divider %>
|
||||
<hr class="border-tertiary my-1">
|
||||
<% else %>
|
||||
<div class="px-1">
|
||||
<%= wrapper do %>
|
||||
<% if icon %>
|
||||
<%= lucide_icon(icon, class: destructive? ? "text-destructive" : "fg-gray") %>
|
||||
<% end %>
|
||||
<%= tag.span(text, class: text_classes) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
57
app/components/menu_item_component.rb
Normal file
57
app/components/menu_item_component.rb
Normal file
|
@ -0,0 +1,57 @@
|
|||
class MenuItemComponent < ViewComponent::Base
|
||||
VARIANTS = %i[link button divider].freeze
|
||||
|
||||
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :opts
|
||||
|
||||
def initialize(variant:, text: nil, icon: nil, href: nil, method: :post, destructive: false, confirm: nil, **opts)
|
||||
@variant = variant.to_sym
|
||||
@text = text
|
||||
@icon = icon
|
||||
@href = href
|
||||
@method = method.to_sym
|
||||
@destructive = destructive
|
||||
@opts = opts
|
||||
@confirm = confirm
|
||||
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
|
||||
end
|
||||
|
||||
def wrapper(&block)
|
||||
if variant == :button
|
||||
button_to href, method: method, class: container_classes, **merged_button_opts, &block
|
||||
elsif variant == :link
|
||||
link_to href, class: container_classes, **opts, &block
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def text_classes
|
||||
[
|
||||
"text-sm",
|
||||
destructive? ? "text-destructive" : "text-primary"
|
||||
].join(" ")
|
||||
end
|
||||
|
||||
def destructive?
|
||||
method == :delete || destructive
|
||||
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
|
||||
|
||||
def merged_button_opts
|
||||
merged_opts = opts.dup || {}
|
||||
data = merged_opts.delete(:data) || {}
|
||||
|
||||
if confirm.present?
|
||||
data = data.merge(turbo_confirm: confirm.to_data_attribute)
|
||||
end
|
||||
|
||||
merged_opts.merge(data: data)
|
||||
end
|
||||
end
|
12
app/components/tab_component.rb
Normal file
12
app/components/tab_component.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
class TabComponent < ViewComponent::Base
|
||||
attr_reader :id, :label
|
||||
|
||||
def initialize(id:, label:)
|
||||
@id = id
|
||||
@label = label
|
||||
end
|
||||
|
||||
def call
|
||||
content
|
||||
end
|
||||
end
|
29
app/components/tabs/nav_component.rb
Normal file
29
app/components/tabs/nav_component.rb
Normal file
|
@ -0,0 +1,29 @@
|
|||
class Tabs::NavComponent < ViewComponent::Base
|
||||
erb_template <<~ERB
|
||||
<%= tag.nav class: classes do %>
|
||||
<% btns.each do |btn| %>
|
||||
<%= btn %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
ERB
|
||||
|
||||
renders_many :btns, ->(id:, label:, classes: nil, &block) do
|
||||
content_tag(
|
||||
:button, label, id: id,
|
||||
type: "button",
|
||||
class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes),
|
||||
data: { id: id, action: "tabs#show", tabs_target: "navBtn" },
|
||||
&block
|
||||
)
|
||||
end
|
||||
|
||||
attr_reader :active_tab, :classes, :active_btn_classes, :inactive_btn_classes, :btn_classes
|
||||
|
||||
def initialize(active_tab:, classes: nil, active_btn_classes: nil, inactive_btn_classes: nil, btn_classes: nil)
|
||||
@active_tab = active_tab
|
||||
@classes = classes
|
||||
@active_btn_classes = active_btn_classes
|
||||
@inactive_btn_classes = inactive_btn_classes
|
||||
@btn_classes = btn_classes
|
||||
end
|
||||
end
|
11
app/components/tabs/panel_component.rb
Normal file
11
app/components/tabs/panel_component.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
class Tabs::PanelComponent < ViewComponent::Base
|
||||
attr_reader :tab_id
|
||||
|
||||
def initialize(tab_id:)
|
||||
@tab_id = tab_id
|
||||
end
|
||||
|
||||
def call
|
||||
content
|
||||
end
|
||||
end
|
17
app/components/tabs_component.html.erb
Normal file
17
app/components/tabs_component.html.erb
Normal file
|
@ -0,0 +1,17 @@
|
|||
<%= tag.div data: {
|
||||
controller: "tabs",
|
||||
testid: testid,
|
||||
tabs_url_param_key_value: url_param_key,
|
||||
tabs_nav_btn_active_class: active_btn_classes,
|
||||
tabs_nav_btn_inactive_class: inactive_btn_classes
|
||||
} do %>
|
||||
<% if unstyled? %>
|
||||
<%= content %>
|
||||
<% else %>
|
||||
<%= nav %>
|
||||
|
||||
<% panels.each do |panel| %>
|
||||
<%= panel %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
65
app/components/tabs_component.rb
Normal file
65
app/components/tabs_component.rb
Normal file
|
@ -0,0 +1,65 @@
|
|||
class TabsComponent < ViewComponent::Base
|
||||
renders_one :nav, ->(classes: nil) do
|
||||
Tabs::NavComponent.new(
|
||||
active_tab: active_tab,
|
||||
active_btn_classes: active_btn_classes,
|
||||
inactive_btn_classes: inactive_btn_classes,
|
||||
btn_classes: base_btn_classes,
|
||||
classes: unstyled? ? classes : class_names(nav_container_classes, classes)
|
||||
)
|
||||
end
|
||||
|
||||
renders_many :panels, ->(tab_id:, &block) do
|
||||
content_tag(
|
||||
:div,
|
||||
class: ("hidden" unless tab_id == active_tab),
|
||||
data: { id: tab_id, tabs_target: "panel" },
|
||||
&block
|
||||
)
|
||||
end
|
||||
|
||||
VARIANTS = {
|
||||
default: {
|
||||
active_btn_classes: "bg-white theme-dark:bg-gray-700 text-primary shadow-sm",
|
||||
inactive_btn_classes: "text-secondary hover:bg-surface-inset-hover",
|
||||
base_btn_classes: "w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200",
|
||||
nav_container_classes: "flex bg-surface-inset p-1 rounded-lg mb-4"
|
||||
}
|
||||
}
|
||||
|
||||
attr_reader :active_tab, :url_param_key, :variant, :testid
|
||||
|
||||
def initialize(active_tab:, url_param_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil)
|
||||
@active_tab = active_tab
|
||||
@url_param_key = url_param_key
|
||||
@variant = variant.to_sym
|
||||
@active_btn_classes = active_btn_classes
|
||||
@inactive_btn_classes = inactive_btn_classes
|
||||
@testid = testid
|
||||
end
|
||||
|
||||
def active_btn_classes
|
||||
unstyled? ? @active_btn_classes : VARIANTS.dig(variant, :active_btn_classes)
|
||||
end
|
||||
|
||||
def inactive_btn_classes
|
||||
unstyled? ? @inactive_btn_classes : VARIANTS.dig(variant, :inactive_btn_classes)
|
||||
end
|
||||
|
||||
private
|
||||
def unstyled?
|
||||
variant == :unstyled
|
||||
end
|
||||
|
||||
def base_btn_classes
|
||||
unless unstyled?
|
||||
VARIANTS.dig(variant, :base_btn_classes)
|
||||
end
|
||||
end
|
||||
|
||||
def nav_container_classes
|
||||
unless unstyled?
|
||||
VARIANTS.dig(variant, :nav_container_classes)
|
||||
end
|
||||
end
|
||||
end
|
42
app/components/tabs_controller.js
Normal file
42
app/components/tabs_controller.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="tabs--components"
|
||||
export default class extends Controller {
|
||||
static classes = ["navBtnActive", "navBtnInactive"];
|
||||
static targets = ["panel", "navBtn"];
|
||||
static values = { urlParamKey: String };
|
||||
|
||||
connect() {
|
||||
console.log("tabs controller connected");
|
||||
}
|
||||
|
||||
show(e) {
|
||||
const btn = e.target.closest("button");
|
||||
const selectedTabId = btn.dataset.id;
|
||||
|
||||
this.navBtnTargets.forEach((navBtn) => {
|
||||
if (navBtn.dataset.id === selectedTabId) {
|
||||
navBtn.classList.add(...this.navBtnActiveClasses);
|
||||
navBtn.classList.remove(...this.navBtnInactiveClasses);
|
||||
} else {
|
||||
navBtn.classList.add(...this.navBtnInactiveClasses);
|
||||
navBtn.classList.remove(...this.navBtnActiveClasses);
|
||||
}
|
||||
});
|
||||
|
||||
this.panelTargets.forEach((panel) => {
|
||||
if (panel.dataset.id === selectedTabId) {
|
||||
panel.classList.remove("hidden");
|
||||
} else {
|
||||
panel.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
// Update URL with the selected tab
|
||||
if (this.urlParamKeyValue) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(this.urlParamKeyValue, selectedTabId);
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
}
|
||||
}
|
5
app/components/toggle_component.html.erb
Normal file
5
app/components/toggle_component.html.erb
Normal file
|
@ -0,0 +1,5 @@
|
|||
<div class="relative inline-block select-none">
|
||||
<%= hidden_field_tag name, unchecked_value, id: nil %>
|
||||
<%= check_box_tag name, checked_value, checked, class: "sr-only peer", disabled: disabled, id: id, **opts %>
|
||||
<%= label_tag name, " ".html_safe, class: label_classes, for: id %>
|
||||
</div>
|
26
app/components/toggle_component.rb
Normal file
26
app/components/toggle_component.rb
Normal file
|
@ -0,0 +1,26 @@
|
|||
class ToggleComponent < ViewComponent::Base
|
||||
attr_reader :id, :name, :checked, :disabled, :checked_value, :unchecked_value, :opts
|
||||
|
||||
def initialize(id:, name: nil, checked: false, disabled: false, checked_value: "1", unchecked_value: "0", **opts)
|
||||
@id = id
|
||||
@name = name
|
||||
@checked = checked
|
||||
@disabled = disabled
|
||||
@checked_value = checked_value
|
||||
@unchecked_value = unchecked_value
|
||||
@opts = opts
|
||||
end
|
||||
|
||||
def label_classes
|
||||
class_names(
|
||||
"block w-9 h-5 cursor-pointer",
|
||||
"rounded-full bg-gray-100 theme-dark:bg-gray-700",
|
||||
"transition-colors duration-300",
|
||||
"after:content-[''] after:block after:bg-white after:absolute after:rounded-full",
|
||||
"after:top-0.5 after:left-0.5 after:w-4 after:h-4",
|
||||
"after:transition-transform after:duration-300 after:ease-in-out",
|
||||
"peer-checked:bg-green-600 peer-checked:after:translate-x-4",
|
||||
"peer-disabled:opacity-70 peer-disabled:cursor-not-allowed"
|
||||
)
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue