feat(web): add ScrollFade component for improved horizontal scrolling experience

This commit is contained in:
2026-03-11 11:49:03 +08:00
parent 1619ed22a0
commit ee329c7b9b
2 changed files with 116 additions and 35 deletions

View File

@@ -16,6 +16,7 @@ import {
} from "@heroui/react"; } from "@heroui/react";
import { Plus, Pencil, PlugConnection, TrashBin, ArrowRotateRight } from "@gravity-ui/icons"; import { Plus, Pencil, PlugConnection, TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
import Link from "next/link"; import Link from "next/link";
import { ScrollFade } from "@/components/scroll-fade";
import { api, type ChargePoint } from "@/lib/api"; import { api, type ChargePoint } from "@/lib/api";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
@@ -45,6 +46,39 @@ const statusDotClass: Record<string, string> = {
Occupied: "bg-warning", Occupied: "bg-warning",
}; };
function ConnectorCell({ connectors }: { connectors: ChargePoint["connectors"] }) {
return (
<ScrollFade maxWidth="max-w-2xs">
{connectors.length === 0 ? (
<span className="text-muted text-sm"></span>
) : (
[...connectors]
.sort((a, b) => a.connectorId - b.connectorId)
.map((conn) => (
<div
key={conn.id}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-secondary px-2 py-1"
>
<PlugConnection className="size-3 shrink-0 text-muted" />
<span className="text-xs font-medium tabular-nums text-muted">
#{conn.connectorId}
</span>
<span className="h-3 w-px bg-border" />
<span
className={`size-1.5 shrink-0 rounded-full ${
statusDotClass[conn.status] ?? "bg-warning"
}`}
/>
<span className="text-xs text-nowrap text-foreground">
{statusLabelMap[conn.status] ?? conn.status}
</span>
</div>
))
)}
</ScrollFade>
);
}
const registrationColorMap: Record<string, "success" | "warning" | "danger"> = { const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
Accepted: "success", Accepted: "success",
Pending: "warning", Pending: "warning",
@@ -74,7 +108,11 @@ export default function ChargePointsPage() {
const [formBusy, setFormBusy] = useState(false); const [formBusy, setFormBusy] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null); const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const { data: chargePoints = [], refetch: refetchList, isFetching: refreshing } = useQuery({ const {
data: chargePoints = [],
refetch: refetchList,
isFetching: refreshing,
} = useQuery({
queryKey: ["chargePoints"], queryKey: ["chargePoints"],
queryFn: () => api.chargePoints.list().catch(() => []), queryFn: () => api.chargePoints.list().catch(() => []),
refetchInterval: 3_000, refetchInterval: 3_000,
@@ -153,7 +191,14 @@ export default function ChargePointsPage() {
<p className="mt-0.5 text-sm text-muted"> {chargePoints.length} </p> <p className="mt-0.5 text-sm text-muted"> {chargePoints.length} </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button isIconOnly size="sm" variant="ghost" isDisabled={refreshing} onPress={() => refetchList()} aria-label="刷新"> <Button
isIconOnly
size="sm"
variant="ghost"
isDisabled={refreshing}
onPress={() => refetchList()}
aria-label="刷新"
>
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} /> <ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button> </Button>
{isAdmin && ( {isAdmin && (
@@ -252,7 +297,7 @@ export default function ChargePointsPage() {
</TextField> </TextField>
{!isEdit && ( {!isEdit && (
<p className="text-xs text-muted"> <p className="text-xs text-muted">
Pending Accepted Pending Accepted 线
</p> </p>
)} )}
</Modal.Body> </Modal.Body>
@@ -303,19 +348,28 @@ export default function ChargePointsPage() {
</Table.Row> </Table.Row>
)} )}
{chargePoints.map((cp) => ( {chargePoints.map((cp) => (
<Table.Row key={cp.id} id={String(cp.id)}> <Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
<Table.Cell> <Table.Cell>
<Link <Link
href={`/dashboard/charge-points/${cp.id}`} href={`/dashboard/charge-points/${cp.id}`}
className="font-mono font-medium text-accent hover:underline" className="font-medium text-accent"
> >
{cp.chargePointIdentifier} {cp.chargePointIdentifier}
</Link> </Link>
</Table.Cell> </Table.Cell>
{isAdmin && ( {isAdmin && (
<Table.Cell> <Table.Cell>
{cp.chargePointVendor && cp.chargePointModel ? ( {cp.chargePointVendor || cp.chargePointModel ? (
`${cp.chargePointVendor} / ${cp.chargePointModel}` <div className="flex flex-col">
{cp.chargePointVendor && (
<span className="text-xs text-muted font-medium">
{cp.chargePointVendor}
</span>
)}
{cp.chargePointModel && (
<span className="text-sm text-foreground">{cp.chargePointModel}</span>
)}
</div>
) : ( ) : (
<span className="text-muted"></span> <span className="text-muted"></span>
)} )}
@@ -368,34 +422,7 @@ export default function ChargePointsPage() {
)} )}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<div className="flex flex-wrap gap-1.5"> <ConnectorCell connectors={cp.connectors} />
{cp.connectors.length === 0 ? (
<span className="text-muted text-sm"></span>
) : (
[...cp.connectors]
.sort((a, b) => a.connectorId - b.connectorId)
.map((conn) => (
<div
key={conn.id}
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-secondary px-2 py-1"
>
<PlugConnection className="size-3 shrink-0 text-muted" />
<span className="text-xs font-medium tabular-nums text-muted">
#{conn.connectorId}
</span>
<span className="h-3 w-px bg-border" />
<span
className={`size-1.5 shrink-0 rounded-full ${
statusDotClass[conn.status] ?? "bg-warning"
}`}
/>
<span className="text-xs text-foreground text-nowrap">
{statusLabelMap[conn.status] ?? conn.status}
</span>
</div>
))
)}
</div>
</Table.Cell> </Table.Cell>
{isAdmin && ( {isAdmin && (
<Table.Cell> <Table.Cell>

View File

@@ -0,0 +1,54 @@
"use client";
import { useRef, useState, useEffect, type ReactNode } from "react";
interface ScrollFadeProps {
children: ReactNode;
className?: string;
/** Tailwind max-width class, e.g. "max-w-2xs". Defaults to none. */
maxWidth?: string;
}
/**
* Wraps children in a horizontally-scrollable container that shows left/right
* gradient fade indicators when there is overflowed content in that direction.
*/
export function ScrollFade({ children, className, maxWidth }: ScrollFadeProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(false);
const update = () => {
const el = scrollRef.current;
if (!el) return;
setShowLeft(el.scrollLeft > 0);
setShowRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
useEffect(() => {
update();
const el = scrollRef.current;
if (!el) return;
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [children]);
return (
<div className={`relative ${maxWidth ?? ""}`}>
<div
ref={scrollRef}
onScroll={update}
className={`flex gap-1.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${className ?? ""}`}
>
{children}
</div>
{showLeft && (
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-linear-to-r from-surface to-transparent group-hover:from-surface-hover" />
)}
{showRight && (
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-linear-to-l from-surface to-transparent group-hover:from-surface-secondary" />
)}
</div>
);
}