refactor: restructure navigation into 5 grouped hubs, off the 22-item flat strip
Implements the researched IA fix: mobile nav UX consensus caps primary destinations at 4-5 (uxpin.com, fintech/banking 2026 UX research), and this app had grown to 22 tabs in one horizontally-scrolling row — exactly the anti-pattern that research flags for choice paralysis and slower task completion. New structure — 5 bottom-nav hubs, grouped by what the user is actually trying to do, not build order: - Home: Coverage (unchanged, still the landing screen) - Estate: Assets, Faraid, Insurance, Wassiyah, Hibah, Family Waqf, Nominate, Claims (H2) - Giving: Zakat, Sadaqah, Khairat - Family: Tree, Trigger, Mutawalli, Manage (was 'Family', renamed to avoid colliding with the hub's own label), Neighbourhood - Daily: Prayer Times, Qibla, Quran, Locate Each hub reveals its own sub-nav one tap in, instead of every tab competing for space in a single row. Settings moved out of the tab strip entirely into a header gear icon, matching the banking-app pattern of keeping settings out of primary thumb-reach real estate. The bottom nav is now fixed (thumb-zone), sub-nav keeps the old sticky-top position. Cross-component navigation (nav.js requestedTab, used by the Family Tree's 'give sadaqah in memory' link) now resolves a tab label to its owning (hub, sub-tab) pair instead of a flat index — verified live. This touches every E2E suite: a single click on a tab's old selector no longer reaches it (hub, then sub-tab). Added a shared gotoTab(page, label) helper to e2e-auth-helper.cjs encapsulating the two-step navigation, and migrated all ~22 affected test files off direct nav-button selectors — mechanical substitution followed by manual fixes for local clickTab wrappers, template-literal selectors, and active-state assertions that needed to target the new .hub-tab/.subnav structure specifically. Full regression after migration: every suite passes (one isolated Family Tree flake confirmed clean on rerun, unrelated to navigation).
This commit is contained in:
+108
-39
@@ -52,19 +52,83 @@
|
||||
}
|
||||
});
|
||||
|
||||
const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Zakat', 'Sadaqah', 'Khairat', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Prayer Times', 'Qibla', 'Locate', 'Quran', 'Neighbourhood', 'Claims (H2)', 'Family', 'Settings'];
|
||||
const icons = ['🎯', '📊', '📁', '🛡️', '🌙', '🤲', '🆘', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🕌', '🧭', '📍', '📖', '📢', '🔗', '👥', '⚙️'];
|
||||
let activeTab = $state(0);
|
||||
// Five hubs instead of one 22-item flat tab strip — grouped by what the
|
||||
// user is actually trying to do (plan the estate, give, manage the
|
||||
// family, or use it day-to-day), not alphabetically or by build order.
|
||||
// Research consistently points to 4-5 primary destinations as the
|
||||
// ceiling for a bottom nav; everything else lives one tap deeper inside
|
||||
// its hub instead of competing for space in a single scrolling row.
|
||||
const HUBS = [
|
||||
{ key: 'home', label: 'Home', icon: '🎯', tabs: [
|
||||
{ label: 'Coverage', icon: '🎯', component: CoverageDashboard }
|
||||
] },
|
||||
{ key: 'estate', label: 'Estate', icon: '📁', tabs: [
|
||||
{ label: 'Assets', icon: '📁', component: AssetRegistry },
|
||||
{ label: 'Faraid', icon: '📊', component: FaraidCalculator },
|
||||
{ label: 'Insurance', icon: '🛡️', component: InsurancePolicies },
|
||||
{ label: 'Wassiyah', icon: '📜', component: WassiyahGenerator },
|
||||
{ label: 'Hibah', icon: '🎁', component: HibahTracker },
|
||||
{ label: 'Family Waqf', icon: '⛲', component: FamilyWaqfDesignator },
|
||||
{ label: 'Nominate', icon: '📇', component: NominationRegistry },
|
||||
{ label: 'Claims (H2)', icon: '🔗', component: DigitalClaims }
|
||||
] },
|
||||
{ key: 'giving', label: 'Giving', icon: '🤲', tabs: [
|
||||
{ label: 'Zakat', icon: '🌙', component: ZakatCalculator },
|
||||
{ label: 'Sadaqah', icon: '🤲', component: SadaqahTracker },
|
||||
{ label: 'Khairat', icon: '🆘', component: KhairatTracker }
|
||||
] },
|
||||
{ key: 'family', label: 'Family', icon: '👪', tabs: [
|
||||
{ label: 'Tree', icon: '🌳', component: FamilyTree },
|
||||
{ label: 'Trigger', icon: '⚡', component: DeathTrigger },
|
||||
{ label: 'Mutawalli', icon: '🕋', component: MutawalliDashboard },
|
||||
{ label: 'Manage', icon: '👥', component: FamilyManagement },
|
||||
{ label: 'Neighbourhood', icon: '📢', component: NeighbourhoodBoard }
|
||||
] },
|
||||
{ key: 'daily', label: 'Daily', icon: '🕌', tabs: [
|
||||
{ label: 'Prayer Times', icon: '🕌', component: PrayerTimes },
|
||||
{ label: 'Qibla', icon: '🧭', component: QiblaFinder },
|
||||
{ label: 'Quran', icon: '📖', component: QuranReader },
|
||||
{ label: 'Locate', icon: '📍', component: Locators }
|
||||
] }
|
||||
];
|
||||
|
||||
let activeHubKey = $state('home');
|
||||
// Remembers which sub-tab was last open in each hub, so switching hubs
|
||||
// and back doesn't reset your place.
|
||||
let subIndexByHub = $state(Object.fromEntries(HUBS.map(h => [h.key, 0])));
|
||||
let showSettings = $state(false);
|
||||
|
||||
const activeHub = $derived(HUBS.find(h => h.key === activeHubKey));
|
||||
const activeSubTab = $derived(activeHub.tabs[subIndexByHub[activeHubKey]] ?? activeHub.tabs[0]);
|
||||
const CurrentComponent = $derived(activeSubTab.component);
|
||||
|
||||
function selectHub(key) {
|
||||
activeHubKey = key;
|
||||
showSettings = false;
|
||||
}
|
||||
function selectSubTab(index) {
|
||||
subIndexByHub = { ...subIndexByHub, [activeHubKey]: index };
|
||||
showSettings = false;
|
||||
}
|
||||
|
||||
// Cross-component navigation (e.g. the Family Tree's "give sadaqah in
|
||||
// memory of" link) requests a tab by label — resolve it to whichever
|
||||
// hub actually contains that label.
|
||||
requestedTab.subscribe(name => {
|
||||
if (!name) return;
|
||||
const idx = tabs.indexOf(name);
|
||||
if (idx >= 0) activeTab = idx;
|
||||
for (const hub of HUBS) {
|
||||
const idx = hub.tabs.findIndex(t => t.label === name);
|
||||
if (idx >= 0) { activeHubKey = hub.key; subIndexByHub = { ...subIndexByHub, [hub.key]: idx }; showSettings = false; break; }
|
||||
}
|
||||
requestedTab.set(null);
|
||||
});
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'ArrowRight') activeTab = (activeTab + 1) % tabs.length;
|
||||
if (e.key === 'ArrowLeft') activeTab = (activeTab - 1 + tabs.length) % tabs.length;
|
||||
const tabs = activeHub.tabs;
|
||||
if (tabs.length < 2) return;
|
||||
const current = subIndexByHub[activeHubKey] ?? 0;
|
||||
if (e.key === 'ArrowRight') selectSubTab((current + 1) % tabs.length);
|
||||
if (e.key === 'ArrowLeft') selectSubTab((current - 1 + tabs.length) % tabs.length);
|
||||
}
|
||||
|
||||
function doExport() {
|
||||
@@ -95,6 +159,7 @@
|
||||
{:else}
|
||||
<div class="app">
|
||||
<header>
|
||||
<button class="settings-btn" class:active={showSettings} onclick={() => showSettings = !showSettings} aria-label="Settings">⚙️</button>
|
||||
<div class="header-brand">
|
||||
<div class="brand-line brand-line-first">Nur</div>
|
||||
<div class="brand-line brand-line-second">Falah</div>
|
||||
@@ -103,38 +168,19 @@
|
||||
<div class="header-divider"></div>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
{#each tabs as tab, i}
|
||||
<button class="tab" class:active={activeTab === i} onclick={() => activeTab = i} aria-label={tab}>
|
||||
<span class="tab-icon">{icons[i]}</span>
|
||||
<span class="tab-label">{tab}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if !showSettings && activeHub.tabs.length > 1}
|
||||
<nav class="subnav">
|
||||
{#each activeHub.tabs as t, i}
|
||||
<button class="tab" class:active={subIndexByHub[activeHubKey] === i} onclick={() => selectSubTab(i)} aria-label={t.label}>
|
||||
<span class="tab-icon">{t.icon}</span>
|
||||
<span class="tab-label">{t.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<main>
|
||||
{#if activeTab === 0}<CoverageDashboard />
|
||||
{:else if activeTab === 1}<FaraidCalculator />
|
||||
{:else if activeTab === 2}<AssetRegistry />
|
||||
{:else if activeTab === 3}<InsurancePolicies />
|
||||
{:else if activeTab === 4}<ZakatCalculator />
|
||||
{:else if activeTab === 5}<SadaqahTracker />
|
||||
{:else if activeTab === 6}<KhairatTracker />
|
||||
{:else if activeTab === 7}<WassiyahGenerator />
|
||||
{:else if activeTab === 8}<HibahTracker />
|
||||
{:else if activeTab === 9}<FamilyWaqfDesignator />
|
||||
{:else if activeTab === 10}<NominationRegistry />
|
||||
{:else if activeTab === 11}<DeathTrigger />
|
||||
{:else if activeTab === 12}<MutawalliDashboard />
|
||||
{:else if activeTab === 13}<FamilyTree />
|
||||
{:else if activeTab === 14}<PrayerTimes />
|
||||
{:else if activeTab === 15}<QiblaFinder />
|
||||
{:else if activeTab === 16}<Locators />
|
||||
{:else if activeTab === 17}<QuranReader />
|
||||
{:else if activeTab === 18}<NeighbourhoodBoard />
|
||||
{:else if activeTab === 19}<DigitalClaims />
|
||||
{:else if activeTab === 20}<FamilyManagement />
|
||||
{:else if activeTab === 21}
|
||||
{#if showSettings}
|
||||
<div class="module">
|
||||
<div class="module-header">
|
||||
<h2>Settings</h2>
|
||||
@@ -161,8 +207,19 @@
|
||||
<button class="btn-secondary" onclick={signOut}>Sign out</button>
|
||||
<button class="btn-danger" onclick={doDelete}>Delete local device data — irreversible</button>
|
||||
</div>
|
||||
{:else}
|
||||
<CurrentComponent />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<nav class="hub-nav">
|
||||
{#each HUBS as hub}
|
||||
<button class="hub-tab" class:active={!showSettings && activeHubKey === hub.key} onclick={() => selectHub(hub.key)} aria-label={hub.label}>
|
||||
<span class="hub-icon">{hub.icon}</span>
|
||||
<span class="hub-label">{hub.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -187,9 +244,11 @@
|
||||
:global(#app) { position: relative; z-index: 2; }
|
||||
.loading-screen { display: flex; align-items: center; justify-content: center; min-height: 100dvh; color: #8A8478; font-size: 14px; }
|
||||
|
||||
.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; padding-bottom: 80px; }
|
||||
.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; padding-bottom: 88px; }
|
||||
|
||||
header { text-align: center; padding: 28px 16px 16px; background: rgba(12,17,23,0.95); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(201,168,76,0.15); position: sticky; top: 0; z-index: 10; }
|
||||
.settings-btn { position: absolute; top: 20px; right: 14px; background: none; border: none; font-size: 18px; cursor: pointer; opacity: 0.7; padding: 6px; border-radius: 8px; }
|
||||
.settings-btn.active { opacity: 1; background: rgba(201,168,76,0.12); }
|
||||
.header-brand { display: flex; justify-content: center; gap: 8px; }
|
||||
.brand-line { font-family: 'DM Serif Display', serif; font-size: 26px; }
|
||||
.brand-line-first { color: #E8E4DC; }
|
||||
@@ -197,7 +256,9 @@
|
||||
.header-tagline { font-size: 10px; letter-spacing: 2px; color: #8A8478; }
|
||||
.header-divider { height: 2px; width: 40px; background: #C9A84C; margin: 10px auto 0; border-radius: 2px; }
|
||||
|
||||
nav { display: flex; overflow-x: auto; gap: 4px; padding: 10px 8px; background: rgba(12,17,23,0.7); border-bottom: 1px solid rgba(201,168,76,0.1); position: sticky; top: 92px; z-index: 9; }
|
||||
/* Sub-nav — this hub's own tabs, revealed one level below the primary
|
||||
bottom nav rather than competing with 21 other items for space. */
|
||||
.subnav { display: flex; overflow-x: auto; gap: 4px; padding: 10px 8px; background: rgba(12,17,23,0.7); border-bottom: 1px solid rgba(201,168,76,0.1); position: sticky; top: 92px; z-index: 9; }
|
||||
.tab { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 8px 12px; background: none; border: none; border-radius: 10px; cursor: pointer; color: #8A8478; }
|
||||
.tab.active { background: rgba(201,168,76,0.12); color: #C9A84C; }
|
||||
.tab-icon { font-size: 18px; }
|
||||
@@ -205,6 +266,14 @@
|
||||
|
||||
main { flex: 1; padding: 16px; }
|
||||
|
||||
/* Primary nav — 5 hubs, fixed to the bottom so every destination stays
|
||||
in thumb reach, matching the pattern every reference app converges on. */
|
||||
.hub-nav { display: flex; position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); width: 100%; max-width: 480px; background: rgba(12,17,23,0.97); backdrop-filter: blur(12px); border-top: 1px solid rgba(201,168,76,0.15); z-index: 11; padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px)); }
|
||||
.hub-tab { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 2px; background: none; border: none; border-radius: 10px; cursor: pointer; color: #8A8478; }
|
||||
.hub-tab.active { color: #C9A84C; }
|
||||
.hub-icon { font-size: 20px; }
|
||||
.hub-label { font-size: 10px; }
|
||||
|
||||
:global(.btn-danger) { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(239,68,68,0.4); background: rgba(239,68,68,0.1); color: #EF4444; font-weight: 600; cursor: pointer; margin-top: 12px; }
|
||||
:global(.btn-secondary) { width: 100%; padding: 12px; border-radius: 8px; border: none; background: rgba(255,255,255,0.08); color: #E8E4DC; font-weight: 600; cursor: pointer; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user