expense list: clickable month pill + fix scroll-to-month
- make the floating month pill interactive (button role, hover, focus ring) and jump to the active month's header on click/Enter - scroll via a non-sticky month anchor + window.scrollTo instead of scrollIntoView, which no-ops on sticky headers that are always already in view
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { alpha } from "@mui/material/styles";
|
import { alpha } from "@mui/material/styles";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||||||
import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi";
|
import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi";
|
||||||
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
|
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
|
||||||
import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types";
|
import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types";
|
||||||
@@ -139,6 +140,27 @@ export function ExpenseList({ items, fields }: ExpenseListProps) {
|
|||||||
setExpandedId((prev) => (prev === id ? null : id));
|
setExpandedId((prev) => (prev === id ? null : id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const jumpToActiveMonth = useCallback(() => {
|
||||||
|
if (!activeMonth) return;
|
||||||
|
const anchor = listRef.current?.querySelector<HTMLElement>(
|
||||||
|
`[data-month-anchor="${activeMonth}"]`,
|
||||||
|
);
|
||||||
|
if (!anchor) return;
|
||||||
|
const offset = window.matchMedia("(min-width: 900px)").matches ? 64 : 56;
|
||||||
|
const top = anchor.getBoundingClientRect().top + window.scrollY - offset;
|
||||||
|
window.scrollTo({ top, behavior: "smooth" });
|
||||||
|
}, [activeMonth]);
|
||||||
|
|
||||||
|
const handlePillKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
jumpToActiveMonth();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[jumpToActiveMonth],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = listRef.current;
|
const root = listRef.current;
|
||||||
if (!root || groups.length === 0) return;
|
if (!root || groups.length === 0) return;
|
||||||
@@ -179,6 +201,7 @@ export function ExpenseList({ items, fields }: ExpenseListProps) {
|
|||||||
<Box ref={listRef} sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<Box ref={listRef} sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<Box key={group.key}>
|
<Box key={group.key}>
|
||||||
|
<span data-month-anchor={group.key} aria-hidden="true" style={{ display: "block", height: 0 }} />
|
||||||
<Box
|
<Box
|
||||||
data-month-header={group.key}
|
data-month-header={group.key}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -237,25 +260,42 @@ export function ExpenseList({ items, fields }: ExpenseListProps) {
|
|||||||
left: "50%",
|
left: "50%",
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
zIndex: 3,
|
zIndex: 3,
|
||||||
pointerEvents: "none",
|
pointerEvents: activeMonth ? "auto" : "none",
|
||||||
opacity: activeMonth ? 1 : 0,
|
opacity: activeMonth ? 1 : 0,
|
||||||
transition: "opacity 160ms ease",
|
transition: "opacity 160ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={jumpToActiveMonth}
|
||||||
|
onKeyDown={handlePillKeyDown}
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
px: 1.5,
|
px: 1.5,
|
||||||
py: 0.75,
|
py: 0.75,
|
||||||
borderRadius: "999px",
|
borderRadius: "999px",
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.85),
|
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.85),
|
||||||
backdropFilter: "blur(8px)",
|
backdropFilter: "blur(8px)",
|
||||||
border: "1px solid",
|
border: "1px solid",
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
boxShadow: 1,
|
boxShadow: 1,
|
||||||
|
transition: "background-color 160ms ease, transform 160ms ease",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.95),
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
},
|
||||||
|
"&:focus-visible": {
|
||||||
|
outline: "2px solid",
|
||||||
|
outlineColor: "primary.main",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<KeyboardArrowUpIcon sx={{ fontSize: 14, color: "text.secondary" }} />
|
||||||
<Typography variant="caption" fontWeight={700} color="text.secondary">
|
<Typography variant="caption" fontWeight={700} color="text.secondary">
|
||||||
{activeMonth ? monthLabel(activeMonth) : ""}
|
{activeMonth ? monthLabel(activeMonth) : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
Reference in New Issue
Block a user