feat: preserve BI sub tab history
This commit is contained in:
@@ -190,23 +190,51 @@ export function SegmentedNav<T extends string>({
|
||||
tabs,
|
||||
active,
|
||||
onChange,
|
||||
idPrefix,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: {
|
||||
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
|
||||
active: T;
|
||||
onChange: (id: T) => void;
|
||||
idPrefix: string;
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const activateFromKeyboard = (event: React.KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
|
||||
let nextIndex: number | null = null;
|
||||
if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % tabs.length;
|
||||
if (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
if (event.key === 'Home') nextIndex = 0;
|
||||
if (event.key === 'End') nextIndex = tabs.length - 1;
|
||||
if (nextIndex === null) return;
|
||||
event.preventDefault();
|
||||
const next = tabs[nextIndex];
|
||||
onChange(next.id);
|
||||
window.requestAnimationFrame(() => document.getElementById(`${idPrefix}-tab-${next.id}`)?.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
|
||||
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}>
|
||||
{tabs.map(({ id, label, icon: Icon }) => {
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="grid gap-1"
|
||||
style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{tabs.map(({ id, label, icon: Icon }, index) => {
|
||||
const isActive = active === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
id={`${idPrefix}-tab-${id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`${idPrefix}-panel-${id}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onChange(id)}
|
||||
onKeyDown={event => activateFromKeyboard(event, index)}
|
||||
className={cn(
|
||||
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
|
||||
isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600',
|
||||
|
||||
@@ -24,10 +24,16 @@ export default function ElectricModule() {
|
||||
meta="时间单位清晰标注 · 支持日/总览切换"
|
||||
actions={<BiHeaderActions domain="electric" />}
|
||||
>
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} idPrefix="electric-view" ariaLabel="电能分析视图" />
|
||||
<AnimatePresence mode="wait">
|
||||
<FadeIn key={sub}>
|
||||
<ElectricView sub={sub} />
|
||||
<div
|
||||
id={`electric-view-panel-${sub}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`electric-view-tab-${sub}`}
|
||||
>
|
||||
<ElectricView sub={sub} />
|
||||
</div>
|
||||
</FadeIn>
|
||||
</AnimatePresence>
|
||||
</PageFrame>
|
||||
|
||||
@@ -24,10 +24,16 @@ export default function HydrogenModule() {
|
||||
meta="数据单位清晰标注 · 支持日/总览切换"
|
||||
actions={<BiHeaderActions domain="hydrogen" />}
|
||||
>
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} idPrefix="hydrogen-view" ariaLabel="氢能分析视图" />
|
||||
<AnimatePresence mode="wait">
|
||||
<FadeIn key={sub}>
|
||||
<HydrogenView sub={sub} />
|
||||
<div
|
||||
id={`hydrogen-view-panel-${sub}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`hydrogen-view-tab-${sub}`}
|
||||
>
|
||||
<HydrogenView sub={sub} />
|
||||
</div>
|
||||
</FadeIn>
|
||||
</AnimatePresence>
|
||||
</PageFrame>
|
||||
|
||||
@@ -11,12 +11,14 @@ interface Props<T extends string> {
|
||||
tabs: readonly SubTab<T>[];
|
||||
active: T;
|
||||
onChange: (id: T) => void;
|
||||
idPrefix: string;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
|
||||
export default function SubTabs<T extends string>({ tabs, active, onChange, idPrefix, ariaLabel }: Props<T>) {
|
||||
return (
|
||||
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
|
||||
<SegmentedNav tabs={tabs} active={active} onChange={onChange} />
|
||||
<SegmentedNav tabs={tabs} active={active} onChange={onChange} idPrefix={idPrefix} ariaLabel={ariaLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildHashSubTab, parseHashSubTab } from './useHashSubTab.js';
|
||||
|
||||
const tabs = ['daily', 'overview'] as const;
|
||||
|
||||
test('parses valid sub tabs and falls back to the module default', () => {
|
||||
assert.equal(parseHashSubTab('#electric/overview', 'electric', tabs), 'overview');
|
||||
assert.equal(parseHashSubTab('#electric/unknown', 'electric', tabs), 'daily');
|
||||
assert.equal(parseHashSubTab('#hydrogen/overview', 'electric', tabs), 'daily');
|
||||
});
|
||||
|
||||
test('builds a compact default hash and an explicit secondary hash', () => {
|
||||
assert.equal(buildHashSubTab('electric', 'daily', 'daily'), '#electric');
|
||||
assert.equal(buildHashSubTab('electric', 'overview', 'daily'), '#electric/overview');
|
||||
});
|
||||
@@ -1,5 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function parseHashSubTab<T extends string>(
|
||||
hash: string,
|
||||
moduleId: string,
|
||||
subs: readonly T[],
|
||||
): T {
|
||||
const [first, second] = hash.replace(/^#/, '').split('/');
|
||||
if (first !== moduleId) return subs[0];
|
||||
return second && (subs as readonly string[]).includes(second) ? second as T : subs[0];
|
||||
}
|
||||
|
||||
export function buildHashSubTab<T extends string>(moduleId: string, sub: T, defaultSub: T): string {
|
||||
return sub === defaultSub ? `#${moduleId}` : `#${moduleId}/${sub}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把模块内子 tab 状态同步到 URL hash 二级段。
|
||||
* hash 形如 `#<moduleId>`(= 默认 sub)或 `#<moduleId>/<sub>`。
|
||||
@@ -11,26 +25,25 @@ export function useHashSubTab<T extends string>(
|
||||
): [T, (sub: T) => void] {
|
||||
const defaultSub = subs[0];
|
||||
|
||||
const parse = (): T => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
const [first, second] = hash.split('/');
|
||||
if (first !== moduleId) return defaultSub;
|
||||
if (second && (subs as readonly string[]).includes(second)) return second as T;
|
||||
return defaultSub;
|
||||
};
|
||||
const parse = (): T => parseHashSubTab(window.location.hash, moduleId, subs);
|
||||
|
||||
const [sub, setSubState] = useState<T>(parse);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setSubState(parse());
|
||||
window.addEventListener('hashchange', onChange);
|
||||
return () => window.removeEventListener('hashchange', onChange);
|
||||
window.addEventListener('popstate', onChange);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', onChange);
|
||||
window.removeEventListener('popstate', onChange);
|
||||
};
|
||||
}, [moduleId]);
|
||||
|
||||
const setSub = (next: T) => {
|
||||
if (next === sub) return;
|
||||
const { pathname, search } = window.location;
|
||||
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
|
||||
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
|
||||
const newHash = buildHashSubTab(moduleId, next, defaultSub);
|
||||
window.history.pushState(null, '', `${pathname}${search}${newHash}`);
|
||||
setSubState(next);
|
||||
};
|
||||
|
||||
|
||||
@@ -32,18 +32,30 @@ export default function MileageModule() {
|
||||
compactInfo
|
||||
>
|
||||
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
|
||||
<SegmentedNav tabs={MILEAGE_TABS} active={activeSubTab} onChange={setActiveSubTab} />
|
||||
<SegmentedNav
|
||||
tabs={MILEAGE_TABS}
|
||||
active={activeSubTab}
|
||||
onChange={setActiveSubTab}
|
||||
idPrefix="mileage-view"
|
||||
ariaLabel="里程分析视图"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<FadeIn key={activeSubTab}>
|
||||
{activeSubTab === 'monitoring' ? (
|
||||
<MonitoringView />
|
||||
) : activeSubTab === 'statistics' ? (
|
||||
<StatisticsView />
|
||||
) : (
|
||||
<DailyReportView />
|
||||
)}
|
||||
<div
|
||||
id={`mileage-view-panel-${activeSubTab}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`mileage-view-tab-${activeSubTab}`}
|
||||
>
|
||||
{activeSubTab === 'monitoring' ? (
|
||||
<MonitoringView />
|
||||
) : activeSubTab === 'statistics' ? (
|
||||
<StatisticsView />
|
||||
) : (
|
||||
<DailyReportView />
|
||||
)}
|
||||
</div>
|
||||
</FadeIn>
|
||||
</AnimatePresence>
|
||||
<RotatingFooterHint />
|
||||
|
||||
Reference in New Issue
Block a user