@elabs-ai/components-maps 4.2.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/chunk-2FPB53NG.js +36 -0
- package/dist/chunk-2FPB53NG.js.map +1 -0
- package/dist/chunk-2VVJRT66.js +37 -0
- package/dist/chunk-2VVJRT66.js.map +1 -0
- package/dist/chunk-3HXFONRP.js +2 -0
- package/dist/chunk-3HXFONRP.js.map +1 -0
- package/dist/chunk-3SJN3IYT.js +2 -0
- package/dist/chunk-3SJN3IYT.js.map +1 -0
- package/dist/chunk-4DANBD5B.js +55 -0
- package/dist/chunk-4DANBD5B.js.map +1 -0
- package/dist/chunk-5PDVG53B.js +2 -0
- package/dist/chunk-5PDVG53B.js.map +1 -0
- package/dist/chunk-643R7IJC.js +2 -0
- package/dist/chunk-643R7IJC.js.map +1 -0
- package/dist/chunk-B3ZDBQ6I.js +164 -0
- package/dist/chunk-B3ZDBQ6I.js.map +1 -0
- package/dist/chunk-BGCE3RR5.js +35 -0
- package/dist/chunk-BGCE3RR5.js.map +1 -0
- package/dist/chunk-C4VI37TD.js +485 -0
- package/dist/chunk-C4VI37TD.js.map +1 -0
- package/dist/chunk-CJJT6AEI.js +179 -0
- package/dist/chunk-CJJT6AEI.js.map +1 -0
- package/dist/chunk-D2RCPTRO.js +2 -0
- package/dist/chunk-D2RCPTRO.js.map +1 -0
- package/dist/chunk-DTV7JZVN.js +2 -0
- package/dist/chunk-DTV7JZVN.js.map +1 -0
- package/dist/chunk-E65Z3MSV.js +80 -0
- package/dist/chunk-E65Z3MSV.js.map +1 -0
- package/dist/chunk-E7VPNINZ.js +70 -0
- package/dist/chunk-E7VPNINZ.js.map +1 -0
- package/dist/chunk-FHV5IA6N.js +100 -0
- package/dist/chunk-FHV5IA6N.js.map +1 -0
- package/dist/chunk-FQ6V7BFZ.js +347 -0
- package/dist/chunk-FQ6V7BFZ.js.map +1 -0
- package/dist/chunk-G7JDXDJN.js +19 -0
- package/dist/chunk-G7JDXDJN.js.map +1 -0
- package/dist/chunk-GWULZOMW.js +21 -0
- package/dist/chunk-GWULZOMW.js.map +1 -0
- package/dist/chunk-GYDTTAVD.js +120 -0
- package/dist/chunk-GYDTTAVD.js.map +1 -0
- package/dist/chunk-HCOBWIM4.js +48 -0
- package/dist/chunk-HCOBWIM4.js.map +1 -0
- package/dist/chunk-IAHK5MVT.js +2 -0
- package/dist/chunk-IAHK5MVT.js.map +1 -0
- package/dist/chunk-JGJ6442I.js +235 -0
- package/dist/chunk-JGJ6442I.js.map +1 -0
- package/dist/chunk-JYFERXZT.js +18 -0
- package/dist/chunk-JYFERXZT.js.map +1 -0
- package/dist/chunk-LU2UEKFM.js +53 -0
- package/dist/chunk-LU2UEKFM.js.map +1 -0
- package/dist/chunk-LVYM6C5C.js +2 -0
- package/dist/chunk-LVYM6C5C.js.map +1 -0
- package/dist/chunk-MIMDP3EU.js +104 -0
- package/dist/chunk-MIMDP3EU.js.map +1 -0
- package/dist/chunk-NNBURJHY.js +2 -0
- package/dist/chunk-NNBURJHY.js.map +1 -0
- package/dist/chunk-NPUC3V37.js +2 -0
- package/dist/chunk-NPUC3V37.js.map +1 -0
- package/dist/chunk-OEVVNPN3.js +201 -0
- package/dist/chunk-OEVVNPN3.js.map +1 -0
- package/dist/chunk-PF3R2BXM.js +200 -0
- package/dist/chunk-PF3R2BXM.js.map +1 -0
- package/dist/chunk-Q2I5YODW.js +2 -0
- package/dist/chunk-Q2I5YODW.js.map +1 -0
- package/dist/chunk-QDWWP3N7.js +247 -0
- package/dist/chunk-QDWWP3N7.js.map +1 -0
- package/dist/chunk-QU33KPMP.js +2 -0
- package/dist/chunk-QU33KPMP.js.map +1 -0
- package/dist/chunk-RK4BUMHS.js +2 -0
- package/dist/chunk-RK4BUMHS.js.map +1 -0
- package/dist/chunk-RNSLG64F.js +26 -0
- package/dist/chunk-RNSLG64F.js.map +1 -0
- package/dist/chunk-RV673TB4.js +68 -0
- package/dist/chunk-RV673TB4.js.map +1 -0
- package/dist/chunk-S7N2WMVT.js +180 -0
- package/dist/chunk-S7N2WMVT.js.map +1 -0
- package/dist/chunk-T3Z6TCDD.js +2 -0
- package/dist/chunk-T3Z6TCDD.js.map +1 -0
- package/dist/chunk-T4TJTVVA.js +105 -0
- package/dist/chunk-T4TJTVVA.js.map +1 -0
- package/dist/chunk-UMOYRCDX.js +103 -0
- package/dist/chunk-UMOYRCDX.js.map +1 -0
- package/dist/chunk-V2REM6UT.js +185 -0
- package/dist/chunk-V2REM6UT.js.map +1 -0
- package/dist/chunk-VFBRFGID.js +89 -0
- package/dist/chunk-VFBRFGID.js.map +1 -0
- package/dist/chunk-W76NLFER.js +103 -0
- package/dist/chunk-W76NLFER.js.map +1 -0
- package/dist/chunk-WNU4IOGL.js +116 -0
- package/dist/chunk-WNU4IOGL.js.map +1 -0
- package/dist/chunk-WS4Z7ENS.js +494 -0
- package/dist/chunk-WS4Z7ENS.js.map +1 -0
- package/dist/chunk-XQT7YLJY.js +497 -0
- package/dist/chunk-XQT7YLJY.js.map +1 -0
- package/dist/chunk-Y44MEKJV.js +21 -0
- package/dist/chunk-Y44MEKJV.js.map +1 -0
- package/dist/chunk-Z4EPSOB4.js +53 -0
- package/dist/chunk-Z4EPSOB4.js.map +1 -0
- package/dist/chunk-Z5OPDBDC.js +2 -0
- package/dist/chunk-Z5OPDBDC.js.map +1 -0
- package/dist/index.d.ts +1241 -46
- package/dist/index.js +189 -1471
- package/dist/index.js.map +1 -1
- package/dist/lib/arc-math.js +8 -0
- package/dist/lib/arc-math.js.map +1 -0
- package/dist/lib/map-position.js +11 -0
- package/dist/lib/map-position.js.map +1 -0
- package/dist/lib/merge-hover-paint.js +10 -0
- package/dist/lib/merge-hover-paint.js.map +1 -0
- package/dist/lib/plan-crs.js +14 -0
- package/dist/lib/plan-crs.js.map +1 -0
- package/dist/lib/plan-patterns.js +14 -0
- package/dist/lib/plan-patterns.js.map +1 -0
- package/dist/lib/plan-regions.js +18 -0
- package/dist/lib/plan-regions.js.map +1 -0
- package/dist/lib/plan-status.js +14 -0
- package/dist/lib/plan-status.js.map +1 -0
- package/dist/lib/route-arrow.js +12 -0
- package/dist/lib/route-arrow.js.map +1 -0
- package/dist/lib/use-map-breakpoint.js +33 -0
- package/dist/lib/use-map-breakpoint.js.map +1 -0
- package/dist/lib/use-plan-projection.js +9 -0
- package/dist/lib/use-plan-projection.js.map +1 -0
- package/dist/lib/use-token-color.js +10 -0
- package/dist/lib/use-token-color.js.map +1 -0
- package/dist/lib/warn-once.js +10 -0
- package/dist/lib/warn-once.js.map +1 -0
- package/dist/map-annotation/anchor.js +14 -0
- package/dist/map-annotation/anchor.js.map +1 -0
- package/dist/map-annotation/index.js +21 -0
- package/dist/map-annotation/index.js.map +1 -0
- package/dist/map-annotation/map-annotation.js +18 -0
- package/dist/map-annotation/map-annotation.js.map +1 -0
- package/dist/map-arc/index.js +16 -0
- package/dist/map-arc/index.js.map +1 -0
- package/dist/map-arc/map-arc.js +13 -0
- package/dist/map-arc/map-arc.js.map +1 -0
- package/dist/map-canvas/index.css +11 -0
- package/dist/map-canvas/index.css.map +1 -0
- package/dist/map-canvas/index.js +20 -0
- package/dist/map-canvas/index.js.map +1 -0
- package/dist/map-canvas/map-canvas.css +11 -0
- package/dist/map-canvas/map-canvas.css.map +1 -0
- package/dist/map-canvas/map-canvas.js +17 -0
- package/dist/map-canvas/map-canvas.js.map +1 -0
- package/dist/map-canvas/map-context.js +11 -0
- package/dist/map-canvas/map-context.js.map +1 -0
- package/dist/map-canvas/use-resolved-basemap-theme.js +9 -0
- package/dist/map-canvas/use-resolved-basemap-theme.js.map +1 -0
- package/dist/map-cluster-layer/index.js +12 -0
- package/dist/map-cluster-layer/index.js.map +1 -0
- package/dist/map-cluster-layer/map-cluster-layer.js +12 -0
- package/dist/map-cluster-layer/map-cluster-layer.js.map +1 -0
- package/dist/map-controls/index.js +11 -0
- package/dist/map-controls/index.js.map +1 -0
- package/dist/map-controls/map-controls.js +11 -0
- package/dist/map-controls/map-controls.js.map +1 -0
- package/dist/map-geojson/index.js +13 -0
- package/dist/map-geojson/index.js.map +1 -0
- package/dist/map-geojson/map-geojson.js +13 -0
- package/dist/map-geojson/map-geojson.js.map +1 -0
- package/dist/map-inset/index.css +11 -0
- package/dist/map-inset/index.css.map +1 -0
- package/dist/map-inset/index.js +20 -0
- package/dist/map-inset/index.js.map +1 -0
- package/dist/map-inset/map-inset.css +11 -0
- package/dist/map-inset/map-inset.css.map +1 -0
- package/dist/map-inset/map-inset.js +20 -0
- package/dist/map-inset/map-inset.js.map +1 -0
- package/dist/map-legend/index.js +14 -0
- package/dist/map-legend/index.js.map +1 -0
- package/dist/map-legend/map-corner.js +10 -0
- package/dist/map-legend/map-corner.js.map +1 -0
- package/dist/map-legend/map-legend.js +11 -0
- package/dist/map-legend/map-legend.js.map +1 -0
- package/dist/map-marker/index.js +22 -0
- package/dist/map-marker/index.js.map +1 -0
- package/dist/map-marker/map-marker.js +22 -0
- package/dist/map-marker/map-marker.js.map +1 -0
- package/dist/map-north-arrow/index.js +11 -0
- package/dist/map-north-arrow/index.js.map +1 -0
- package/dist/map-north-arrow/map-north-arrow.js +11 -0
- package/dist/map-north-arrow/map-north-arrow.js.map +1 -0
- package/dist/map-plan/index.js +30 -0
- package/dist/map-plan/index.js.map +1 -0
- package/dist/map-plan/map-plan-legend.js +10 -0
- package/dist/map-plan/map-plan-legend.js.map +1 -0
- package/dist/map-plan/map-plan-overlay.js +13 -0
- package/dist/map-plan/map-plan-overlay.js.map +1 -0
- package/dist/map-plan/map-plan-status.js +9 -0
- package/dist/map-plan/map-plan-status.js.map +1 -0
- package/dist/map-plan/map-plan-table.js +10 -0
- package/dist/map-plan/map-plan-table.js.map +1 -0
- package/dist/map-plan/use-plan-selection.js +9 -0
- package/dist/map-plan/use-plan-selection.js.map +1 -0
- package/dist/map-plan-image/index.js +11 -0
- package/dist/map-plan-image/index.js.map +1 -0
- package/dist/map-plan-image/map-plan-image.js +11 -0
- package/dist/map-plan-image/map-plan-image.js.map +1 -0
- package/dist/map-popup/index.js +12 -0
- package/dist/map-popup/index.js.map +1 -0
- package/dist/map-popup/map-popup.js +12 -0
- package/dist/map-popup/map-popup.js.map +1 -0
- package/dist/map-route/index.js +12 -0
- package/dist/map-route/index.js.map +1 -0
- package/dist/map-route/map-route.js +12 -0
- package/dist/map-route/map-route.js.map +1 -0
- package/dist/map-scale-bar/index.js +18 -0
- package/dist/map-scale-bar/index.js.map +1 -0
- package/dist/map-scale-bar/map-scale-bar.js +18 -0
- package/dist/map-scale-bar/map-scale-bar.js.map +1 -0
- package/package.json +5 -5
- package/src/index.ts +83 -0
- package/src/lib/arc-math.test.ts +19 -0
- package/src/lib/arc-math.ts +6 -1
- package/src/lib/map-position.ts +77 -0
- package/src/lib/merge-hover-paint.ts +44 -13
- package/src/lib/plan-crs.parity.test.ts +39 -0
- package/src/lib/plan-crs.test.ts +248 -0
- package/src/lib/plan-crs.ts +252 -0
- package/src/lib/plan-patterns.test.ts +255 -0
- package/src/lib/plan-patterns.ts +160 -0
- package/src/lib/plan-regions.test.ts +186 -0
- package/src/lib/plan-regions.ts +209 -0
- package/src/lib/plan-status.test.ts +87 -0
- package/src/lib/plan-status.ts +147 -0
- package/src/lib/route-arrow.test.ts +88 -0
- package/src/lib/route-arrow.ts +59 -0
- package/src/lib/use-map-breakpoint.test.ts +83 -0
- package/src/lib/use-map-breakpoint.ts +238 -0
- package/src/lib/use-plan-projection.ts +157 -0
- package/src/lib/warn-once.ts +21 -0
- package/src/map-annotation/anchor.ts +92 -0
- package/src/map-annotation/index.ts +15 -0
- package/src/map-annotation/map-annotation.stories.tsx +113 -0
- package/src/map-annotation/map-annotation.test.tsx +106 -0
- package/src/map-annotation/map-annotation.tsx +356 -0
- package/src/map-arc/map-arc.tsx +26 -5
- package/src/map-canvas/map-canvas.stories.tsx +211 -0
- package/src/map-canvas/map-canvas.test.tsx +389 -0
- package/src/map-canvas/map-canvas.tsx +457 -17
- package/src/map-canvas/map-context.ts +11 -0
- package/src/map-cluster-layer/map-cluster-layer.tsx +14 -1
- package/src/map-controls/map-controls.stories.tsx +16 -0
- package/src/map-controls/map-controls.test.tsx +145 -1
- package/src/map-controls/map-controls.tsx +57 -12
- package/src/map-geojson/map-geojson.stories.tsx +153 -3
- package/src/map-geojson/map-geojson.test.tsx +453 -0
- package/src/map-geojson/map-geojson.tsx +591 -51
- package/src/map-inset/index.ts +1 -0
- package/src/map-inset/map-inset.stories.tsx +61 -0
- package/src/map-inset/map-inset.test.tsx +117 -0
- package/src/map-inset/map-inset.tsx +264 -0
- package/src/map-legend/index.ts +10 -0
- package/src/map-legend/map-corner.ts +23 -0
- package/src/map-legend/map-legend.stories.tsx +171 -0
- package/src/map-legend/map-legend.test.tsx +136 -0
- package/src/map-legend/map-legend.tsx +264 -0
- package/src/map-marker/map-marker.stories.tsx +136 -4
- package/src/map-marker/map-marker.test.tsx +328 -3
- package/src/map-marker/map-marker.tsx +370 -33
- package/src/map-north-arrow/index.ts +1 -0
- package/src/map-north-arrow/map-north-arrow.stories.tsx +65 -0
- package/src/map-north-arrow/map-north-arrow.test.tsx +39 -0
- package/src/map-north-arrow/map-north-arrow.tsx +82 -0
- package/src/map-plan/index.ts +13 -0
- package/src/map-plan/map-plan-legend.test.tsx +61 -0
- package/src/map-plan/map-plan-legend.tsx +83 -0
- package/src/map-plan/map-plan-overlay.stories.tsx +260 -0
- package/src/map-plan/map-plan-overlay.test.tsx +302 -0
- package/src/map-plan/map-plan-overlay.tsx +531 -0
- package/src/map-plan/map-plan-status.test.tsx +71 -0
- package/src/map-plan/map-plan-status.tsx +77 -0
- package/src/map-plan/map-plan-table.test.tsx +86 -0
- package/src/map-plan/map-plan-table.tsx +153 -0
- package/src/map-plan/use-plan-selection.ts +78 -0
- package/src/map-plan-image/index.ts +1 -0
- package/src/map-plan-image/map-plan-image.stories.tsx +301 -0
- package/src/map-plan-image/map-plan-image.test.tsx +176 -0
- package/src/map-plan-image/map-plan-image.tsx +279 -0
- package/src/map-popup/map-popup.tsx +15 -10
- package/src/map-route/index.ts +1 -1
- package/src/map-route/map-route.test.tsx +216 -0
- package/src/map-route/map-route.tsx +105 -6
- package/src/map-scale-bar/index.ts +9 -0
- package/src/map-scale-bar/map-scale-bar.stories.tsx +63 -0
- package/src/map-scale-bar/map-scale-bar.test.tsx +65 -0
- package/src/map-scale-bar/map-scale-bar.tsx +145 -0
- package/src/test-utils/locator-fixture.ts +75 -0
- package/src/test-utils/maplibre-mock.ts +256 -19
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
13
|
+
import { useReducedMotion } from "@elabs-ai/components-tokens";
|
|
14
|
+
|
|
15
|
+
import { useMap } from "../map-canvas/map-context";
|
|
16
|
+
import {
|
|
17
|
+
planGroupsFromRegions,
|
|
18
|
+
planRegionCentre,
|
|
19
|
+
type MapPlanGroup,
|
|
20
|
+
type MapPlanRegion,
|
|
21
|
+
} from "../lib/plan-regions";
|
|
22
|
+
import { usePlanProjection, type PlanScreenBox } from "../lib/use-plan-projection";
|
|
23
|
+
import { warnMapOnce } from "../lib/warn-once";
|
|
24
|
+
|
|
25
|
+
/** Beyond this many regions a keyboard walk stops being usable — see `MapPlanOverlay`. */
|
|
26
|
+
const REGION_WARN_CEILING = 500;
|
|
27
|
+
/** Beyond this many members, a group is no better to walk than the whole plan was. */
|
|
28
|
+
const GROUP_MEMBER_CEILING = 64;
|
|
29
|
+
/** A label needs at least this much room, in px, before it is worth drawing. */
|
|
30
|
+
const LABEL_MIN_WIDTH = 44;
|
|
31
|
+
const LABEL_MIN_HEIGHT = 14;
|
|
32
|
+
/** How long the camera takes to bring an off-screen region into view. */
|
|
33
|
+
const TRAVEL_DURATION_MS = 260;
|
|
34
|
+
|
|
35
|
+
/** How the overlay lays its buttons out. */
|
|
36
|
+
export type MapPlanOverlayMode = "regions" | "groups";
|
|
37
|
+
|
|
38
|
+
export type MapPlanOverlayProps = {
|
|
39
|
+
/**
|
|
40
|
+
* The interactive regions, in reading order. Keep the array MEMOIZED — it
|
|
41
|
+
* drives the projection pass.
|
|
42
|
+
*/
|
|
43
|
+
regions: readonly MapPlanRegion[];
|
|
44
|
+
/**
|
|
45
|
+
* Accessible name for the set of regions ("Rooms on level 3"). Supplied by the
|
|
46
|
+
* consumer so it can be localized.
|
|
47
|
+
*/
|
|
48
|
+
label: string;
|
|
49
|
+
/** The selected region id(s) — the same value handed to `<MapGeoJSON selectedId>`. */
|
|
50
|
+
selectedId?: string | readonly string[] | null;
|
|
51
|
+
/** Fired when a region is activated by click, `Enter` or `Space`; `null` on `Escape`. */
|
|
52
|
+
onSelect?: (id: string | null, region: MapPlanRegion | null) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Fired when the pointer or keyboard focus moves to another region. Feed it to
|
|
55
|
+
* `<MapGeoJSON hoveredId>` so the shape and the proxy highlight together.
|
|
56
|
+
*/
|
|
57
|
+
onActiveChange?: (id: string | null, region: MapPlanRegion | null) => void;
|
|
58
|
+
/**
|
|
59
|
+
* `"regions"` (default) puts one button on every region. `"groups"` puts one
|
|
60
|
+
* button on each `group` instead, and `Enter` descends into its members — the
|
|
61
|
+
* answer for a plan with more regions than a keyboard can reasonably walk (a
|
|
62
|
+
* train with 192 seats), where the first question is always "which coach?".
|
|
63
|
+
*/
|
|
64
|
+
mode?: MapPlanOverlayMode;
|
|
65
|
+
/**
|
|
66
|
+
* The accessible description of a group's button — "64 seats", localized by the
|
|
67
|
+
* consumer. Group mode only.
|
|
68
|
+
*/
|
|
69
|
+
describeGroup?: (group: MapPlanGroup) => string;
|
|
70
|
+
/** Draw each region's label in the overlay (default true). */
|
|
71
|
+
showLabels?: boolean;
|
|
72
|
+
/** Hide the labels below this zoom, when a fitted plan is too small to read. */
|
|
73
|
+
labelMinZoom?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Let the regions take the pointer as well as the keyboard (default true).
|
|
76
|
+
* With `false` the overlay is keyboard-only and clicks reach the canvas, where
|
|
77
|
+
* `<MapGeoJSON onClick>` picks the shape itself — the right choice for
|
|
78
|
+
* interlocking, non-rectangular shapes whose boxes overlap.
|
|
79
|
+
*/
|
|
80
|
+
capturePointer?: boolean;
|
|
81
|
+
/** Extra classes for the overlay root. */
|
|
82
|
+
className?: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Reading-order index, for the fallback when no region lies in the arrow's cone. */
|
|
86
|
+
function orderIndex(regions: readonly MapPlanRegion[], id: string | null) {
|
|
87
|
+
return id == null ? -1 : regions.findIndex((region) => region.id === id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type Direction = "start" | "end" | "up" | "down";
|
|
91
|
+
|
|
92
|
+
const DIRECTION_VECTORS: Record<Direction, [number, number]> = {
|
|
93
|
+
end: [1, 0],
|
|
94
|
+
start: [-1, 0],
|
|
95
|
+
up: [0, -1],
|
|
96
|
+
down: [0, 1],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The nearest region centre inside a ±45° cone — how a sighted user reads an
|
|
101
|
+
* arrow key on a plan. Falls back to reading order, so an arrow always moves.
|
|
102
|
+
*/
|
|
103
|
+
function nearestInDirection(
|
|
104
|
+
regions: readonly MapPlanRegion[],
|
|
105
|
+
fromId: string,
|
|
106
|
+
direction: Direction,
|
|
107
|
+
flipY: boolean,
|
|
108
|
+
): MapPlanRegion | null {
|
|
109
|
+
const from = regions.find((region) => region.id === fromId);
|
|
110
|
+
if (!from) return null;
|
|
111
|
+
|
|
112
|
+
const centre = (region: MapPlanRegion) => {
|
|
113
|
+
const point = planRegionCentre(region.bounds);
|
|
114
|
+
return { x: point.x, y: flipY ? -point.y : point.y };
|
|
115
|
+
};
|
|
116
|
+
const origin = centre(from);
|
|
117
|
+
const [dirX, dirY] = DIRECTION_VECTORS[direction];
|
|
118
|
+
|
|
119
|
+
let best: MapPlanRegion | null = null;
|
|
120
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
121
|
+
|
|
122
|
+
for (const region of regions) {
|
|
123
|
+
if (region.id === fromId) continue;
|
|
124
|
+
const target = centre(region);
|
|
125
|
+
const dx = target.x - origin.x;
|
|
126
|
+
const dy = target.y - origin.y;
|
|
127
|
+
const along = dx * dirX + dy * dirY;
|
|
128
|
+
if (along <= 0) continue;
|
|
129
|
+
// cos(45°) — inside the cone when the along-axis share dominates.
|
|
130
|
+
const distance = Math.hypot(dx, dy);
|
|
131
|
+
if (along / distance < Math.SQRT1_2) continue;
|
|
132
|
+
if (distance < bestDistance) {
|
|
133
|
+
bestDistance = distance;
|
|
134
|
+
best = region;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (best) return best;
|
|
139
|
+
|
|
140
|
+
const index = orderIndex(regions, fromId);
|
|
141
|
+
const step = direction === "end" || direction === "down" ? 1 : -1;
|
|
142
|
+
return regions[index + step] ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The keyboard and label layer of a plan. MapLibre draws every shape into one
|
|
147
|
+
* WebGL canvas, so a room is not a DOM element and cannot be focused, named or
|
|
148
|
+
* given a pressed state. This overlay puts one real `<button aria-pressed>` over
|
|
149
|
+
* each region — the shapes stay in WebGL, the semantics come back to the DOM.
|
|
150
|
+
*
|
|
151
|
+
* ```tsx
|
|
152
|
+
* <MapCanvas blank plan={{ width: 2400, height: 1600, unit: "cm" }}>
|
|
153
|
+
* <MapGeoJSON data={rooms} promoteId="id" selectedId={selection.selectedIds} hoveredId={active} />
|
|
154
|
+
* <MapPlanOverlay
|
|
155
|
+
* regions={regions}
|
|
156
|
+
* label={t("floor.rooms")}
|
|
157
|
+
* selectedId={selection.selectedIds}
|
|
158
|
+
* onSelect={(id) => selection.select(id)}
|
|
159
|
+
* onActiveChange={setActive}
|
|
160
|
+
* />
|
|
161
|
+
* </MapCanvas>
|
|
162
|
+
* ```
|
|
163
|
+
*
|
|
164
|
+
* Roughly 250 regions stay comfortable and 500 is the hard ceiling: past that a
|
|
165
|
+
* `Tab`-and-arrow walk is no longer a usable way to reach one seat, and the
|
|
166
|
+
* regions want `mode="groups"` instead — one button per coach or wing, `Enter`
|
|
167
|
+
* to go in, `Escape` to come back out, `PageUp`/`PageDown` to change group at
|
|
168
|
+
* either level.
|
|
169
|
+
*/
|
|
170
|
+
export function MapPlanOverlay({
|
|
171
|
+
regions,
|
|
172
|
+
label,
|
|
173
|
+
selectedId,
|
|
174
|
+
onSelect,
|
|
175
|
+
onActiveChange,
|
|
176
|
+
mode = "regions",
|
|
177
|
+
describeGroup,
|
|
178
|
+
showLabels = true,
|
|
179
|
+
labelMinZoom,
|
|
180
|
+
capturePointer = true,
|
|
181
|
+
className,
|
|
182
|
+
}: MapPlanOverlayProps) {
|
|
183
|
+
const { map, plan, loading } = useMap();
|
|
184
|
+
const reducedMotion = useReducedMotion();
|
|
185
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
186
|
+
/** Group mode only: which group the keyboard is inside, if any. */
|
|
187
|
+
const [openGroupKey, setOpenGroupKey] = useState<string | null>(null);
|
|
188
|
+
|
|
189
|
+
const buttonsRef = useRef(new Map<string, HTMLButtonElement>());
|
|
190
|
+
const labelsRef = useRef(new Map<string, HTMLElement>());
|
|
191
|
+
/**
|
|
192
|
+
* A button to focus once it exists. Changing group level swaps the whole set of
|
|
193
|
+
* buttons, so the target is not in the DOM when the key is pressed — and a
|
|
194
|
+
* `requestAnimationFrame` would drop the focus on the floor whenever the frame
|
|
195
|
+
* lands after the next keystroke.
|
|
196
|
+
*/
|
|
197
|
+
const pendingFocusRef = useRef<string | null>(null);
|
|
198
|
+
|
|
199
|
+
const selectedIds = useMemo(() => {
|
|
200
|
+
if (selectedId == null) return [] as string[];
|
|
201
|
+
return Array.isArray(selectedId) ? [...selectedId] : [selectedId as string];
|
|
202
|
+
}, [selectedId]);
|
|
203
|
+
|
|
204
|
+
const groups = useMemo(
|
|
205
|
+
() => (mode === "groups" ? planGroupsFromRegions(regions) : []),
|
|
206
|
+
[mode, regions],
|
|
207
|
+
);
|
|
208
|
+
const openGroup = useMemo(
|
|
209
|
+
() => groups.find((group) => group.key === openGroupKey) ?? null,
|
|
210
|
+
[groups, openGroupKey],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* What actually gets a button: every region, the groups, or one group's members.
|
|
215
|
+
* Everything downstream — the projection pass, the arrow keys, the roving tab
|
|
216
|
+
* stop — works from this one list.
|
|
217
|
+
*/
|
|
218
|
+
const visibleRegions = useMemo<readonly MapPlanRegion[]>(() => {
|
|
219
|
+
if (mode !== "groups") return regions;
|
|
220
|
+
if (openGroup) return openGroup.members;
|
|
221
|
+
return groups.map((group) => {
|
|
222
|
+
const description = describeGroup?.(group);
|
|
223
|
+
return description ? { ...group.region, description } : group.region;
|
|
224
|
+
});
|
|
225
|
+
}, [describeGroup, groups, mode, openGroup, regions]);
|
|
226
|
+
|
|
227
|
+
const groupByRegionId = useMemo(() => {
|
|
228
|
+
const index = new Map<string, MapPlanGroup>();
|
|
229
|
+
for (const group of groups) index.set(group.region.id, group);
|
|
230
|
+
return index;
|
|
231
|
+
}, [groups]);
|
|
232
|
+
|
|
233
|
+
if (!plan) {
|
|
234
|
+
warnMapOnce(
|
|
235
|
+
"plan-overlay-no-plan",
|
|
236
|
+
"<MapPlanOverlay> needs a plan coordinate system: set `plan` on <MapCanvas>.",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (mode === "regions" && regions.length > REGION_WARN_CEILING) {
|
|
240
|
+
warnMapOnce(
|
|
241
|
+
"plan-overlay-region-count",
|
|
242
|
+
`<MapPlanOverlay> has ${regions.length} regions. Past ~${REGION_WARN_CEILING} a keyboard walk stops being a usable way to reach one of them — set mode="groups" instead.`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const biggestGroup = groups.reduce((most, group) => Math.max(most, group.members.length), 0);
|
|
246
|
+
if (biggestGroup > GROUP_MEMBER_CEILING) {
|
|
247
|
+
warnMapOnce(
|
|
248
|
+
"plan-overlay-group-size",
|
|
249
|
+
`<MapPlanOverlay> has a group of ${biggestGroup} regions. Past ~${GROUP_MEMBER_CEILING} members a group is no easier to walk than the whole plan was — split it.`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const callbacksRef = useRef({ onSelect, onActiveChange });
|
|
254
|
+
callbacksRef.current = { onSelect, onActiveChange };
|
|
255
|
+
|
|
256
|
+
const regionById = useMemo(() => {
|
|
257
|
+
const index = new Map<string, MapPlanRegion>();
|
|
258
|
+
for (const region of visibleRegions) index.set(region.id, region);
|
|
259
|
+
return index;
|
|
260
|
+
}, [visibleRegions]);
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Labels are sized in the same pass that places the boxes: a label that cannot
|
|
264
|
+
* fit its region is hidden rather than allowed to spill over the shape next
|
|
265
|
+
* door, and it shrinks to `shortLabel` before it disappears.
|
|
266
|
+
*/
|
|
267
|
+
const handleFrame = useCallback(
|
|
268
|
+
(boxes: ReadonlyMap<string, PlanScreenBox>) => {
|
|
269
|
+
if (!showLabels) return;
|
|
270
|
+
const zoom = map?.getZoom?.();
|
|
271
|
+
const belowZoom = labelMinZoom !== undefined && zoom !== undefined && zoom < labelMinZoom;
|
|
272
|
+
|
|
273
|
+
for (const [id, box] of boxes) {
|
|
274
|
+
const element = labelsRef.current.get(id);
|
|
275
|
+
if (!element) continue;
|
|
276
|
+
const region = regionById.get(id);
|
|
277
|
+
const fits = !belowZoom && box.width >= LABEL_MIN_WIDTH && box.height >= LABEL_MIN_HEIGHT;
|
|
278
|
+
element.style.visibility = fits ? "visible" : "hidden";
|
|
279
|
+
if (!fits || !region) continue;
|
|
280
|
+
const short = region.shortLabel ?? region.label;
|
|
281
|
+
// A rough per-character estimate is enough here: it only decides which
|
|
282
|
+
// of two strings to draw, and a wrong guess still truncates cleanly.
|
|
283
|
+
const roomFor = Math.floor(box.width / 7);
|
|
284
|
+
element.textContent = region.label.length <= roomFor ? region.label : short;
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
[labelMinZoom, map, regionById, showLabels],
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const { register, refresh } = usePlanProjection({
|
|
291
|
+
map,
|
|
292
|
+
plan,
|
|
293
|
+
regions: visibleRegions,
|
|
294
|
+
enabled: !!plan,
|
|
295
|
+
onFrame: handleFrame,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Focus whatever a level change asked for, in the commit that created it.
|
|
299
|
+
useLayoutEffect(() => {
|
|
300
|
+
const id = pendingFocusRef.current;
|
|
301
|
+
if (id == null) return;
|
|
302
|
+
pendingFocusRef.current = null;
|
|
303
|
+
buttonsRef.current.get(id)?.focus();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Re-run the pass when the label rules change, so a zoom threshold or a
|
|
307
|
+
// switched-off label takes effect without waiting for the next map move.
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
refresh();
|
|
310
|
+
}, [refresh, showLabels, labelMinZoom, visibleRegions]);
|
|
311
|
+
|
|
312
|
+
const setActive = useCallback(
|
|
313
|
+
(id: string | null) => {
|
|
314
|
+
setActiveId(id);
|
|
315
|
+
callbacksRef.current.onActiveChange?.(id, id ? (regionById.get(id) ?? null) : null);
|
|
316
|
+
},
|
|
317
|
+
[regionById],
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
/** Move focus, and bring the region into view when it sits off-screen. */
|
|
321
|
+
const focusRegion = useCallback(
|
|
322
|
+
(region: MapPlanRegion | null) => {
|
|
323
|
+
if (!region) return;
|
|
324
|
+
setActive(region.id);
|
|
325
|
+
buttonsRef.current.get(region.id)?.focus();
|
|
326
|
+
|
|
327
|
+
if (!map || !plan) return;
|
|
328
|
+
const canvas = map.getCanvas?.();
|
|
329
|
+
const width = canvas?.clientWidth ?? 0;
|
|
330
|
+
const height = canvas?.clientHeight ?? 0;
|
|
331
|
+
const centre = planRegionCentre(region.bounds);
|
|
332
|
+
const point = map.project(plan.toLngLat(centre));
|
|
333
|
+
const outside = point.x < 0 || point.y < 0 || point.x > width || point.y > height;
|
|
334
|
+
if (!outside) return;
|
|
335
|
+
|
|
336
|
+
map.easeTo({
|
|
337
|
+
center: plan.toLngLat(centre),
|
|
338
|
+
duration: reducedMotion ? 0 : TRAVEL_DURATION_MS,
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
[map, plan, reducedMotion, setActive],
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const flipY = plan?.extent.origin === "bottom-left";
|
|
345
|
+
|
|
346
|
+
/** Open a group and put focus on its first member. */
|
|
347
|
+
const enterGroup = useCallback(
|
|
348
|
+
(group: MapPlanGroup) => {
|
|
349
|
+
const first = group.members[0];
|
|
350
|
+
setOpenGroupKey(group.key);
|
|
351
|
+
if (!first) return;
|
|
352
|
+
pendingFocusRef.current = first.id;
|
|
353
|
+
setActive(first.id);
|
|
354
|
+
},
|
|
355
|
+
[setActive],
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
/** Leave the open group and put focus back on the group's own button. */
|
|
359
|
+
const leaveGroup = useCallback(() => {
|
|
360
|
+
const key = openGroupKey;
|
|
361
|
+
if (key == null) return false;
|
|
362
|
+
setOpenGroupKey(null);
|
|
363
|
+
const groupRegionId = groups.find((group) => group.key === key)?.region.id;
|
|
364
|
+
if (groupRegionId) {
|
|
365
|
+
pendingFocusRef.current = groupRegionId;
|
|
366
|
+
setActive(groupRegionId);
|
|
367
|
+
}
|
|
368
|
+
return true;
|
|
369
|
+
}, [groups, openGroupKey, setActive]);
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* A button press means "go in" on a group and "select" on a region — the one
|
|
373
|
+
* place the two levels of group mode differ.
|
|
374
|
+
*/
|
|
375
|
+
const activate = useCallback(
|
|
376
|
+
(region: MapPlanRegion) => {
|
|
377
|
+
const group = groupByRegionId.get(region.id);
|
|
378
|
+
if (group) {
|
|
379
|
+
enterGroup(group);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
callbacksRef.current.onSelect?.(region.id, region);
|
|
383
|
+
},
|
|
384
|
+
[enterGroup, groupByRegionId],
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
/** `PageUp`/`PageDown`: the group before or after this one, at either level. */
|
|
388
|
+
const stepGroup = useCallback(
|
|
389
|
+
(step: 1 | -1, fromRegionId: string) => {
|
|
390
|
+
if (groups.length === 0) return;
|
|
391
|
+
const fromKey =
|
|
392
|
+
openGroupKey ??
|
|
393
|
+
groups.find((group) => group.region.id === fromRegionId)?.key ??
|
|
394
|
+
groups[0]!.key;
|
|
395
|
+
const index = groups.findIndex((group) => group.key === fromKey);
|
|
396
|
+
const next = groups[index + step];
|
|
397
|
+
if (!next) return;
|
|
398
|
+
if (openGroupKey != null) enterGroup(next);
|
|
399
|
+
else focusRegion(next.region);
|
|
400
|
+
},
|
|
401
|
+
[enterGroup, focusRegion, groups, openGroupKey],
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const handleKeyDown = useCallback(
|
|
405
|
+
(event: ReactKeyboardEvent<HTMLButtonElement>, region: MapPlanRegion) => {
|
|
406
|
+
const direction: Direction | null =
|
|
407
|
+
event.key === "ArrowRight"
|
|
408
|
+
? "end"
|
|
409
|
+
: event.key === "ArrowLeft"
|
|
410
|
+
? "start"
|
|
411
|
+
: event.key === "ArrowUp"
|
|
412
|
+
? "up"
|
|
413
|
+
: event.key === "ArrowDown"
|
|
414
|
+
? "down"
|
|
415
|
+
: null;
|
|
416
|
+
|
|
417
|
+
if (direction) {
|
|
418
|
+
event.preventDefault();
|
|
419
|
+
focusRegion(nearestInDirection(visibleRegions, region.id, direction, !!flipY));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (event.key === "Home" || event.key === "End") {
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
focusRegion((event.key === "Home" ? visibleRegions[0] : visibleRegions.at(-1)) ?? null);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (event.key === "PageUp" || event.key === "PageDown") {
|
|
430
|
+
if (groups.length === 0) return;
|
|
431
|
+
event.preventDefault();
|
|
432
|
+
stepGroup(event.key === "PageDown" ? 1 : -1, region.id);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (event.key === "Escape") {
|
|
437
|
+
event.preventDefault();
|
|
438
|
+
// Inside a group, Escape steps back OUT of it; otherwise it clears the
|
|
439
|
+
// selection. One key, the level it is on.
|
|
440
|
+
if (leaveGroup()) return;
|
|
441
|
+
callbacksRef.current.onSelect?.(null, null);
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
[flipY, focusRegion, groups.length, leaveGroup, stepGroup, visibleRegions],
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
// Roving tabindex: exactly one stop into the set, then arrows inside it.
|
|
448
|
+
const tabStopId =
|
|
449
|
+
activeId ??
|
|
450
|
+
visibleRegions.find((region) => selectedIds.includes(region.id))?.id ??
|
|
451
|
+
visibleRegions[0]?.id ??
|
|
452
|
+
null;
|
|
453
|
+
|
|
454
|
+
return (
|
|
455
|
+
<div
|
|
456
|
+
data-slot="map-plan-overlay"
|
|
457
|
+
// The overlay sits over the canvas but under the loading veil, so a
|
|
458
|
+
// keyboard user never lands on a button hidden behind it.
|
|
459
|
+
className={cn("pointer-events-none absolute inset-0", loading && "invisible", className)}
|
|
460
|
+
>
|
|
461
|
+
{/* `role="list"` is explicit: `display: contents` drops list semantics in
|
|
462
|
+
some engines, and the set needs its name to survive. */}
|
|
463
|
+
<ul data-slot="map-plan-overlay-list" role="list" aria-label={label} className="contents">
|
|
464
|
+
{visibleRegions.map((region) => {
|
|
465
|
+
const isGroup = groupByRegionId.has(region.id);
|
|
466
|
+
const isSelected = !isGroup && selectedIds.includes(region.id);
|
|
467
|
+
const accessibleName = region.description
|
|
468
|
+
? `${region.label}, ${region.description}`
|
|
469
|
+
: region.label;
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<li
|
|
473
|
+
key={region.id}
|
|
474
|
+
ref={(element) => register(region.id, element)}
|
|
475
|
+
// Placed by the projection pass; `absolute` with no inset so the
|
|
476
|
+
// transform it writes is the only source of position.
|
|
477
|
+
className="pointer-events-none absolute"
|
|
478
|
+
>
|
|
479
|
+
<button
|
|
480
|
+
type="button"
|
|
481
|
+
ref={(element) => {
|
|
482
|
+
if (element) buttonsRef.current.set(region.id, element);
|
|
483
|
+
else buttonsRef.current.delete(region.id);
|
|
484
|
+
}}
|
|
485
|
+
data-slot="map-plan-overlay-region"
|
|
486
|
+
data-selected={isSelected || undefined}
|
|
487
|
+
data-group={isGroup || undefined}
|
|
488
|
+
// A group's button is not a toggle: pressing it replaces the
|
|
489
|
+
// group buttons with its members, and `aria-expanded` would then
|
|
490
|
+
// describe a control that is no longer on screen.
|
|
491
|
+
aria-pressed={isGroup ? undefined : isSelected}
|
|
492
|
+
tabIndex={region.id === tabStopId ? 0 : -1}
|
|
493
|
+
className={cn(
|
|
494
|
+
"absolute inset-0 rounded-sm focus-ring",
|
|
495
|
+
capturePointer ? "pointer-events-auto" : "pointer-events-none",
|
|
496
|
+
)}
|
|
497
|
+
onFocus={() => setActive(region.id)}
|
|
498
|
+
onBlur={() => setActive(null)}
|
|
499
|
+
onPointerEnter={() => setActive(region.id)}
|
|
500
|
+
onPointerLeave={() => setActive(null)}
|
|
501
|
+
onClick={() => activate(region)}
|
|
502
|
+
onKeyDown={(event) => handleKeyDown(event, region)}
|
|
503
|
+
>
|
|
504
|
+
<span className="sr-only">{accessibleName}</span>
|
|
505
|
+
</button>
|
|
506
|
+
{showLabels && (
|
|
507
|
+
<span
|
|
508
|
+
ref={(element) => {
|
|
509
|
+
if (element) labelsRef.current.set(region.id, element);
|
|
510
|
+
else labelsRef.current.delete(region.id);
|
|
511
|
+
}}
|
|
512
|
+
data-slot="map-plan-overlay-label"
|
|
513
|
+
// Aria-hidden and a SIBLING of the button: the button already
|
|
514
|
+
// says the label as the start of its accessible name (WCAG
|
|
515
|
+
// 2.5.3), and nesting it would say it twice.
|
|
516
|
+
aria-hidden="true"
|
|
517
|
+
className={cn(
|
|
518
|
+
"pointer-events-none absolute inset-0 m-auto grid h-fit w-fit max-w-full",
|
|
519
|
+
"place-items-center truncate rounded-sm bg-card px-1 text-meta text-foreground",
|
|
520
|
+
)}
|
|
521
|
+
>
|
|
522
|
+
{region.label}
|
|
523
|
+
</span>
|
|
524
|
+
)}
|
|
525
|
+
</li>
|
|
526
|
+
);
|
|
527
|
+
})}
|
|
528
|
+
</ul>
|
|
529
|
+
</div>
|
|
530
|
+
);
|
|
531
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { MapPlanStatus } from "./map-plan-status";
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
cleanup();
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("MapPlanStatus", () => {
|
|
16
|
+
it("is one polite live region, silent on screen by default", () => {
|
|
17
|
+
render(<MapPlanStatus message="Nothing selected" />);
|
|
18
|
+
|
|
19
|
+
const region = screen.getByRole("status");
|
|
20
|
+
expect(region).toHaveAttribute("aria-live", "polite");
|
|
21
|
+
expect(region).toHaveTextContent("Nothing selected");
|
|
22
|
+
expect(region.className).toContain("sr-only");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("says the first message straight away", () => {
|
|
26
|
+
const { rerender } = render(<MapPlanStatus message="Nothing selected" />);
|
|
27
|
+
rerender(<MapPlanStatus message="Cell A1 selected" />);
|
|
28
|
+
|
|
29
|
+
expect(screen.getByRole("status")).toHaveTextContent("Cell A1 selected");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("coalesces a burst into one sentence, the newest one", () => {
|
|
33
|
+
const { rerender } = render(<MapPlanStatus message="idle" coalesceMs={2000} />);
|
|
34
|
+
rerender(<MapPlanStatus message="Cell A1 stopped" />);
|
|
35
|
+
expect(screen.getByRole("status")).toHaveTextContent("Cell A1 stopped");
|
|
36
|
+
|
|
37
|
+
// Three changes inside the quiet window: the region must not read all three.
|
|
38
|
+
rerender(<MapPlanStatus message="Cell A2 stopped" />);
|
|
39
|
+
rerender(<MapPlanStatus message="Cell A3 stopped" />);
|
|
40
|
+
rerender(<MapPlanStatus message="Cell A4 stopped" />);
|
|
41
|
+
expect(screen.getByRole("status")).toHaveTextContent("Cell A1 stopped");
|
|
42
|
+
|
|
43
|
+
act(() => {
|
|
44
|
+
vi.advanceTimersByTime(2000);
|
|
45
|
+
});
|
|
46
|
+
expect(screen.getByRole("status")).toHaveTextContent("Cell A4 stopped");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("drops a pending announcement when it unmounts", () => {
|
|
50
|
+
const { rerender, unmount } = render(<MapPlanStatus message="idle" />);
|
|
51
|
+
rerender(<MapPlanStatus message="first" />);
|
|
52
|
+
rerender(<MapPlanStatus message="second" />);
|
|
53
|
+
|
|
54
|
+
unmount();
|
|
55
|
+
|
|
56
|
+
// A timer that fires after teardown would set state on a gone component.
|
|
57
|
+
expect(() => {
|
|
58
|
+
act(() => {
|
|
59
|
+
vi.advanceTimersByTime(4000);
|
|
60
|
+
});
|
|
61
|
+
}).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("can also be shown, for a readout beside the plan", () => {
|
|
65
|
+
render(<MapPlanStatus message="Cell A1 selected" visible />);
|
|
66
|
+
|
|
67
|
+
const region = screen.getByRole("status");
|
|
68
|
+
expect(region.className).not.toContain("sr-only");
|
|
69
|
+
expect(region).toHaveTextContent("Cell A1 selected");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
|
+
|
|
6
|
+
/** Nothing is announced more often than this, however fast the data changes. */
|
|
7
|
+
const DEFAULT_COALESCE_MS = 2000;
|
|
8
|
+
|
|
9
|
+
export interface MapPlanStatusProps {
|
|
10
|
+
/**
|
|
11
|
+
* The sentence to announce. Change it when something a person needs to hear
|
|
12
|
+
* changed — a selection, a status transition — never on every tick of a number.
|
|
13
|
+
*/
|
|
14
|
+
message: string;
|
|
15
|
+
/** Milliseconds between announcements (default 2000). */
|
|
16
|
+
coalesceMs?: number;
|
|
17
|
+
/** Also show the message (default false: it is an announcement, not chrome). */
|
|
18
|
+
visible?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ONE live region per plan. A WebGL canvas announces nothing by itself, and a
|
|
24
|
+
* plan that changes — a machine goes down, a room frees up — has to say so
|
|
25
|
+
* without stealing focus.
|
|
26
|
+
*
|
|
27
|
+
* Announcements are coalesced: a live region that re-reads a ticking number is
|
|
28
|
+
* worse than silence, because it talks over everything else the person is doing.
|
|
29
|
+
*/
|
|
30
|
+
export function MapPlanStatus({
|
|
31
|
+
message,
|
|
32
|
+
coalesceMs = DEFAULT_COALESCE_MS,
|
|
33
|
+
visible = false,
|
|
34
|
+
className,
|
|
35
|
+
}: MapPlanStatusProps) {
|
|
36
|
+
const [announced, setAnnounced] = useState(message);
|
|
37
|
+
const lastAtRef = useRef(0);
|
|
38
|
+
const pendingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (message === announced) return;
|
|
42
|
+
|
|
43
|
+
const elapsed = Date.now() - lastAtRef.current;
|
|
44
|
+
if (elapsed >= coalesceMs) {
|
|
45
|
+
lastAtRef.current = Date.now();
|
|
46
|
+
setAnnounced(message);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Inside the quiet window: hold the newest message and say it when the
|
|
51
|
+
// window closes, so a burst of changes becomes one sentence.
|
|
52
|
+
if (pendingRef.current) clearTimeout(pendingRef.current);
|
|
53
|
+
pendingRef.current = setTimeout(() => {
|
|
54
|
+
lastAtRef.current = Date.now();
|
|
55
|
+
pendingRef.current = null;
|
|
56
|
+
setAnnounced(message);
|
|
57
|
+
}, coalesceMs - elapsed);
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
if (pendingRef.current) {
|
|
61
|
+
clearTimeout(pendingRef.current);
|
|
62
|
+
pendingRef.current = null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}, [message, announced, coalesceMs]);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<p
|
|
69
|
+
data-slot="map-plan-status"
|
|
70
|
+
role="status"
|
|
71
|
+
aria-live="polite"
|
|
72
|
+
className={cn(visible ? "text-caption text-muted-foreground" : "sr-only", className)}
|
|
73
|
+
>
|
|
74
|
+
{announced}
|
|
75
|
+
</p>
|
|
76
|
+
);
|
|
77
|
+
}
|