Make tabs hash-routable
- New src/lib/router.js: parseHash/ viewToHash for the 5 tab views, with a default fallback for empty/unknown hashes. - App.svelte: initialize viewMode from location.hash, setView() updates both state and the URL hash, and a guarded hashchange listener syncs back/forward and manual URL edits. All tab onclicks route through setView(). - Tests for parse/viewToHash round-trips and fallback. 76 total pass.
This commit is contained in:
parent
7e06c17bfa
commit
36d3625d61
72
dist/index.html
vendored
72
dist/index.html
vendored
@ -8208,6 +8208,42 @@ function AddLocationDialog($$anchor, $$props) {
|
||||
}
|
||||
delegate(["click", "input"]);
|
||||
//#endregion
|
||||
//#region src/lib/router.js
|
||||
/**
|
||||
* Lightweight hash router for the app's tab views.
|
||||
*
|
||||
* Tabs are routable via the URL hash so a user can share/copy a URL for a
|
||||
* specific tab (e.g. #/radar) and use browser back/forward between tabs.
|
||||
* There is no nested route structure — just a single top-level view segment.
|
||||
*/
|
||||
var VALID_VIEWS = [
|
||||
"current",
|
||||
"hourly",
|
||||
"daily",
|
||||
"detail",
|
||||
"radar"
|
||||
];
|
||||
var DEFAULT_VIEW = "current";
|
||||
/**
|
||||
* Parse a location hash into a view id, falling back to the default view.
|
||||
* Accepts '#/hourly', 'hourly', '#hourly', '', '#/unknown', etc.
|
||||
*
|
||||
* @param {string} [hash] - window.location.hash
|
||||
* @returns {string} one of VALID_VIEWS
|
||||
*/
|
||||
function parseHash(hash = "") {
|
||||
const seg = String(hash).replace(/^#\/?/, "").split("/")[0];
|
||||
return VALID_VIEWS.includes(seg) ? seg : DEFAULT_VIEW;
|
||||
}
|
||||
/**
|
||||
* Convert a view id into its URL hash representation.
|
||||
* @param {string} view - one of VALID_VIEWS
|
||||
* @returns {string} e.g. '#/radar'
|
||||
*/
|
||||
function viewToHash(view) {
|
||||
return `#/${view}`;
|
||||
}
|
||||
//#endregion
|
||||
//#region src/App.svelte
|
||||
var root = /* @__PURE__ */ from_html(`<button class="sidebar-overlay svelte-1n46o8q" aria-label="Close sidebar"></button>`);
|
||||
var root_1 = /* @__PURE__ */ from_html(`<span class="current-badge svelte-1n46o8q">📍 Current</span>`);
|
||||
@ -8219,7 +8255,21 @@ var root_6 = /* @__PURE__ */ from_html(`<div class="mobile-nav svelte-1n46o8q"><
|
||||
var root_7 = /* @__PURE__ */ from_html(`<div class="app-shell svelte-1n46o8q"><div class="mobile-header svelte-1n46o8q"><button class="btn-icon" aria-label="Toggle menu"> </button> <span class="mobile-title svelte-1n46o8q"> </span> <button class="btn-icon" aria-label="Settings" title="Settings">⚙️</button> <!></div> <!> <aside><!></aside> <main class="main-content svelte-1n46o8q"><div class="top-bar svelte-1n46o8q"><div class="top-bar-title svelte-1n46o8q"><h1 class="svelte-1n46o8q"> </h1> <!></div> <div class="top-bar-actions svelte-1n46o8q"><!></div></div> <!> <!> <!> <!></main> <!> <!></div>`);
|
||||
function App($$anchor, $$props) {
|
||||
push($$props, true);
|
||||
let viewMode = /* @__PURE__ */ state("current");
|
||||
let viewMode = /* @__PURE__ */ state(proxy(parseHash(typeof window !== "undefined" ? window.location.hash : "")));
|
||||
/** Switch the active tab and reflect it in the URL hash. */
|
||||
function setView(v) {
|
||||
set(viewMode, v, true);
|
||||
if (typeof window !== "undefined") window.location.hash = viewToHash(v);
|
||||
}
|
||||
user_effect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onHash = () => {
|
||||
const v = parseHash(window.location.hash);
|
||||
if (v !== get(viewMode)) set(viewMode, v, true);
|
||||
};
|
||||
window.addEventListener("hashchange", onHash);
|
||||
return () => window.removeEventListener("hashchange", onHash);
|
||||
});
|
||||
let initDone = /* @__PURE__ */ state(false);
|
||||
let geoError = /* @__PURE__ */ state("");
|
||||
user_effect(() => {
|
||||
@ -8449,16 +8499,16 @@ function App($$anchor, $$props) {
|
||||
classes_9 = set_class(button_13, 1, "tab-btn svelte-1n46o8q", null, classes_9, { active: get(viewMode) === "detail" });
|
||||
classes_10 = set_class(button_14, 1, "tab-btn svelte-1n46o8q", null, classes_10, { active: get(viewMode) === "radar" });
|
||||
});
|
||||
delegated("click", button_5, () => set(viewMode, "current"));
|
||||
delegated("click", button_6, () => set(viewMode, "hourly"));
|
||||
delegated("click", button_7, () => set(viewMode, "daily"));
|
||||
delegated("click", button_8, () => set(viewMode, "detail"));
|
||||
delegated("click", button_9, () => set(viewMode, "radar"));
|
||||
delegated("click", button_10, () => set(viewMode, "current"));
|
||||
delegated("click", button_11, () => set(viewMode, "hourly"));
|
||||
delegated("click", button_12, () => set(viewMode, "daily"));
|
||||
delegated("click", button_13, () => set(viewMode, "detail"));
|
||||
delegated("click", button_14, () => set(viewMode, "radar"));
|
||||
delegated("click", button_5, () => setView("current"));
|
||||
delegated("click", button_6, () => setView("hourly"));
|
||||
delegated("click", button_7, () => setView("daily"));
|
||||
delegated("click", button_8, () => setView("detail"));
|
||||
delegated("click", button_9, () => setView("radar"));
|
||||
delegated("click", button_10, () => setView("current"));
|
||||
delegated("click", button_11, () => setView("hourly"));
|
||||
delegated("click", button_12, () => setView("daily"));
|
||||
delegated("click", button_13, () => setView("detail"));
|
||||
delegated("click", button_14, () => setView("radar"));
|
||||
append($$anchor, fragment);
|
||||
};
|
||||
if_block(node_8, ($$render) => {
|
||||
|
||||
@ -10,8 +10,32 @@
|
||||
import NotificationBell from './components/NotificationBell.svelte'
|
||||
import SettingsDialog from './components/SettingsDialog.svelte'
|
||||
import AddLocationDialog from './components/AddLocationDialog.svelte'
|
||||
import { parseHash, viewToHash } from './lib/router.js'
|
||||
|
||||
// Hash-routable tab: initialize from the URL hash so a shared link like
|
||||
// #/radar opens straight to that tab.
|
||||
let viewMode = $state(parseHash(typeof window !== 'undefined' ? window.location.hash : ''))
|
||||
|
||||
/** Switch the active tab and reflect it in the URL hash. */
|
||||
function setView(v) {
|
||||
viewMode = v
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.hash = viewToHash(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep viewMode in sync with the hash — covers browser back/forward and a
|
||||
// manually edited URL. Guarded so we never re-enter on the same value.
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const onHash = () => {
|
||||
const v = parseHash(window.location.hash)
|
||||
if (v !== viewMode) viewMode = v
|
||||
}
|
||||
window.addEventListener('hashchange', onHash)
|
||||
return () => window.removeEventListener('hashchange', onHash)
|
||||
})
|
||||
|
||||
let viewMode = $state('current') // 'current' | 'hourly' | 'daily' | 'detail' | 'radar'
|
||||
let initDone = $state(false)
|
||||
let geoError = $state('')
|
||||
|
||||
@ -186,37 +210,37 @@
|
||||
<button
|
||||
class="nav-btn"
|
||||
class:active={viewMode === 'current'}
|
||||
onclick={() => viewMode = 'current'}
|
||||
onclick={() => setView('current')}
|
||||
>Now</button>
|
||||
<button
|
||||
class="nav-btn"
|
||||
class:active={viewMode === 'hourly'}
|
||||
onclick={() => viewMode = 'hourly'}
|
||||
onclick={() => setView('hourly')}
|
||||
>Hourly</button>
|
||||
<button
|
||||
class="nav-btn"
|
||||
class:active={viewMode === 'daily'}
|
||||
onclick={() => viewMode = 'daily'}
|
||||
onclick={() => setView('daily')}
|
||||
>Daily</button>
|
||||
<button
|
||||
class="nav-btn"
|
||||
class:active={viewMode === 'detail'}
|
||||
onclick={() => viewMode = 'detail'}
|
||||
onclick={() => setView('detail')}
|
||||
>More</button>
|
||||
<button
|
||||
class="nav-btn"
|
||||
class:active={viewMode === 'radar'}
|
||||
onclick={() => viewMode = 'radar'}
|
||||
onclick={() => setView('radar')}
|
||||
>Radar</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop tab bar -->
|
||||
<div class="desktop-tabs">
|
||||
<button class="tab-btn" class:active={viewMode === 'current'} onclick={() => viewMode = 'current'}>Current</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'hourly'} onclick={() => viewMode = 'hourly'}>Hourly</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'daily'} onclick={() => viewMode = 'daily'}>7-Day Forecast</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'detail'} onclick={() => viewMode = 'detail'}>Details</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'radar'} onclick={() => viewMode = 'radar'}>Radar</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'current'} onclick={() => setView('current')}>Current</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'hourly'} onclick={() => setView('hourly')}>Hourly</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'daily'} onclick={() => setView('daily')}>7-Day Forecast</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'detail'} onclick={() => setView('detail')}>Details</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'radar'} onclick={() => setView('radar')}>Radar</button>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
|
||||
32
src/lib/router.js
Normal file
32
src/lib/router.js
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Lightweight hash router for the app's tab views.
|
||||
*
|
||||
* Tabs are routable via the URL hash so a user can share/copy a URL for a
|
||||
* specific tab (e.g. #/radar) and use browser back/forward between tabs.
|
||||
* There is no nested route structure — just a single top-level view segment.
|
||||
*/
|
||||
|
||||
export const VALID_VIEWS = ['current', 'hourly', 'daily', 'detail', 'radar']
|
||||
export const DEFAULT_VIEW = 'current'
|
||||
|
||||
/**
|
||||
* Parse a location hash into a view id, falling back to the default view.
|
||||
* Accepts '#/hourly', 'hourly', '#hourly', '', '#/unknown', etc.
|
||||
*
|
||||
* @param {string} [hash] - window.location.hash
|
||||
* @returns {string} one of VALID_VIEWS
|
||||
*/
|
||||
export function parseHash(hash = '') {
|
||||
const clean = String(hash).replace(/^#\/?/, '')
|
||||
const seg = clean.split('/')[0]
|
||||
return VALID_VIEWS.includes(seg) ? seg : DEFAULT_VIEW
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a view id into its URL hash representation.
|
||||
* @param {string} view - one of VALID_VIEWS
|
||||
* @returns {string} e.g. '#/radar'
|
||||
*/
|
||||
export function viewToHash(view) {
|
||||
return `#/${view}`
|
||||
}
|
||||
34
tests/lib/router.test.js
Normal file
34
tests/lib/router.test.js
Normal file
@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseHash, viewToHash, VALID_VIEWS, DEFAULT_VIEW } from '../../src/lib/router.js'
|
||||
|
||||
describe('parseHash', () => {
|
||||
it('parses a standard hash route', () => {
|
||||
expect(parseHash('#/hourly')).toBe('hourly')
|
||||
expect(parseHash('#/radar')).toBe('radar')
|
||||
})
|
||||
|
||||
it('handles a bare segment without a leading slash', () => {
|
||||
expect(parseHash('daily')).toBe('daily')
|
||||
expect(parseHash('#daily')).toBe('daily')
|
||||
})
|
||||
|
||||
it('falls back to the default view for empty or unknown hashes', () => {
|
||||
expect(parseHash('')).toBe(DEFAULT_VIEW)
|
||||
expect(parseHash('#')).toBe(DEFAULT_VIEW)
|
||||
expect(parseHash('#/nope')).toBe(DEFAULT_VIEW)
|
||||
expect(parseHash('#/current')).toBe('current')
|
||||
})
|
||||
|
||||
it('ignores a trailing path after the view segment', () => {
|
||||
expect(parseHash('#/detail/sub')).toBe('detail')
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewToHash', () => {
|
||||
it('round-trips a valid view', () => {
|
||||
for (const v of VALID_VIEWS) {
|
||||
expect(viewToHash(v)).toBe(`#/${v}`)
|
||||
expect(parseHash(viewToHash(v))).toBe(v)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user