mirror of
https://github.com/maputnik/editor.git
synced 2026-09-26 14:17:31 +00:00
c0f76dfff8
- Fixes #1675 ## Problem In the layer list, the show/hide button's `title` tooltip named the layer's current visibility instead of what a click does. A visible layer's button read `show` while clicking it hides the layer, and a hidden layer's button read `hide` while clicking it shows the layer — exactly reversed. `LayerListItem.tsx` derived a single value from the layer state: ```ts const visibilityAction = visibility === "visible" ? "show" : "hide"; ``` and passed it to `IconAction` as `action`, which `IconAction` used for three different things: choosing the icon, building the `--show`/`--hide` CSS modifier, and rendering `title={this.props.action}`. The first two are correct as a state indicator; only the tooltip needs the opposite verb. ## Change - `IconAction` gets an optional `title` prop and falls back to `action` when it is not given, so the delete and duplicate buttons are unchanged. - `LayerListItem` keeps `visibilityAction` (icon + `maputnik-layer-list-icon-action__visibility--hide` CSS modifier, which `_layer.scss` relies on to keep the button visible for hidden layers) and adds `visibilityTitle` for the inverted tooltip. No icon, class name or click behaviour changes. ## Test New regression test in `e2e/layers-list.spec.ts`: it asserts the button is titled `hide` while the layer is visible, clicks it, and asserts it is titled `show` once the layer is hidden. Against the unpatched component the first assertion fails with `Expected: "hide"` / `Received: "show"`, reproducing the reported behaviour. `npm run lint`, `npx tsc --noEmit`, `npx vitest run` (50 tests) and `npx playwright test e2e/layers-list.spec.ts` (28 tests) all pass. ## Note The tooltips in this component (`delete`, `duplicate`, `show`, `hide`) are plain English literals and are not run through `t()` today, so this change keeps them as-is rather than introducing translation keys for one button. Also worth flagging separately: these buttons carry `aria-hidden="true"`, so the `title` never reaches assistive technology at all — out of scope here, but it means the label is a mouse-hover tooltip only.
173 lines
5.1 KiB
TypeScript
173 lines
5.1 KiB
TypeScript
import React from "react";
|
|
import classnames from "classnames";
|
|
import { MdContentCopy, MdVisibility, MdVisibilityOff, MdDelete } from "react-icons/md";
|
|
import { IconContext } from "react-icons";
|
|
import { useSortable } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
|
|
import { IconLayer } from "./IconLayer";
|
|
import type { VisibilitySpecification } from "maplibre-gl";
|
|
|
|
|
|
type DraggableLabelProps = {
|
|
layerId: string
|
|
layerType: string
|
|
dragAttributes?: React.HTMLAttributes<HTMLElement>
|
|
dragListeners?: React.HTMLAttributes<HTMLElement>
|
|
};
|
|
|
|
const DraggableLabel: React.FC<DraggableLabelProps> = (props) => {
|
|
const { dragAttributes, dragListeners } = props;
|
|
return <div className="maputnik-layer-list-item-handle" {...dragAttributes} {...dragListeners}>
|
|
<IconLayer
|
|
className="layer-handle__icon"
|
|
type={props.layerType}
|
|
style={{ width: "1em", height: "1em", verticalAlign: "middle" }}
|
|
/>
|
|
<button className="maputnik-layer-list-item-id">
|
|
{props.layerId}
|
|
</button>
|
|
</div>;
|
|
};
|
|
|
|
type IconActionProps = {
|
|
action: string
|
|
/** Tooltip text, for buttons whose action reads differently from their icon. */
|
|
title?: string
|
|
onClick(...args: unknown[]): unknown
|
|
wdKey?: string
|
|
classBlockName?: string
|
|
classBlockModifier?: string
|
|
};
|
|
|
|
class IconAction extends React.Component<IconActionProps> {
|
|
renderIcon() {
|
|
switch (this.props.action) {
|
|
case "duplicate": return <MdContentCopy />;
|
|
case "show": return <MdVisibility />;
|
|
case "hide": return <MdVisibilityOff />;
|
|
case "delete": return <MdDelete />;
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const { classBlockName, classBlockModifier } = this.props;
|
|
|
|
let classAdditions = "";
|
|
if (classBlockName) {
|
|
classAdditions = `maputnik-layer-list-icon-action__${classBlockName}`;
|
|
|
|
if (classBlockModifier) {
|
|
classAdditions += ` maputnik-layer-list-icon-action__${classBlockName}--${classBlockModifier}`;
|
|
}
|
|
}
|
|
|
|
return <button
|
|
tabIndex={-1}
|
|
title={this.props.title ?? this.props.action}
|
|
className={`maputnik-layer-list-icon-action ${classAdditions}`}
|
|
data-wd-key={this.props.wdKey}
|
|
onClick={this.props.onClick}
|
|
aria-hidden="true"
|
|
>
|
|
{this.renderIcon()}
|
|
</button>;
|
|
}
|
|
}
|
|
|
|
type LayerListItemProps = {
|
|
id?: string
|
|
layerIndex: number
|
|
layerId: string
|
|
layerType: string
|
|
isSelected?: boolean
|
|
visibility?: VisibilitySpecification
|
|
className?: string
|
|
onLayerSelect(index: number): void;
|
|
onLayerCopy?(...args: unknown[]): unknown
|
|
onLayerDestroy?(...args: unknown[]): unknown
|
|
onLayerVisibilityToggle?(...args: unknown[]): unknown
|
|
};
|
|
|
|
export const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props, ref) => {
|
|
const {
|
|
isSelected = false,
|
|
visibility = "visible",
|
|
onLayerCopy = () => { },
|
|
onLayerDestroy = () => { },
|
|
onLayerVisibilityToggle = () => { },
|
|
} = props;
|
|
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: props.layerId });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
};
|
|
|
|
// The icon and the CSS modifier describe the layer's current visibility,
|
|
// while the tooltip has to describe what clicking the button does, which is
|
|
// the opposite of it.
|
|
const isVisible = visibility === "visible";
|
|
const visibilityAction = isVisible ? "show" : "hide";
|
|
const visibilityTitle = isVisible ? "hide" : "show";
|
|
|
|
// Cast ref to MutableRefObject since we know from the codebase that's what's always passed
|
|
const refObject = ref as React.MutableRefObject<HTMLLIElement | null> | null;
|
|
|
|
return <IconContext.Provider value={{ size: "14px" }}>
|
|
<li
|
|
ref={(node) => {
|
|
setNodeRef(node);
|
|
if (refObject) {
|
|
refObject.current = node;
|
|
}
|
|
}}
|
|
style={style}
|
|
id={props.id}
|
|
onClick={_e => props.onLayerSelect(props.layerIndex)}
|
|
data-wd-key={"layer-list-item:" + props.layerId}
|
|
className={classnames({
|
|
"maputnik-layer-list-item": true,
|
|
"maputnik-layer-list-item-selected": isSelected,
|
|
[props.className!]: true,
|
|
})}>
|
|
<DraggableLabel
|
|
layerId={props.layerId}
|
|
layerType={props.layerType}
|
|
dragAttributes={attributes}
|
|
dragListeners={listeners}
|
|
/>
|
|
<span style={{ flexGrow: 1 }} />
|
|
<IconAction
|
|
wdKey={"layer-list-item:" + props.layerId + ":delete"}
|
|
action={"delete"}
|
|
classBlockName="delete"
|
|
onClick={_e => onLayerDestroy!(props.layerIndex)}
|
|
/>
|
|
<IconAction
|
|
wdKey={"layer-list-item:" + props.layerId + ":copy"}
|
|
action={"duplicate"}
|
|
classBlockName="duplicate"
|
|
onClick={_e => onLayerCopy!(props.layerIndex)}
|
|
/>
|
|
<IconAction
|
|
wdKey={"layer-list-item:" + props.layerId + ":toggle-visibility"}
|
|
action={visibilityAction}
|
|
title={visibilityTitle}
|
|
classBlockName="visibility"
|
|
classBlockModifier={visibilityAction}
|
|
onClick={_e => onLayerVisibilityToggle!(props.layerIndex)}
|
|
/>
|
|
</li>
|
|
</IconContext.Provider>;
|
|
});
|