feat: add draggable glass tab bar and mobile calendar prices

This commit is contained in:
陈煜 committed 2026-10-08 14:14:07 +08:00
1 parent 40b8d4dc99
commit f101415722
8 files changed
+412 -57

No files matched your search

+11 -19
View File
@@ -1,3 +1,4 @@
import { PrimaryNav } from './PrimaryNav';
import { t as tr, useLocale, LanguagePicker } from './i18n';
import {
useState,
@@ -1087,25 +1088,16 @@ export default function App() {
<span>W</span> WorthPath
</div>
<p className="nav-caption">{tr('我的资产空间')}</p>
<nav
className="primary-nav"
aria-label={tr('主导航')}
style={{ gridTemplateColumns: `repeat(${visibleNav.length}, minmax(0, 1fr))` }}
>
{visibleNav.map(([key, Icon, label]) => (
<button
key={key}
className={activeGroup === key ? 'active' : ''}
aria-current={activeGroup === key ? 'page' : undefined}
onClick={() => {
navigateTo(navigationDestination(key, hiddenMenus, user.role)!);
}}
>
<Icon size={19} />
<span>{tr(label)}</span>
</button>
))}
</nav>
<PrimaryNav
active={activeGroup}
label={tr('主导航')}
items={visibleNav.map(([key, Icon, label]) => ({
key,
label: tr(label),
icon: <Icon size={19} />,
select: () => navigateTo(navigationDestination(key, hiddenMenus, user.role)!),
}))}
/>
<div className="side-bottom">
<div className="avatar">{user.username.slice(0, 1).toUpperCase()}</div>
<div>
+70 -37
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { api, money, today } from './api';
import { useToast } from './Toast';
import { t as tr } from './i18n';
import { t as tr, useLocale } from './i18n';
type Day = { date: string; income: string; expense: string; complete: boolean };
type Entry = {
id: string;
@@ -63,6 +63,18 @@ export function Calendar({
active = false;
};
}, [selected, revealed, refreshVersion]);
const locale = useLocale();
const compactPrice = (amount: string) => {
const value = Number(amount);
return new Intl.NumberFormat(
locale === 'en' ? 'en' : locale === 'zh-Hant' ? 'zh-TW' : 'zh-CN',
{
notation: Math.abs(value) >= 10000 ? 'compact' : 'standard',
maximumFractionDigits: Math.abs(value) >= 10000 ? 1 : 2,
useGrouping: false,
},
).format(value);
};
const offset = (new Date(month + '-01T00:00:00Z').getUTCDay() + 6) % 7;
const day = days.find((v) => v.date === selected);
return (
@@ -101,43 +113,64 @@ export function Calendar({
{tr('支出')}
</span>
</div>
<div className="calendar-grid">
{['一', '二', '三', '四', '五', '六', '日'].map((v) => (
<span key={v} className="weekday">
{tr(v)}
</span>
))}
{Array.from({ length: offset }, (_, i) => (
<span key={'blank-' + i} />
))}
{days.map((d) => (
<button
key={d.date}
disabled={d.date > today()}
aria-label={`${d.date} · ${tr('收入')} ${money(d.income, base)} · ${tr('支出')} ${money(d.expense, base)}${!d.complete ? ' · ' + tr('缺少汇率') : ''}`}
aria-pressed={d.date === selected}
className={
'calendar-day ' +
(d.date === selected ? 'selected ' : '') +
(d.date === today() ? 'today' : '')
}
onClick={() => setSelected(d.date)}
>
<strong>{Number(d.date.slice(-2))}</strong>
{Number(d.income) > 0 && (
<small className="calendar-amount income-text">+{money(d.income, base)}</small>
)}
{Number(d.expense) > 0 && (
<small className="calendar-amount danger-text">−{money(d.expense, base)}</small>
)}
<span className="calendar-dots" aria-hidden="true">
{Number(d.income) > 0 && <i className="income-dot" />}
{Number(d.expense) > 0 && <i className="expense-dot" />}
{!d.complete && <i className="incomplete-dot" />}
<div className="calendar-scroll">
<div className="calendar-grid">
{['一', '二', '三', '四', '五', '六', '日'].map((v) => (
<span key={v} className="weekday">
{tr(v)}
</span>
{!d.complete && <small className="calendar-amount">{tr('缺少汇率')}</small>}
</button>
))}
))}
{Array.from({ length: offset }, (_, i) => (
<span key={'blank-' + i} />
))}
{days.map((d) => (
<button
key={d.date}
disabled={d.date > today()}
aria-label={`${d.date} · ${tr('收入')} ${money(d.income, base)} · ${tr('支出')} ${money(d.expense, base)}${!d.complete ? ' · ' + tr('缺少汇率') : ''}`}
aria-pressed={d.date === selected}
className={
'calendar-day ' +
(d.date === selected ? 'selected ' : '') +
(d.date === today() ? 'today' : '')
}
onClick={() => setSelected(d.date)}
>
<strong>{Number(d.date.slice(-2))}</strong>
{Number(d.income) > 0 && (
<>
<small className="calendar-amount income-text">+{money(d.income, base)}</small>
<small className="calendar-mobile-price income-text" aria-hidden="true">
+{Number(d.income) >= 10000 ? '≈' : ''}
{compactPrice(d.income)}
</small>
</>
)}
{Number(d.expense) > 0 && (
<>
<small className="calendar-amount danger-text">−{money(d.expense, base)}</small>
<small className="calendar-mobile-price danger-text" aria-hidden="true">
−{Number(d.expense) >= 10000 ? '≈' : ''}
{compactPrice(d.expense)}
</small>
</>
)}
<span className="calendar-dots" aria-hidden="true">
{Number(d.income) > 0 && <i className="income-dot" />}
{Number(d.expense) > 0 && <i className="expense-dot" />}
{!d.complete && <i className="incomplete-dot" />}
</span>
{!d.complete && (
<>
<small className="calendar-amount">{tr('缺少汇率')}</small>
<small className="calendar-mobile-price" aria-hidden="true">
!
</small>
</>
)}
</button>
))}
</div>
</div>
<div className="calendar-detail">
<div className="panel-title">
+140
View File
@@ -0,0 +1,140 @@
import { useLayoutEffect, useRef, type ReactNode } from 'react';
type Item = { key: string; label: string; icon: ReactNode; select: () => void };
// Native pointer tracking: preserve the grab offset and never animate a gesture.
export function PrimaryNav({
items,
active,
label,
}: {
items: Item[];
active: string;
label: string;
}) {
const nav = useRef<HTMLElement>(null);
const lens = useRef<HTMLSpanElement>(null);
const drag = useRef<{
id: number;
startX: number;
startY: number;
left: number;
moved: boolean;
} | null>(null);
const suppressClick = useRef(false);
const buttons = () =>
Array.from(nav.current?.querySelectorAll<HTMLButtonElement>('button') || []);
const place = (left: number, width: number, height: number) => {
const el = lens.current;
if (el) {
el.style.transform = 'translateX(' + left + 'px)';
el.style.width = width + 'px';
el.style.height = height + 'px';
}
};
const reset = () => {
const button = buttons().find((b) => b.dataset.key === active);
if (button) place(button.offsetLeft, button.offsetWidth, button.offsetHeight);
nav.current?.removeAttribute('data-dragging');
buttons().forEach((b) => b.removeAttribute('data-preview'));
};
useLayoutEffect(() => {
reset();
const observer = new ResizeObserver(() => {
if (!drag.current) reset();
});
if (nav.current) observer.observe(nav.current);
return () => observer.disconnect();
}, [active, items.length]);
const finish = (cancelled: boolean) => {
const gesture = drag.current;
drag.current = null;
if (gesture?.moved && !cancelled) {
const preview = buttons().find((b) => b.dataset.preview === 'true');
const item = items.find((i) => i.key === preview?.dataset.key);
suppressClick.current = true;
item?.select();
}
reset();
};
return (
<nav
ref={nav}
className="primary-nav"
aria-label={label}
style={{ gridTemplateColumns: 'repeat(' + items.length + ', minmax(0, 1fr))' }}
onPointerDown={(e) => {
if (!e.isPrimary || e.button !== 0 || !matchMedia('(max-width: 700px)').matches) return;
suppressClick.current = false;
const current = buttons().find((b) => b.dataset.key === active);
if (current)
drag.current = {
id: e.pointerId,
startX: e.clientX,
startY: e.clientY,
left: current.offsetLeft,
moved: false,
};
}}
onPointerMove={(e) => {
const g = drag.current,
root = nav.current;
if (!g || g.id !== e.pointerId || !root) return;
const dx = e.clientX - g.startX;
if (!g.moved) {
if (Math.abs(e.clientY - g.startY) > Math.abs(dx) && Math.abs(e.clientY - g.startY) > 8) {
finish(true);
return;
}
if (Math.abs(dx) < 8) return;
g.moved = true;
root.setPointerCapture(e.pointerId);
root.dataset.dragging = 'true';
}
const all = buttons(),
first = all[0],
last = all[all.length - 1];
if (!first || !last) return;
const left = Math.max(first.offsetLeft, Math.min(last.offsetLeft, g.left + dx));
place(left, first.offsetWidth, first.offsetHeight);
const center = left + first.offsetWidth / 2;
const closest = all.reduce((best, b) =>
Math.abs(b.offsetLeft + b.offsetWidth / 2 - center) <
Math.abs(best.offsetLeft + best.offsetWidth / 2 - center)
? b
: best,
);
all.forEach((b) => {
b.dataset.preview = String(b === closest);
});
}}
onPointerUp={(e) => {
if (drag.current?.id === e.pointerId) finish(false);
}}
onPointerCancel={() => finish(true)}
onLostPointerCapture={(e) => {
if (e.target === e.currentTarget && drag.current) finish(true);
}}
onClickCapture={(e) => {
if (suppressClick.current && e.detail > 0) {
e.preventDefault();
e.stopPropagation();
suppressClick.current = false;
}
}}
>
<span ref={lens} className="nav-glass-lens" aria-hidden="true" />
{items.map((item) => (
<button
key={item.key}
data-key={item.key}
className={active === item.key ? 'active' : ''}
aria-current={active === item.key ? 'page' : undefined}
onClick={item.select}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</nav>
);
}
+119
View File
@@ -3550,3 +3550,122 @@ html[data-input='pointer'] .calendar-day {
.calendar-page-description {
display: none;
}
/* A soft glass material and a directly draggable selection lens. */
.nav-glass-lens {
display: none;
}
@media (max-width: 700px) {
.sidebar {
background: rgb(255 255 255 / 48%);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-color: #ffffffaa;
box-shadow:
0 8px 28px #00000014,
inset 0 1px 1px #ffffffcc;
}
.primary-nav {
position: relative;
touch-action: pan-y;
isolation: isolate;
}
.primary-nav button {
z-index: 1;
touch-action: pan-y;
}
.primary-nav button.active {
background: transparent;
}
.nav-glass-lens {
display: block;
position: absolute;
left: 0;
top: 5px;
border-radius: 28px;
pointer-events: none;
z-index: 0;
background: rgb(0 122 255 / 10%);
border: 1px solid #ffffff88;
box-shadow:
inset 0 1px 1px #ffffff99,
0 2px 8px #00000008;
}
.primary-nav[data-dragging] button.active {
color: var(--muted);
}
.primary-nav[data-dragging] button[data-preview='true'] {
color: var(--accent);
}
}
@media (max-width: 700px) and (prefers-color-scheme: dark) {
.sidebar {
background: rgb(28 28 30 / 58%);
border-color: #ffffff26;
box-shadow:
0 8px 28px #00000055,
inset 0 1px 1px #ffffff1a;
}
.nav-glass-lens {
background: rgb(10 132 255 / 18%);
border-color: #ffffff22;
box-shadow: inset 0 1px 1px #ffffff22;
}
}
@media (max-width: 700px) and (prefers-reduced-transparency: reduce),
(max-width: 700px) and (prefers-contrast: more) {
.sidebar {
background: var(--surface);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border-color: var(--line);
}
.nav-glass-lens {
background: var(--active);
border-color: var(--accent);
}
}
@media (max-width: 700px) and (forced-colors: active) {
.sidebar {
background: Canvas;
}
.nav-glass-lens {
background: Canvas;
border: 2px solid Highlight;
}
}
.calendar-mobile-price {
display: none;
}
@media (max-width: 700px) {
.calendar-day {
min-height: 76px;
gap: 2px;
}
.calendar-dots {
display: none;
}
.calendar-mobile-price {
display: block;
font-size: clamp(0.5rem, 2.3vw, 0.625rem);
line-height: 1.45;
letter-spacing: -0.03em;
white-space: nowrap;
overflow-wrap: normal;
font-variant-numeric: tabular-nums;
}
.calendar-day.selected .calendar-mobile-price {
color: white !important;
}
}
.calendar-scroll {
overflow-x: auto;
overscroll-behavior-x: contain;
}
@media (max-width: 700px) {
.calendar-grid {
grid-template-columns: repeat(7, minmax(max-content, 1fr));
}
}