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

Basic tabs component

This commit is contained in:
Zach Gollwitzer 2025-04-27 20:16:18 -04:00
parent 8aa6cb37d8
commit f6016e47e2
9 changed files with 216 additions and 120 deletions

View 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

View file

@ -0,0 +1,22 @@
<%= tag.div class: "space-y-4", data: {
controller: "tabs",
tabs_url_param_key_value: url_param_key,
tabs_nav_btn_active_class: nav_btn_active_classes,
tabs_nav_btn_inactive_class: nav_btn_inactive_classes
} do %>
<nav class="flex bg-surface-inset p-1 rounded-lg">
<% tabs.each do |tab| %>
<%= tag.button class: nav_btn_classes(active: tab.id == active_tab), data: { id: tab.id, action: "tabs#show", tabs_target: "navBtn" } do %>
<%= tab.label %>
<% end %>
<% end %>
</nav>
<div>
<% tabs.each do |tab| %>
<%= tag.div class: class_names("hidden" => tab.id != active_tab), data: { id: tab.id, tabs_target: "panel" } do %>
<%= tab %>
<% end %>
<% end %>
</div>
<% end %>

View file

@ -0,0 +1,38 @@
class TabsComponent < ViewComponent::Base
renders_many :tabs, TabComponent
VARIANTS = {
default: {
nav_btn_active_classes: "bg-white theme-dark:bg-gray-700 text-primary shadow-sm",
nav_btn_inactive_classes: "text-secondary hover:bg-surface-inset-hover",
nav_btn_classes: "w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200"
}
}
attr_reader :variant, :url_param_key
def initialize(active_tab: nil, variant: "default", url_param_key: nil)
@active_tab = active_tab
@variant = variant.to_sym
@url_param_key = url_param_key
end
def active_tab
@active_tab || tabs.first.id
end
def nav_btn_active_classes
VARIANTS.dig(variant, :nav_btn_active_classes)
end
def nav_btn_inactive_classes
VARIANTS.dig(variant, :nav_btn_inactive_classes)
end
def nav_btn_classes(active: false)
class_names(
VARIANTS.dig(variant, :nav_btn_classes),
active ? nav_btn_active_classes : nav_btn_inactive_classes
)
end
end

View file

@ -2,9 +2,13 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="tabs"
export default class extends Controller {
static classes = ["active", "inactive"];
static targets = ["btn", "tab"];
static values = { defaultTab: String, localStorageKey: String };
static classes = ["active", "inactive", "navBtnActive", "navBtnInactive"];
static targets = ["btn", "tab", "panel", "navBtn"];
static values = {
defaultTab: String,
localStorageKey: String,
urlParamKey: String,
};
connect() {
const selectedTab = this.hasLocalStorageKeyValue
@ -30,6 +34,35 @@ export default class extends Controller {
}
}
show(e) {
const selectedTabId = e.target.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);
}
}
onTurboLoad = () => {
const selectedTab = this.hasLocalStorageKeyValue
? this.getStoredTab() || this.defaultTabValue

View file

@ -1,48 +1,29 @@
<%# locals: (family:) %>
<%# locals: (family:, active_account_group_tab:) %>
<% if family.requires_data_provider? && Provider::Registry.get_provider(:synth).nil? %>
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
<summary class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<%= icon "triangle-alert", size: "sm" %>
<p class="font-medium">Missing historical data</p>
<div>
<% if family.requires_data_provider? && Provider::Registry.get_provider(:synth).nil? %>
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
<summary class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<%= icon "triangle-alert", size: "sm" %>
<p class="font-medium">Missing historical data</p>
</div>
<%= lucide_icon "chevron-down", class: "text-yellow-600 group-open:transform group-open:rotate-180 w-5" %>
</summary>
<div class="text-xs py-2 space-y-2">
<p>Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.</p>
<p>
<%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %>
</p>
</div>
</details>
<% end %>
<%= lucide_icon "chevron-down", class: "text-yellow-600 group-open:transform group-open:rotate-180 w-5" %>
</summary>
<div class="text-xs py-2 space-y-2">
<p>Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.</p>
<p>
<%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %>
</p>
</div>
</details>
<% end %>
<div
class="space-y-3"
data-controller="tabs"
data-tabs-local-storage-key-value="account-sidebar-tabs"
data-tabs-active-class="bg-surface shadow-sm text-primary"
data-tabs-inactive-class="text-secondary"
data-tabs-default-tab-value="assets-tab">
<div class="bg-surface-inset rounded-lg p-1 flex">
<button type="button" data-id="assets-tab" class="w-1/3 px-2 py-1 rounded-md text-sm text-secondary font-medium" data-tabs-target="btn" data-action="click->tabs#select">
Assets
</button>
<button type="button" data-id="debts-tab" class="w-1/3 px-2 py-1 rounded-md text-secondary text-sm font-medium" data-tabs-target="btn" data-action="click->tabs#select">
Debts
</button>
<button type="button" data-id="all-tab" class="w-1/3 px-2 py-1 rounded-md text-secondary text-sm font-medium" data-tabs-target="btn" data-action="click->tabs#select">
All
</button>
</div>
<div data-tabs-target="tab" id="assets-tab">
<%= render LinkComponent.new(
<%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab") do |container| %>
<% container.with_tab(id: "assets", label: "Assets") do %>
<%= render LinkComponent.new(
text: "New asset",
variant: "ghost",
href: new_account_path(step: "method_select", classification: "asset"),
@ -52,15 +33,15 @@
class: "justify-start"
) %>
<div class="space-y-2">
<% family.balance_sheet.account_groups("asset").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
<div class="space-y-2">
<% family.balance_sheet.account_groups("asset").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
<% end %>
<div data-tabs-target="tab" id="debts-tab" class="hidden">
<%= render LinkComponent.new(
<% container.with_tab(id: "debts", label: "Debts") do %>
<%= render LinkComponent.new(
text: "New debt",
variant: "ghost",
href: new_account_path(step: "method_select", classification: "liability"),
@ -70,15 +51,15 @@
class: "justify-start"
) %>
<div class="space-y-2">
<% family.balance_sheet.account_groups("liability").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
<div class="space-y-2">
<% family.balance_sheet.account_groups("liability").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
<% end %>
<div data-tabs-target="tab" id="all-tab" class="hidden">
<%= render LinkComponent.new(
<% container.with_tab(id: "all", label: "All") do %>
<%= render LinkComponent.new(
text: "New account",
variant: "ghost",
full_width: true,
@ -88,10 +69,11 @@
class: "justify-start"
) %>
<div class="space-y-2">
<% family.balance_sheet.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
</div>
</div>
<div class="space-y-2">
<% family.balance_sheet.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group %>
<% end %>
</div>
<% end %>
<% end %>
</div>

View file

@ -11,65 +11,60 @@
<p class="text-secondary text-sm"><%= t(".description") %></p>
</div>
<div
data-controller="tabs"
data-tabs-active-class="bg-surface shadow-sm text-primary"
data-tabs-inactive-class="text-secondary"
data-tabs-default-tab-value="csv-upload-tab">
<div class="flex justify-center mb-4 w-full">
<div class="bg-surface-inset rounded-lg p-1 flex w-full">
<button type="button" data-id="csv-upload-tab" class="w-1/2 px-2 py-1 rounded-md text-secondary text-sm font-medium" data-tabs-target="btn" data-action="click->tabs#select">Upload CSV</button>
<button type="button" data-id="csv-paste-tab" class="w-1/2 px-2 py-1 rounded-md text-sm text-secondary font-medium" data-tabs-target="btn" data-action="click->tabs#select">Copy & Paste</button>
</div>
</div>
<%= render TabsComponent.new(active_tab: "csv-upload", url_param_key: "tab") do |container| %>
<% container.with_tab(id: "csv-upload", label: "Upload CSV") do %>
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
<%= form.select :col_sep, Import::SEPARATORS, label: true %>
<% ["csv-paste-tab", "csv-upload-tab"].each do |tab| %>
<%= tag.div id: tab, data: { tabs_target: "tab" }, class: tab == "csv-upload-tab" ? "hidden" : "" do %>
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
<%= form.select :col_sep, Import::SEPARATORS, label: true %>
<% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
<%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<% end %>
<% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
<%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<% end %>
<div class="flex flex-col items-center justify-center w-full h-64 border border-secondary border-dashed rounded-xl cursor-pointer" data-controller="file-upload" data-action="click->file-upload#triggerFileInput" data-file-upload-target="uploadArea">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<div data-file-upload-target="uploadText" class="flex flex-col items-center">
<%= lucide_icon("plus", class: "w-6 h-6 mb-4 text-secondary mx-auto") %>
<p class="mb-2 text-md text-gray text-center">
<span class="font-medium text-primary">Browse</span> to add your CSV file here
</p>
</div>
<% if tab == "csv-paste-tab" %>
<%= form.text_area :raw_file_str,
<div class="flex flex-col items-center hidden" data-file-upload-target="fileName">
<%= lucide_icon("file-text", class: "w-6 h-6 mb-4 text-primary") %>
<p class="mb-2 text-md font-medium text-primary"></p>
</div>
<%= form.file_field :csv_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input" %>
</div>
</div>
<%= form.submit "Upload CSV", disabled: @import.complete? %>
<% end %>
<% end %>
<% container.with_tab(id: "csv-paste", label: "Copy & Paste") do %>
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
<%= form.select :col_sep, Import::SEPARATORS, label: true %>
<% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
<%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<% end %>
<%= form.text_area :raw_file_str,
rows: 10,
required: true,
placeholder: "Paste your CSV file contents here",
"data-auto-submit-form-target": "auto" %>
<% else %>
<div class="flex flex-col items-center justify-center w-full h-64 border border-secondary border-dashed rounded-xl cursor-pointer" data-controller="file-upload" data-action="click->file-upload#triggerFileInput" data-file-upload-target="uploadArea">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<div data-file-upload-target="uploadText" class="flex flex-col items-center">
<%= lucide_icon("plus", class: "w-6 h-6 mb-4 text-secondary mx-auto") %>
<p class="mb-2 text-md text-gray text-center">
<span class="font-medium text-primary">Browse</span> to add your CSV file here
</p>
</div>
<div class="flex flex-col items-center hidden" data-file-upload-target="fileName">
<%= lucide_icon("file-text", class: "w-6 h-6 mb-4 text-primary") %>
<p class="mb-2 text-md font-medium text-primary"></p>
</div>
<%= form.file_field :csv_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input" %>
</div>
</div>
<% end %>
<%= form.submit "Upload CSV", disabled: @import.complete? %>
<% end %>
<%= form.submit "Upload CSV", disabled: @import.complete? %>
<% end %>
<% end %>
</div>
<% end %>
</div>
<div class="flex justify-center">
<span class="text-secondary text-sm">
<%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format
</span>
</div>
<div class="flex justify-center">
<span class="text-secondary text-sm">
<%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format
</span>
</div>
</div>

View file

@ -23,7 +23,7 @@
<%= icon("x", color: "gray") %>
</button>
</div>
<%= render "accounts/account_sidebar_tabs", family: Current.family %>
<%= render "accounts/account_sidebar_tabs", family: Current.family, active_account_group_tab: params[:account_group_tab] || "assets" %>
</div>
<% end %>
@ -67,7 +67,7 @@
<%= yield :sidebar %>
<% else %>
<div id="account-sidebar-tabs" data-turbo-permanent>
<%= render "accounts/account_sidebar_tabs", family: Current.family %>
<%= render "accounts/account_sidebar_tabs", family: Current.family, active_account_group_tab: params[:account_group_tab] || "assets" %>
</div>
<% end %>
<% end %>

View file

@ -8,7 +8,7 @@
<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
<%= javascript_importmap_tags %>
</head>
<body class="p-4 bg-container">
<body class="p-4 bg-container <%= params.dig(:lookbook, :display, :container_classes) %>">
<%= yield %>
</body>
</html>

View file

@ -0,0 +1,14 @@
class TabsComponentPreview < ViewComponent::Preview
# @display container_classes max-w-[400px]
def default
render TabsComponent.new(active_tab: "tab1") do |container|
container.with_tab(id: "tab1", label: "Tab 1") do
content_tag(:p, "Content for tab 1")
end
container.with_tab(id: "tab2", label: "Tab 2") do
content_tag(:p, "Content for tab 2")
end
end
end
end