feat(web): add ScrollFade component for improved horizontal scrolling experience
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
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