feat: preserve BI sub tab history
This commit is contained in:
@@ -190,23 +190,51 @@ export function SegmentedNav<T extends string>({
|
|||||||
tabs,
|
tabs,
|
||||||
active,
|
active,
|
||||||
onChange,
|
onChange,
|
||||||
|
idPrefix,
|
||||||
|
ariaLabel,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
|
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
|
||||||
active: T;
|
active: T;
|
||||||
onChange: (id: T) => void;
|
onChange: (id: T) => void;
|
||||||
|
idPrefix: string;
|
||||||
|
ariaLabel: string;
|
||||||
className?: 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 (
|
return (
|
||||||
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
|
<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))` }}>
|
<div
|
||||||
{tabs.map(({ id, label, icon: Icon }) => {
|
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;
|
const isActive = active === id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
|
id={`${idPrefix}-tab-${id}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-controls={`${idPrefix}-panel-${id}`}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
onClick={() => onChange(id)}
|
onClick={() => onChange(id)}
|
||||||
|
onKeyDown={event => activateFromKeyboard(event, index)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
|
'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',
|
isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600',
|
||||||
|
|||||||
@@ -24,10 +24,16 @@ export default function ElectricModule() {
|
|||||||
meta="时间单位清晰标注 · 支持日/总览切换"
|
meta="时间单位清晰标注 · 支持日/总览切换"
|
||||||
actions={<BiHeaderActions domain="electric" />}
|
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">
|
<AnimatePresence mode="wait">
|
||||||
<FadeIn key={sub}>
|
<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>
|
</FadeIn>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</PageFrame>
|
</PageFrame>
|
||||||
|
|||||||
@@ -24,10 +24,16 @@ export default function HydrogenModule() {
|
|||||||
meta="数据单位清晰标注 · 支持日/总览切换"
|
meta="数据单位清晰标注 · 支持日/总览切换"
|
||||||
actions={<BiHeaderActions domain="hydrogen" />}
|
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">
|
<AnimatePresence mode="wait">
|
||||||
<FadeIn key={sub}>
|
<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>
|
</FadeIn>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</PageFrame>
|
</PageFrame>
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ interface Props<T extends string> {
|
|||||||
tabs: readonly SubTab<T>[];
|
tabs: readonly SubTab<T>[];
|
||||||
active: T;
|
active: T;
|
||||||
onChange: (id: T) => void;
|
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 (
|
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">
|
<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>
|
</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';
|
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 二级段。
|
* 把模块内子 tab 状态同步到 URL hash 二级段。
|
||||||
* hash 形如 `#<moduleId>`(= 默认 sub)或 `#<moduleId>/<sub>`。
|
* hash 形如 `#<moduleId>`(= 默认 sub)或 `#<moduleId>/<sub>`。
|
||||||
@@ -11,26 +25,25 @@ export function useHashSubTab<T extends string>(
|
|||||||
): [T, (sub: T) => void] {
|
): [T, (sub: T) => void] {
|
||||||
const defaultSub = subs[0];
|
const defaultSub = subs[0];
|
||||||
|
|
||||||
const parse = (): T => {
|
const parse = (): T => parseHashSubTab(window.location.hash, moduleId, subs);
|
||||||
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 [sub, setSubState] = useState<T>(parse);
|
const [sub, setSubState] = useState<T>(parse);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChange = () => setSubState(parse());
|
const onChange = () => setSubState(parse());
|
||||||
window.addEventListener('hashchange', onChange);
|
window.addEventListener('hashchange', onChange);
|
||||||
return () => window.removeEventListener('hashchange', onChange);
|
window.addEventListener('popstate', onChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hashchange', onChange);
|
||||||
|
window.removeEventListener('popstate', onChange);
|
||||||
|
};
|
||||||
}, [moduleId]);
|
}, [moduleId]);
|
||||||
|
|
||||||
const setSub = (next: T) => {
|
const setSub = (next: T) => {
|
||||||
|
if (next === sub) return;
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
|
const newHash = buildHashSubTab(moduleId, next, defaultSub);
|
||||||
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
|
window.history.pushState(null, '', `${pathname}${search}${newHash}`);
|
||||||
setSubState(next);
|
setSubState(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,18 +32,30 @@ export default function MileageModule() {
|
|||||||
compactInfo
|
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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<FadeIn key={activeSubTab}>
|
<FadeIn key={activeSubTab}>
|
||||||
{activeSubTab === 'monitoring' ? (
|
<div
|
||||||
<MonitoringView />
|
id={`mileage-view-panel-${activeSubTab}`}
|
||||||
) : activeSubTab === 'statistics' ? (
|
role="tabpanel"
|
||||||
<StatisticsView />
|
aria-labelledby={`mileage-view-tab-${activeSubTab}`}
|
||||||
) : (
|
>
|
||||||
<DailyReportView />
|
{activeSubTab === 'monitoring' ? (
|
||||||
)}
|
<MonitoringView />
|
||||||
|
) : activeSubTab === 'statistics' ? (
|
||||||
|
<StatisticsView />
|
||||||
|
) : (
|
||||||
|
<DailyReportView />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<RotatingFooterHint />
|
<RotatingFooterHint />
|
||||||
|
|||||||
Reference in New Issue
Block a user