feat: add draggable glass tab bar and mobile calendar prices
This commit is contained in:
1 parent
40b8d4dc99
commit
f101415722
8 files changed
+412
-57
No files matched your search
+11
-19
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user