Files
editor/src/components/LayerListItem.tsx
T
Jamal Ali c0f76dfff8 fix(layer-list): title visibility button with the action it performs (#2135)
- 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.
2026-09-06 00:12:24 +03:00

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>;
});