Compare commits

..

2 Commits

Author SHA1 Message Date
Nico
c945a71f45 fix: stabilize mobile map toolbar widths 2026-06-04 03:55:53 -07:00
Nico
9390bfdf53 fix: memoize map manager derived data 2026-06-04 03:48:47 -07:00
4 changed files with 126 additions and 72 deletions

View File

@ -24,7 +24,10 @@ export default function LocationMapToolbar({
const showHistoryControls = mode === "edit" && showHistorySlot; const showHistoryControls = mode === "edit" && showHistorySlot;
return ( return (
<section className="location-map-toolbar" aria-label="Map controls"> <section
className={`location-map-toolbar ${showHistorySlot ? "has-history-slot" : ""}`}
aria-label="Map controls"
>
<div className="location-map-mode-group"> <div className="location-map-mode-group">
<div className="location-map-mode-buttons" aria-label="Map mode"> <div className="location-map-mode-buttons" aria-label="Map mode">
<button <button
@ -50,55 +53,55 @@ export default function LocationMapToolbar({
</button> </button>
) : null} ) : null}
</div> </div>
{showHistorySlot ? (
<div
className={`location-map-history-buttons ${showHistoryControls ? "" : "is-reserved"}`}
aria-label={showHistoryControls ? "Edit history" : undefined}
aria-hidden={showHistoryControls ? undefined : "true"}
>
<button
type="button"
onClick={onUndo}
disabled={!showHistoryControls || saving || history.length === 0}
aria-label="Undo"
tabIndex={showHistoryControls ? undefined : -1}
>
<span aria-hidden="true">&#8630;</span>
</button>
<button
type="button"
onClick={onRedo}
disabled={!showHistoryControls || saving || future.length === 0}
aria-label="Redo"
tabIndex={showHistoryControls ? undefined : -1}
>
<span aria-hidden="true">&#8631;</span>
</button>
</div>
) : null}
{mode === "edit" && canManage ? (
<div className="location-map-tool-buttons" aria-label="Edit tool">
<button
type="button"
className={editorTool === "pan" ? "active is-pan" : ""}
onClick={onPanMode}
disabled={saving}
aria-pressed={editorTool === "pan"}
>
Move Map
</button>
<button
type="button"
className={editorTool === "edit" ? "active is-edit" : ""}
onClick={onEditObjects}
disabled={saving}
aria-pressed={editorTool === "edit"}
>
Edit Areas
</button>
</div>
) : null}
</div> </div>
{showHistorySlot ? (
<div
className={`location-map-history-buttons ${showHistoryControls ? "" : "is-reserved"}`}
aria-label={showHistoryControls ? "Edit history" : undefined}
aria-hidden={showHistoryControls ? undefined : "true"}
>
<button
type="button"
onClick={onUndo}
disabled={!showHistoryControls || saving || history.length === 0}
aria-label="Undo"
tabIndex={showHistoryControls ? undefined : -1}
>
<span aria-hidden="true">&#8630;</span>
</button>
<button
type="button"
onClick={onRedo}
disabled={!showHistoryControls || saving || future.length === 0}
aria-label="Redo"
tabIndex={showHistoryControls ? undefined : -1}
>
<span aria-hidden="true">&#8631;</span>
</button>
</div>
) : null}
{mode === "edit" && canManage ? (
<div className="location-map-tool-buttons" aria-label="Edit tool">
<button
type="button"
className={editorTool === "pan" ? "active is-pan" : ""}
onClick={onPanMode}
disabled={saving}
aria-pressed={editorTool === "pan"}
>
Move Map
</button>
<button
type="button"
className={editorTool === "edit" ? "active is-edit" : ""}
onClick={onEditObjects}
disabled={saving}
aria-pressed={editorTool === "edit"}
>
Edit Areas
</button>
</div>
) : null}
<div className="location-map-zoom-controls" aria-label="Zoom controls"> <div className="location-map-zoom-controls" aria-label="Zoom controls">
<button <button

View File

@ -1,4 +1,4 @@
import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useBeforeUnload, useLocation, useNavigate, useParams } from "react-router-dom"; import { useBeforeUnload, useLocation, useNavigate, useParams } from "react-router-dom";
import { import {
createBlankLocationMap, createBlankLocationMap,
@ -38,6 +38,8 @@ import {
} from "../lib/locationMapUtils"; } from "../lib/locationMapUtils";
import "../styles/pages/LocationMapManager.css"; import "../styles/pages/LocationMapManager.css";
const EMPTY_MAP_ITEMS = [];
export default function LocationMapManager() { export default function LocationMapManager() {
const { storeId, locationId } = useParams(); const { storeId, locationId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -77,16 +79,44 @@ export default function LocationMapManager() {
const location = mapState?.location || stores.find((store) => String(store.id) === String(locationId)); const location = mapState?.location || stores.find((store) => String(store.id) === String(locationId));
const canManage = Boolean(mapState?.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role)); const canManage = Boolean(mapState?.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role));
const activeMap = mapForMode(mapState, mode, previewDraft); const activeMap = mapForMode(mapState, mode, previewDraft);
const selectedObject = objects.find((object) => getObjectKey(object) === selectedObjectKey) || null; const mapItems = mapState?.items || EMPTY_MAP_ITEMS;
const selectedZoneItems = selectedObject?.zone_id const selectedObject = useMemo(
? itemsForZone(mapState?.items || [], selectedObject.zone_id, filters, username) () => objects.find((object) => getObjectKey(object) === selectedObjectKey) || null,
: []; [objects, selectedObjectKey]
const visibleUnmappedItems = unmappedItems(mapState?.items || [], filters, username); );
const visibleAssignedItemCount = (mapState?.items || []).filter((item) => ( const selectedZoneItems = useMemo(
item.zone_id && itemMatchesMapFilters(item, filters, username) () => selectedObject?.zone_id
)).length; ? itemsForZone(mapItems, selectedObject.zone_id, filters, username)
const unmappedItemCount = (mapState?.items || []).filter((item) => !item.zone_id).length; : EMPTY_MAP_ITEMS,
const hiddenAreaCount = objects.filter((object) => object.visible === false).length; [filters, mapItems, selectedObject?.zone_id, username]
);
const visibleUnmappedItems = useMemo(
() => unmappedItems(mapItems, filters, username),
[filters, mapItems, username]
);
const { visibleAssignedItemCount, unmappedItemCount } = useMemo(() => {
let nextVisibleAssignedItemCount = 0;
let nextUnmappedItemCount = 0;
mapItems.forEach((item) => {
if (!item.zone_id) {
nextUnmappedItemCount += 1;
return;
}
if (itemMatchesMapFilters(item, filters, username)) {
nextVisibleAssignedItemCount += 1;
}
});
return {
visibleAssignedItemCount: nextVisibleAssignedItemCount,
unmappedItemCount: nextUnmappedItemCount,
};
}, [filters, mapItems, username]);
const hiddenAreaCount = useMemo(
() => objects.filter((object) => object.visible === false).length,
[objects]
);
const shouldGuardLeave = canManage && hasUnsavedChanges; const shouldGuardLeave = canManage && hasUnsavedChanges;
const mapSize = { const mapSize = {
@ -584,7 +614,7 @@ export default function LocationMapManager() {
mapSize={mapSize} mapSize={mapSize}
zoom={zoom} zoom={zoom}
selectedObjectKey={selectedObjectKey} selectedObjectKey={selectedObjectKey}
mapItems={mapState?.items || []} mapItems={mapItems}
username={username} username={username}
svgRef={svgRef} svgRef={svgRef}
dragState={dragState} dragState={dragState}

View File

@ -107,7 +107,6 @@
.location-map-toolbar { .location-map-toolbar {
display: flex; display: flex;
justify-content: space-between;
align-items: flex-start; align-items: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
@ -119,8 +118,6 @@
.location-map-mode-group { .location-map-mode-group {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
min-width: 0; min-width: 0;
} }
@ -225,6 +222,10 @@
text-align: center; text-align: center;
} }
.location-map-zoom-controls {
margin-left: auto;
}
.location-map-label-short { .location-map-label-short {
display: none; display: none;
} }
@ -839,21 +840,24 @@
} }
.location-map-toolbar { .location-map-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
flex-wrap: nowrap;
gap: 0.15rem; gap: 0.15rem;
padding: 0.35rem 0.25rem; padding: 0.35rem 0.25rem;
} }
.location-map-toolbar.has-history-slot {
grid-template-columns: minmax(0, 1fr) 82px auto;
}
.location-map-mode-group { .location-map-mode-group {
flex: 0 0 auto; grid-column: 1;
min-width: 0; min-width: 0;
flex-wrap: nowrap;
gap: 0.15rem;
} }
.location-map-mode-buttons { .location-map-mode-buttons {
flex: 0 0 120px; width: 100%;
min-width: 0; min-width: 0;
padding: 0.18rem; padding: 0.18rem;
} }
@ -874,7 +878,7 @@
} }
.location-map-history-buttons { .location-map-history-buttons {
flex: 0 0 auto; grid-column: 2;
gap: 0.12rem; gap: 0.12rem;
width: 82px; width: 82px;
} }
@ -892,8 +896,14 @@
} }
.location-map-zoom-controls { .location-map-zoom-controls {
flex: 0 0 auto; grid-column: 2;
gap: 0.12rem; gap: 0.12rem;
margin-left: 0;
justify-self: end;
}
.location-map-toolbar.has-history-slot .location-map-zoom-controls {
grid-column: 3;
} }
.location-map-zoom-controls button { .location-map-zoom-controls button {

View File

@ -608,9 +608,20 @@ test("mobile keeps draft preview status compact in the topbar", async ({ page })
await expect(historyActions.getByRole("button", { name: "Undo" })).toBeVisible(); await expect(historyActions.getByRole("button", { name: "Undo" })).toBeVisible();
await expect(historyActions.getByRole("button", { name: "Redo" })).toBeVisible(); await expect(historyActions.getByRole("button", { name: "Redo" })).toBeVisible();
const editModeGroupBox = await modeGroup.boundingBox(); const editModeGroupBox = await modeGroup.boundingBox();
const editModeBox = await modeButtons.boundingBox();
const editHistoryBox = await historyActions.boundingBox();
const editZoomBox = await zoomControls.boundingBox();
expect(editModeGroupBox).not.toBeNull(); expect(editModeGroupBox).not.toBeNull();
if (!editModeGroupBox) throw new Error("Mobile edit controls layout was not measurable"); expect(editModeBox).not.toBeNull();
expect(editHistoryBox).not.toBeNull();
expect(editZoomBox).not.toBeNull();
if (!editModeGroupBox || !editModeBox || !editHistoryBox || !editZoomBox) {
throw new Error("Mobile edit controls layout was not measurable");
}
expect(Math.abs(editModeGroupBox.width - modeGroupBox.width)).toBeLessThanOrEqual(1); expect(Math.abs(editModeGroupBox.width - modeGroupBox.width)).toBeLessThanOrEqual(1);
expect(Math.abs(editModeBox.width - modeBox.width)).toBeLessThanOrEqual(1);
expect(editHistoryBox.x).toBeGreaterThan(editModeBox.x + editModeBox.width);
expect(editZoomBox.x).toBeGreaterThan(editHistoryBox.x + editHistoryBox.width);
}); });
test("admin selecting an object does not mark a draft dirty until it changes", async ({ page }) => { test("admin selecting an object does not mark a draft dirty until it changes", async ({ page }) => {