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";
import { Plus, Pencil, PlugConnection, TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
import Link from "next/link";
import { ScrollFade } from "@/components/scroll-fade";
import { api, type ChargePoint } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
@@ -45,6 +46,39 @@ const statusDotClass: Record<string, string> = {
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"> = {
Accepted: "success",
Pending: "warning",
@@ -74,7 +108,11 @@ export default function ChargePointsPage() {
const [formBusy, setFormBusy] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
const [deleting, setDeleting] = useState(false);
const { data: chargePoints = [], refetch: refetchList, isFetching: refreshing } = useQuery({
const {
data: chargePoints = [],
refetch: refetchList,
isFetching: refreshing,
} = useQuery({
queryKey: ["chargePoints"],
queryFn: () => api.chargePoints.list().catch(() => []),
refetchInterval: 3_000,
@@ -153,7 +191,14 @@ export default function ChargePointsPage() {
<p className="mt-0.5 text-sm text-muted"> {chargePoints.length} </p>
</div>
<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" : ""}`} />
</Button>
{isAdmin && (
@@ -252,7 +297,7 @@ export default function ChargePointsPage() {
</TextField>
{!isEdit && (
<p className="text-xs text-muted">
Pending Accepted
Pending Accepted 线
</p>
)}
</Modal.Body>
@@ -303,19 +348,28 @@ export default function ChargePointsPage() {
</Table.Row>
)}
{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>
<Link
href={`/dashboard/charge-points/${cp.id}`}
className="font-mono font-medium text-accent hover:underline"
className="font-medium text-accent"
>
{cp.chargePointIdentifier}
</Link>
</Table.Cell>
{isAdmin && (
<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>
)}
@@ -368,34 +422,7 @@ export default function ChargePointsPage() {
)}
</Table.Cell>
<Table.Cell>
<div className="flex flex-wrap gap-1.5">
{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>
<ConnectorCell connectors={cp.connectors} />
</Table.Cell>
{isAdmin && (
<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>
);
}