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,
|
load_cafe_options, load_roast_options, load_roaster_options,
|
||||||
};
|
};
|
||||||
use crate::application::server::AppState;
|
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;
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct AddQuery {
|
pub(crate) struct AddQuery {
|
||||||
#[serde(rename = "type", default = "default_type")]
|
#[serde(rename = "type", default = "default_type")]
|
||||||
|
|
@ -48,6 +79,18 @@ pub(crate) async fn add_page(
|
||||||
is_authenticated,
|
is_authenticated,
|
||||||
version_info: &crate::VERSION_INFO,
|
version_info: &crate::VERSION_INFO,
|
||||||
active_type: query.entity_type,
|
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,
|
roaster_options,
|
||||||
roast_options,
|
roast_options,
|
||||||
bag_options: brew_form.bag_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::routes::support::{ListQuery, is_datastar_request};
|
||||||
use crate::application::server::AppState;
|
use crate::application::server::AppState;
|
||||||
use crate::presentation::web::templates::{
|
use crate::presentation::web::templates::{
|
||||||
BagListTemplate, BrewListTemplate, CafeListTemplate, CupListTemplate, DataTab, DataTemplate,
|
BagListTemplate, BrewListTemplate, CafeListTemplate, CupListTemplate, DataTemplate,
|
||||||
GearListTemplate, RoastListTemplate, RoasterListTemplate, render_template,
|
GearListTemplate, RoastListTemplate, RoasterListTemplate, Tab, render_template,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABS: &[DataTab] = &[
|
const TABS: &[Tab] = &[
|
||||||
DataTab {
|
Tab {
|
||||||
key: "brews",
|
key: "brews",
|
||||||
label: "Brews",
|
label: "Brews",
|
||||||
},
|
},
|
||||||
DataTab {
|
Tab {
|
||||||
key: "roasters",
|
key: "roasters",
|
||||||
label: "Roasters",
|
label: "Roasters",
|
||||||
},
|
},
|
||||||
DataTab {
|
Tab {
|
||||||
key: "roasts",
|
key: "roasts",
|
||||||
label: "Roasts",
|
label: "Roasts",
|
||||||
},
|
},
|
||||||
DataTab {
|
Tab {
|
||||||
key: "bags",
|
key: "bags",
|
||||||
label: "Bags",
|
label: "Bags",
|
||||||
},
|
},
|
||||||
DataTab {
|
Tab {
|
||||||
key: "gear",
|
key: "gear",
|
||||||
label: "Gear",
|
label: "Gear",
|
||||||
},
|
},
|
||||||
DataTab {
|
Tab {
|
||||||
key: "cafes",
|
key: "cafes",
|
||||||
label: "Cafes",
|
label: "Cafes",
|
||||||
},
|
},
|
||||||
DataTab {
|
Tab {
|
||||||
key: "cups",
|
key: "cups",
|
||||||
label: "Cups",
|
label: "Cups",
|
||||||
},
|
},
|
||||||
|
|
@ -84,9 +84,9 @@ pub(crate) async fn data_page(
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
let tabs: Vec<DataTab> = TABS
|
let tabs: Vec<Tab> = TABS
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| DataTab {
|
.map(|t| Tab {
|
||||||
key: t.key,
|
key: t.key,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
})
|
})
|
||||||
|
|
@ -98,6 +98,11 @@ pub(crate) async fn data_page(
|
||||||
version_info: &crate::VERSION_INFO,
|
version_info: &crate::VERSION_INFO,
|
||||||
active_type: entity_type,
|
active_type: entity_type,
|
||||||
tabs,
|
tabs,
|
||||||
|
tab_signal: "_active-tab",
|
||||||
|
tab_signal_js: "$_activeTab",
|
||||||
|
tab_base_url: "/data?type=",
|
||||||
|
tab_fetch_target: "#data-content",
|
||||||
|
tab_fetch_mode: "inner",
|
||||||
content,
|
content,
|
||||||
search_value,
|
search_value,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -134,12 +134,17 @@ pub struct DataTemplate {
|
||||||
pub is_authenticated: bool,
|
pub is_authenticated: bool,
|
||||||
pub version_info: &'static crate::VersionInfo,
|
pub version_info: &'static crate::VersionInfo,
|
||||||
pub active_type: String,
|
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 content: String,
|
||||||
pub search_value: String,
|
pub search_value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DataTab {
|
pub struct Tab {
|
||||||
pub key: &'static str,
|
pub key: &'static str,
|
||||||
pub label: &'static str,
|
pub label: &'static str,
|
||||||
}
|
}
|
||||||
|
|
@ -151,6 +156,12 @@ pub struct AddTemplate {
|
||||||
pub is_authenticated: bool,
|
pub is_authenticated: bool,
|
||||||
pub version_info: &'static crate::VersionInfo,
|
pub version_info: &'static crate::VersionInfo,
|
||||||
pub active_type: String,
|
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 roaster_options: Vec<RoasterOptionView>,
|
||||||
pub roast_options: Vec<RoastOptionView>,
|
pub roast_options: Vec<RoastOptionView>,
|
||||||
pub bag_options: Vec<BagOptionView>,
|
pub bag_options: Vec<BagOptionView>,
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,6 @@
|
||||||
|
|
||||||
<!-- Manual Add -->
|
<!-- Manual Add -->
|
||||||
<section
|
<section
|
||||||
data-signals:_add-type="'{{ active_type }}'"
|
|
||||||
data-signals:_add-submitting="false"
|
data-signals:_add-submitting="false"
|
||||||
data-signals:_add-roaster-name="''"
|
data-signals:_add-roaster-name="''"
|
||||||
data-signals:_add-roaster-country="''"
|
data-signals:_add-roaster-country="''"
|
||||||
|
|
@ -168,24 +167,9 @@
|
||||||
>
|
>
|
||||||
<h2 class="text-lg font-semibold text-stone-800 mb-3">Manual Add</h2>
|
<h2 class="text-lg font-semibold text-stone-800 mb-3">Manual Add</h2>
|
||||||
|
|
||||||
<!-- Entity type selector pills -->
|
<!-- Entity type selector -->
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<div class="rounded-lg border bg-surface mb-4">
|
||||||
{% for (key, label) in [("roaster", "Roaster"), ("roast", "Roast"), ("bag", "Bag"), ("brew", "Brew"), ("gear", "Gear"), ("cafe", "Cafe"), ("cup", "Cup")] %}
|
{% include "partials/tab_bar.html" %}
|
||||||
<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 %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ========== ROASTER FORM ========== -->
|
<!-- ========== ROASTER FORM ========== -->
|
||||||
|
|
|
||||||
|
|
@ -16,51 +16,8 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="rounded-lg border bg-surface"
|
<div class="rounded-lg border bg-surface">
|
||||||
data-signals:_active-tab="'{{ active_type }}'" data-signals:_tabs-open="false">
|
{% include "partials/tab_bar.html" %}
|
||||||
<!-- 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>
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="border-t px-3 py-2">
|
<div class="border-t px-3 py-2">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,53 @@ a {
|
||||||
background-color: var(--surface-alt);
|
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 ───────────────────────────────────────────── */
|
/* ── 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