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:
Jon Seager 2026-02-05 17:23:50 +00:00
parent 70cbb76c3e
commit 4a54834424
No known key found for this signature in database
7 changed files with 168 additions and 79 deletions

View file

@ -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,

View file

@ -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,
};

View file

@ -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>,

View file

@ -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 ========== -->

View file

@ -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

View file

@ -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 ───────────────────────────────────────────── */

View 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>