feat(web): add ScrollFade component for improved horizontal scrolling experience
This commit is contained in:
@@ -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>
|
||||
|
||||
54
apps/web/components/scroll-fade.tsx
Normal file
54
apps/web/components/scroll-fade.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user