refactor(ui): extract reusable tab bar component
- Create shared tab_bar.html partial with desktop + mobile layouts - Add CSS component classes (.tab, .tab-active, .tab-mobile variants) - Replace divergent implementations in data.html and add.html - Rename DataTab to Tab for shared use across templates - Improve readability: darker inactive text, more spacing, hover states
This commit is contained in:
parent
70cbb76c3e
commit
4a54834424
7 changed files with 168 additions and 79 deletions
|
|
@ -9,10 +9,41 @@ use crate::application::routes::support::{
|
|||
load_cafe_options, load_roast_options, load_roaster_options,
|
||||
};
|
||||
use crate::application::server::AppState;
|
||||
use crate::presentation::web::templates::AddTemplate;
|
||||
use crate::presentation::web::templates::{AddTemplate, Tab};
|
||||
|
||||
use super::brews::load_brew_form_data;
|
||||
|
||||
const ADD_TABS: &[Tab] = &[
|
||||
Tab {
|
||||
key: "roaster",
|
||||
label: "Roaster",
|
||||
},
|
||||
Tab {
|
||||
key: "roast",
|
||||
label: "Roast",
|
||||
},
|
||||
Tab {
|
||||
key: "bag",
|
||||
label: "Bag",
|
||||
},
|
||||
Tab {
|
||||
key: "brew",
|
||||
label: "Brew",
|
||||
},
|
||||
Tab {
|
||||
key: "gear",
|
||||
label: "Gear",
|
||||
},
|
||||
Tab {
|
||||
key: "cafe",
|
||||
label: "Cafe",
|
||||
},
|
||||
Tab {
|
||||
key: "cup",
|
||||
label: "Cup",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AddQuery {
|
||||
#[serde(rename = "type", default = "default_type")]
|
||||
|
|
@ -48,6 +79,18 @@ pub(crate) async fn add_page(
|
|||
is_authenticated,
|
||||
version_info: &crate::VERSION_INFO,
|
||||
active_type: query.entity_type,
|
||||
tabs: ADD_TABS
|
||||
.iter()
|
||||
.map(|t| Tab {
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
})
|
||||
.collect(),
|
||||
tab_signal: "_add-type",
|
||||
tab_signal_js: "$_addType",
|
||||
tab_base_url: "",
|
||||
tab_fetch_target: "",
|
||||
tab_fetch_mode: "",
|
||||
roaster_options,
|
||||
roast_options,
|
||||
bag_options: brew_form.bag_options,
|
||||
|
|
|
|||
|
|
@ -8,36 +8,36 @@ use crate::application::routes::render_html;
|
|||
use crate::application::routes::support::{ListQuery, is_datastar_request};
|
||||
use crate::application::server::AppState;
|
||||
use crate::presentation::web::templates::{
|
||||
BagListTemplate, BrewListTemplate, CafeListTemplate, CupListTemplate, DataTab, DataTemplate,
|
||||
GearListTemplate, RoastListTemplate, RoasterListTemplate, render_template,
|
||||
BagListTemplate, BrewListTemplate, CafeListTemplate, CupListTemplate, DataTemplate,
|
||||
GearListTemplate, RoastListTemplate, RoasterListTemplate, Tab, render_template,
|
||||
};
|
||||
|
||||
const TABS: &[DataTab] = &[
|
||||
DataTab {
|
||||
const TABS: &[Tab] = &[
|
||||
Tab {
|
||||
key: "brews",
|
||||
label: "Brews",
|
||||
},
|
||||
DataTab {
|
||||
Tab {
|
||||
key: "roasters",
|
||||
label: "Roasters",
|
||||
},
|
||||
DataTab {
|
||||
Tab {
|
||||
key: "roasts",
|
||||
label: "Roasts",
|
||||
},
|
||||
DataTab {
|
||||
Tab {
|
||||
key: "bags",
|
||||
label: "Bags",
|
||||
},
|
||||
DataTab {
|
||||
Tab {
|
||||
key: "gear",
|
||||
label: "Gear",
|
||||
},
|
||||
DataTab {
|
||||
Tab {
|
||||
key: "cafes",
|
||||
label: "Cafes",
|
||||
},
|
||||
DataTab {
|
||||
Tab {
|
||||
key: "cups",
|
||||
label: "Cups",
|
||||
},
|
||||
|
|
@ -84,9 +84,9 @@ pub(crate) async fn data_page(
|
|||
return Ok(response);
|
||||
}
|
||||
|
||||
let tabs: Vec<DataTab> = TABS
|
||||
let tabs: Vec<Tab> = TABS
|
||||
.iter()
|
||||
.map(|t| DataTab {
|
||||
.map(|t| Tab {
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
})
|
||||
|
|
@ -98,6 +98,11 @@ pub(crate) async fn data_page(
|
|||
version_info: &crate::VERSION_INFO,
|
||||
active_type: entity_type,
|
||||
tabs,
|
||||
tab_signal: "_active-tab",
|
||||
tab_signal_js: "$_activeTab",
|
||||
tab_base_url: "/data?type=",
|
||||
tab_fetch_target: "#data-content",
|
||||
tab_fetch_mode: "inner",
|
||||
content,
|
||||
search_value,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -134,12 +134,17 @@ pub struct DataTemplate {
|
|||
pub is_authenticated: bool,
|
||||
pub version_info: &'static crate::VersionInfo,
|
||||
pub active_type: String,
|
||||
pub tabs: Vec<DataTab>,
|
||||
pub tabs: Vec<Tab>,
|
||||
pub tab_signal: &'static str,
|
||||
pub tab_signal_js: &'static str,
|
||||
pub tab_base_url: &'static str,
|
||||
pub tab_fetch_target: &'static str,
|
||||
pub tab_fetch_mode: &'static str,
|
||||
pub content: String,
|
||||
pub search_value: String,
|
||||
}
|
||||
|
||||
pub struct DataTab {
|
||||
pub struct Tab {
|
||||
pub key: &'static str,
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
|
@ -151,6 +156,12 @@ pub struct AddTemplate {
|
|||
pub is_authenticated: bool,
|
||||
pub version_info: &'static crate::VersionInfo,
|
||||
pub active_type: String,
|
||||
pub tabs: Vec<Tab>,
|
||||
pub tab_signal: &'static str,
|
||||
pub tab_signal_js: &'static str,
|
||||
pub tab_base_url: &'static str,
|
||||
pub tab_fetch_target: &'static str,
|
||||
pub tab_fetch_mode: &'static str,
|
||||
pub roaster_options: Vec<RoasterOptionView>,
|
||||
pub roast_options: Vec<RoastOptionView>,
|
||||
pub bag_options: Vec<BagOptionView>,
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@
|
|||
|
||||
<!-- Manual Add -->
|
||||
<section
|
||||
data-signals:_add-type="'{{ active_type }}'"
|
||||
data-signals:_add-submitting="false"
|
||||
data-signals:_add-roaster-name="''"
|
||||
data-signals:_add-roaster-country="''"
|
||||
|
|
@ -168,24 +167,9 @@
|
|||
>
|
||||
<h2 class="text-lg font-semibold text-stone-800 mb-3">Manual Add</h2>
|
||||
|
||||
<!-- Entity type selector pills -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{% for (key, label) in [("roaster", "Roaster"), ("roast", "Roast"), ("bag", "Bag"), ("brew", "Brew"), ("gear", "Gear"), ("cafe", "Cafe"), ("cup", "Cup")] %}
|
||||
<button
|
||||
type="button"
|
||||
data-on:click="$_addType = '{{ key }}'"
|
||||
class="rounded-full px-3 py-1.5 text-sm font-medium transition border"
|
||||
data-class:bg-accent="$_addType === '{{ key }}'"
|
||||
data-class:text-accent-text="$_addType === '{{ key }}'"
|
||||
data-class:border-accent="$_addType === '{{ key }}'"
|
||||
data-class:bg-transparent="$_addType !== '{{ key }}'"
|
||||
data-class:text-stone-600="$_addType !== '{{ key }}'"
|
||||
data-class:border="$_addType !== '{{ key }}'"
|
||||
data-class:hover--bg-surface-alt="$_addType !== '{{ key }}'"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
<!-- Entity type selector -->
|
||||
<div class="rounded-lg border bg-surface mb-4">
|
||||
{% include "partials/tab_bar.html" %}
|
||||
</div>
|
||||
|
||||
<!-- ========== ROASTER FORM ========== -->
|
||||
|
|
|
|||
|
|
@ -16,51 +16,8 @@
|
|||
{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="rounded-lg border bg-surface"
|
||||
data-signals:_active-tab="'{{ active_type }}'" data-signals:_tabs-open="false">
|
||||
<!-- Desktop tabs -->
|
||||
<nav class="hidden md:flex flex-wrap gap-1 p-1.5">
|
||||
{% for tab in tabs %}
|
||||
<a
|
||||
href="/data?type={{ tab.key }}"
|
||||
data-class:bg-accent="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:text-white="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:shadow-sm="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'"
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition"
|
||||
data-on:click__prevent="$_activeTab = '{{ tab.key }}'; history.pushState(null, '', '/data?type={{ tab.key }}'); @get('/data?type={{ tab.key }}', {responseOverrides: {selector: '#data-content', mode: 'inner'}})"
|
||||
>{{ tab.label }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<!-- Mobile tab selector -->
|
||||
<div class="md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between p-3 text-sm font-medium text-stone-800"
|
||||
data-on:click="$_tabsOpen = !$_tabsOpen"
|
||||
>
|
||||
<span>
|
||||
{% for tab in tabs %}
|
||||
<span data-show="$_activeTab === '{{ tab.key }}'">{{ tab.label }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
<span data-show="!$_tabsOpen">{% call icons::chevron_down("h-5 w-5") %}</span>
|
||||
<span data-show="$_tabsOpen" style="display:none">{% call icons::chevron_up("h-5 w-5") %}</span>
|
||||
</button>
|
||||
<div class="flex flex-col border-t" data-show="$_tabsOpen" style="display:none">
|
||||
{% for tab in tabs %}
|
||||
<a
|
||||
href="/data?type={{ tab.key }}"
|
||||
data-class:bg-accent-subtle="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:text-stone-800="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:font-semibold="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'"
|
||||
class="px-3 py-2 text-sm transition hover:bg-surface-alt"
|
||||
data-on:click__prevent="$_activeTab = '{{ tab.key }}'; $_tabsOpen = false; history.pushState(null, '', '/data?type={{ tab.key }}'); @get('/data?type={{ tab.key }}', {responseOverrides: {selector: '#data-content', mode: 'inner'}})"
|
||||
>{{ tab.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-surface">
|
||||
{% include "partials/tab_bar.html" %}
|
||||
<!-- Search -->
|
||||
<div class="border-t px-3 py-2">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -141,7 +141,53 @@ a {
|
|||
background-color: var(--surface-alt);
|
||||
}
|
||||
|
||||
/* ── Tabs ──────────────────────────────────────────────────────── */
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab:not(.tab-active):hover {
|
||||
background-color: var(--surface-alt);
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.tab-mobile {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tab-mobile:not(.tab-mobile-active):hover {
|
||||
background-color: var(--surface-alt);
|
||||
}
|
||||
|
||||
.tab-mobile-active {
|
||||
background-color: var(--accent-subtle);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Timeline layout ───────────────────────────────────────────── */
|
||||
|
||||
|
|
|
|||
43
templates/partials/tab_bar.html
Normal file
43
templates/partials/tab_bar.html
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{% import "partials/icons.html" as icons %}
|
||||
|
||||
<div data-signals:{{ tab_signal }}="'{{ active_type }}'" data-signals:_tabs-open="false">
|
||||
<!-- Desktop tabs -->
|
||||
<nav class="hidden md:flex flex-wrap gap-1.5 p-2" role="tablist">
|
||||
{% for tab in tabs %}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
data-class:tab-active="{{ tab_signal_js }} === '{{ tab.key }}'"
|
||||
data-on:click="{{ tab_signal_js }} = '{{ tab.key }}'{% if !tab_base_url.is_empty() %}; history.pushState(null, '', '{{ tab_base_url }}{{ tab.key }}'); @get('{{ tab_base_url }}{{ tab.key }}', {responseOverrides: {selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}}){% endif %}"
|
||||
>{{ tab.label }}</button>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<!-- Mobile tab selector -->
|
||||
<div class="md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between p-3 text-sm font-medium"
|
||||
style="color: var(--text)"
|
||||
data-on:click="$_tabsOpen = !$_tabsOpen"
|
||||
>
|
||||
<span>
|
||||
{% for tab in tabs %}
|
||||
<span data-show="{{ tab_signal_js }} === '{{ tab.key }}'">{{ tab.label }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
<span data-show="!$_tabsOpen">{% call icons::chevron_down("h-5 w-5") %}</span>
|
||||
<span data-show="$_tabsOpen" style="display:none">{% call icons::chevron_up("h-5 w-5") %}</span>
|
||||
</button>
|
||||
<div class="flex flex-col border-t" data-show="$_tabsOpen" style="display:none">
|
||||
{% for tab in tabs %}
|
||||
<button
|
||||
type="button"
|
||||
class="tab-mobile"
|
||||
data-class:tab-mobile-active="{{ tab_signal_js }} === '{{ tab.key }}'"
|
||||
data-on:click="{{ tab_signal_js }} = '{{ tab.key }}'; $_tabsOpen = false{% if !tab_base_url.is_empty() %}; history.pushState(null, '', '{{ tab_base_url }}{{ tab.key }}'); @get('{{ tab_base_url }}{{ tab.key }}', {responseOverrides: {selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}}){% endif %}"
|
||||
>{{ tab.label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Reference in a new issue