Derive the gem-blue palette with OKLCH tonal and complementary-color rules while preserving acrylic opacity, blur and surface hierarchy. Darker focus tokens exposed stacked field rings, fallback outlines and persistent basemap selection rings. Keep fallback focus styles in the base layer and use one themed field boundary or inset card stroke. Preserve keyboard focus, invalid states and native input semantics. Add browser regression coverage for pointer and keyboard focus, all basemap presentations, narrow layouts and rendered contrast. Validation in an isolated checkout: pnpm lint, pnpm typecheck, pnpm test (393 tests), focused browser suites and pnpm build.
121 lines
4.1 KiB
TypeScript
121 lines
4.1 KiB
TypeScript
import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
|
|
import { cn } from "@/shared/ui/cn";
|
|
import {
|
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
|
} from "./map-control-styles";
|
|
|
|
export type MapLayerControlItem = {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
featureCount?: number;
|
|
minZoom?: number;
|
|
visible: boolean;
|
|
disabled?: boolean;
|
|
locked?: boolean;
|
|
group?: string;
|
|
groupLabel?: string;
|
|
groupDescription?: string;
|
|
groupDefaultOpen?: boolean;
|
|
collection?: string;
|
|
collectionLabel?: string;
|
|
visual?: MapLayerControlVisual;
|
|
};
|
|
|
|
export type MapLayerControlVisual = {
|
|
kind: "line" | "line-stack" | "point" | "ring" | "area" | "image";
|
|
color?: string;
|
|
colors?: string[];
|
|
dashed?: boolean;
|
|
imageSrc?: string;
|
|
};
|
|
|
|
type MapLayerControlProps = {
|
|
title?: string;
|
|
items: MapLayerControlItem[];
|
|
onToggleLayer: (id: string, visible: boolean) => void;
|
|
className?: string;
|
|
};
|
|
|
|
export function MapLayerControl({
|
|
title = "图层",
|
|
items,
|
|
onToggleLayer,
|
|
className = ""
|
|
}: MapLayerControlProps) {
|
|
return (
|
|
<section
|
|
aria-label={title}
|
|
className={cn(
|
|
"pointer-events-auto w-[276px] p-3",
|
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
|
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
|
className
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between gap-3 px-1">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-soft)] text-[var(--action-blue-hover)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
|
<Layers3 size={17} aria-hidden="true" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
|
<p className="mt-0.5 text-xs text-[var(--ink-muted)]">
|
|
{items.filter((item) => item.visible).length}/{items.length} 可见
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-1.5">
|
|
{items.map((item) => {
|
|
const Icon = item.visible ? Eye : EyeOff;
|
|
const disabled = item.disabled || item.locked;
|
|
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
disabled={disabled}
|
|
aria-pressed={item.visible}
|
|
onClick={() => onToggleLayer(item.id, !item.visible)}
|
|
className={cn(
|
|
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
|
|
MAP_COMPACT_RADIUS_CLASS_NAME,
|
|
item.visible
|
|
? "surface-control border-[var(--surface-border)] text-slate-900"
|
|
: "surface-well border-transparent text-[var(--ink-muted)] hover:border-[var(--surface-divider)] hover:bg-[var(--surface-reading)]",
|
|
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"grid h-8 w-8 shrink-0 place-items-center",
|
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
|
item.visible ? "bg-[var(--action-blue)] text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]"
|
|
)}
|
|
>
|
|
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate font-semibold">{item.label}</span>
|
|
{item.description ? <span className="block truncate text-xs text-[var(--ink-muted)]">{item.description}</span> : null}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"h-2.5 w-2.5 rounded-full",
|
|
item.visible ? "bg-[var(--action-blue)]" : "bg-slate-300"
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|