@humanforest/ui 0.1.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.
Files changed (235) hide show
  1. package/LICENSE +67 -0
  2. package/icons/forest/bike-asterisk.svg +21 -0
  3. package/icons/forest/bike-down.svg +19 -0
  4. package/icons/forest/bike-off.svg +20 -0
  5. package/icons/forest/bike-up.svg +19 -0
  6. package/icons/forest/bike-x.svg +19 -0
  7. package/icons/forest/bike.svg +18 -0
  8. package/icons/forest/map/forest-place-count.svg +21 -0
  9. package/icons/forest/map/forest-place-marker.svg +17 -0
  10. package/icons/forest/map/forest-vehicle-marker.svg +73 -0
  11. package/icons/forest/map/manifest.json +373 -0
  12. package/icons/forest/map/place-count-manifest.json +83 -0
  13. package/icons/forest/map/place-manifest.json +74 -0
  14. package/icons/forest/mark-fill.svg +6 -0
  15. package/icons/forest/mark.svg +6 -0
  16. package/icons/forest/parking.svg +3 -0
  17. package/icons/forest/star-fill.svg +4 -0
  18. package/icons/forest/star.svg +4 -0
  19. package/icons/forest/traffic-light-caution.svg +8 -0
  20. package/icons/forest/traffic-light-go.svg +8 -0
  21. package/icons/forest/traffic-light-lit.svg +8 -0
  22. package/icons/forest/traffic-light-stop.svg +8 -0
  23. package/icons/forest/traffic-light.svg +8 -0
  24. package/package.json +64 -0
  25. package/src/address/address.ts +125 -0
  26. package/src/address/autofill.ts +78 -0
  27. package/src/address/geocode.ts +55 -0
  28. package/src/address/index.ts +31 -0
  29. package/src/address/validate.ts +91 -0
  30. package/src/canvas/FCanvasCard.vue +97 -0
  31. package/src/canvas/FCanvasControl.vue +121 -0
  32. package/src/canvas/FCanvasControlGroup.vue +100 -0
  33. package/src/canvas/FCanvasFlyout.vue +216 -0
  34. package/src/canvas/FCanvasHud.vue +402 -0
  35. package/src/canvas/canvasSurface.ts +39 -0
  36. package/src/canvas/index.ts +17 -0
  37. package/src/contexts/ForestContext.vue +53 -0
  38. package/src/contexts/contexts.ts +153 -0
  39. package/src/contexts/index.ts +23 -0
  40. package/src/contexts/marketing-theme.ts +214 -0
  41. package/src/contexts/mobile-theme.ts +402 -0
  42. package/src/filter/FFilterBar.vue +72 -0
  43. package/src/filter/FFilterChip.vue +362 -0
  44. package/src/filter/FFilterClauses.vue +104 -0
  45. package/src/filter/FFilterTrigger.vue +183 -0
  46. package/src/filter/filter.theme.ts +27 -0
  47. package/src/filter/filterClause.ts +265 -0
  48. package/src/filter/filterPredicate.ts +134 -0
  49. package/src/filter/filterSchema.ts +82 -0
  50. package/src/filter/index.ts +36 -0
  51. package/src/forest-preset.ts +68 -0
  52. package/src/icons.ts +79 -0
  53. package/src/index.ts +17 -0
  54. package/src/kpi/FKpi.vue +253 -0
  55. package/src/kpi/FKpiGroup.vue +161 -0
  56. package/src/kpi/index.ts +20 -0
  57. package/src/kpi/kpi.theme.ts +168 -0
  58. package/src/kpi/kpiDelta.ts +82 -0
  59. package/src/kpi/kpiFormat.ts +72 -0
  60. package/src/kpi/kpiGroupState.ts +63 -0
  61. package/src/logo/FLogo.vue +52 -0
  62. package/src/logo/FSubLogo.vue +214 -0
  63. package/src/logo/index.ts +8 -0
  64. package/src/map/FMap.vue +355 -0
  65. package/src/map/FMapCompass.vue +154 -0
  66. package/src/map/FMapCoordinates.vue +134 -0
  67. package/src/map/FMapDrawTools.vue +116 -0
  68. package/src/map/FMapFullscreen.vue +92 -0
  69. package/src/map/FMapGeolocate.vue +208 -0
  70. package/src/map/FMapLayers.vue +75 -0
  71. package/src/map/FMapLegend.vue +45 -0
  72. package/src/map/FMapPopup.vue +129 -0
  73. package/src/map/FMapRamp.vue +69 -0
  74. package/src/map/FMapRegion.vue +77 -0
  75. package/src/map/FMapScale.vue +107 -0
  76. package/src/map/FMapSearch.vue +362 -0
  77. package/src/map/FMapTilt.vue +87 -0
  78. package/src/map/FMapTools.vue +61 -0
  79. package/src/map/FMapZoom.vue +81 -0
  80. package/src/map/FMarker.vue +218 -0
  81. package/src/map/FTrackScrubber.vue +94 -0
  82. package/src/map/FVehicleLegend.vue +72 -0
  83. package/src/map/FVehicleMark.vue +166 -0
  84. package/src/map/bin.ts +196 -0
  85. package/src/map/coordinates.ts +83 -0
  86. package/src/map/density.ts +401 -0
  87. package/src/map/draw.ts +356 -0
  88. package/src/map/ease.ts +67 -0
  89. package/src/map/fullscreen.ts +59 -0
  90. package/src/map/heat.ts +160 -0
  91. package/src/map/index.ts +351 -0
  92. package/src/map/liveMarkers.ts +60 -0
  93. package/src/map/map.css +116 -0
  94. package/src/map/mapColor.ts +31 -0
  95. package/src/map/mapContext.ts +12 -0
  96. package/src/map/mapLayers.ts +55 -0
  97. package/src/map/mapLegend.ts +70 -0
  98. package/src/map/mapSearch.theme.ts +79 -0
  99. package/src/map/mapSearch.ts +279 -0
  100. package/src/map/mapUrl.ts +117 -0
  101. package/src/map/markerAsset.ts +3 -0
  102. package/src/map/padding.ts +39 -0
  103. package/src/map/place.ts +192 -0
  104. package/src/map/ramp.ts +95 -0
  105. package/src/map/regionSelect.ts +163 -0
  106. package/src/map/route.ts +167 -0
  107. package/src/map/scale.ts +329 -0
  108. package/src/map/scaleBar.ts +67 -0
  109. package/src/map/selectionModel.ts +50 -0
  110. package/src/map/sources/coordinates.ts +58 -0
  111. package/src/map/sources/mapboxPlaces.ts +71 -0
  112. package/src/map/style.ts +14 -0
  113. package/src/map/track.ts +279 -0
  114. package/src/map/useFeatureSelect.ts +230 -0
  115. package/src/map/useLiveMarkers.ts +84 -0
  116. package/src/map/useMap.ts +150 -0
  117. package/src/map/useMapDraw.ts +776 -0
  118. package/src/map/useMapOverlay.ts +421 -0
  119. package/src/map/useMapTableSelection.ts +101 -0
  120. package/src/map/useMapUrlState.ts +155 -0
  121. package/src/map/useMarkerSource.ts +163 -0
  122. package/src/map/useRegionSelect.ts +309 -0
  123. package/src/map/useTokenMode.ts +61 -0
  124. package/src/map/useTrackPlayback.ts +131 -0
  125. package/src/map/useVehicleFilter.ts +51 -0
  126. package/src/map/useVisibleFeatures.ts +124 -0
  127. package/src/map/vehicle.ts +807 -0
  128. package/src/map/vehicleFilter.ts +284 -0
  129. package/src/map/vehicleLegend.ts +155 -0
  130. package/src/map/visibleFeatures.ts +192 -0
  131. package/src/map/zone.ts +291 -0
  132. package/src/shell/FAppNavbar.vue +68 -0
  133. package/src/shell/FAppShell.vue +163 -0
  134. package/src/shell/FAppSidebar.vue +215 -0
  135. package/src/shell/FPageToolbar.vue +126 -0
  136. package/src/shell/FScrollShadow.vue +43 -0
  137. package/src/shell/FSidePanel.vue +258 -0
  138. package/src/shell/FToolbarActions.vue +32 -0
  139. package/src/shell/index.ts +33 -0
  140. package/src/shell/nav.ts +37 -0
  141. package/src/shell/pageToolbar.ts +69 -0
  142. package/src/shell/shell.ts +74 -0
  143. package/src/shell/sidePanel.ts +115 -0
  144. package/src/styles/forest.css +852 -0
  145. package/src/styles/index.css +39 -0
  146. package/src/themes/accordion.ts +36 -0
  147. package/src/themes/alert.ts +16 -0
  148. package/src/themes/auth-form.ts +19 -0
  149. package/src/themes/avatar-group.ts +9 -0
  150. package/src/themes/avatar.ts +18 -0
  151. package/src/themes/badge.ts +51 -0
  152. package/src/themes/banner.ts +21 -0
  153. package/src/themes/breadcrumb.ts +10 -0
  154. package/src/themes/button.mechanics.ts +191 -0
  155. package/src/themes/button.ts +136 -0
  156. package/src/themes/calendar.ts +29 -0
  157. package/src/themes/card.ts +67 -0
  158. package/src/themes/carousel.ts +41 -0
  159. package/src/themes/checkbox-group.ts +20 -0
  160. package/src/themes/checkbox.ts +47 -0
  161. package/src/themes/chip.ts +50 -0
  162. package/src/themes/collapsible.ts +26 -0
  163. package/src/themes/color-picker.ts +13 -0
  164. package/src/themes/command-palette.ts +37 -0
  165. package/src/themes/container.ts +14 -0
  166. package/src/themes/context-menu.ts +38 -0
  167. package/src/themes/dashboard-group.ts +11 -0
  168. package/src/themes/dashboard-navbar.ts +10 -0
  169. package/src/themes/dashboard-panel.ts +18 -0
  170. package/src/themes/dashboard-resize-handle.ts +35 -0
  171. package/src/themes/dashboard-sidebar-collapse.ts +7 -0
  172. package/src/themes/dashboard-sidebar-toggle.ts +7 -0
  173. package/src/themes/dashboard-sidebar.ts +58 -0
  174. package/src/themes/dashboard-toolbar.ts +15 -0
  175. package/src/themes/drawer.ts +45 -0
  176. package/src/themes/dropdown-menu.ts +38 -0
  177. package/src/themes/empty.ts +43 -0
  178. package/src/themes/field-group.ts +52 -0
  179. package/src/themes/file-upload.ts +38 -0
  180. package/src/themes/footer-columns.ts +13 -0
  181. package/src/themes/footer.ts +9 -0
  182. package/src/themes/form-field.ts +25 -0
  183. package/src/themes/form.ts +22 -0
  184. package/src/themes/header.ts +12 -0
  185. package/src/themes/icon.ts +61 -0
  186. package/src/themes/index.ts +99 -0
  187. package/src/themes/input-date.ts +73 -0
  188. package/src/themes/input-menu.ts +66 -0
  189. package/src/themes/input-number.ts +58 -0
  190. package/src/themes/input-rating.ts +25 -0
  191. package/src/themes/input-tags.ts +63 -0
  192. package/src/themes/input-time.ts +70 -0
  193. package/src/themes/input.ts +148 -0
  194. package/src/themes/kbd.ts +27 -0
  195. package/src/themes/link.ts +27 -0
  196. package/src/themes/listbox.ts +48 -0
  197. package/src/themes/main.ts +6 -0
  198. package/src/themes/marquee.ts +45 -0
  199. package/src/themes/modal.ts +46 -0
  200. package/src/themes/navigation-menu.ts +80 -0
  201. package/src/themes/page-anchors.ts +7 -0
  202. package/src/themes/page-aside.ts +7 -0
  203. package/src/themes/page-card.ts +22 -0
  204. package/src/themes/page-cta.ts +18 -0
  205. package/src/themes/page-feature.ts +13 -0
  206. package/src/themes/page-header.ts +16 -0
  207. package/src/themes/page-hero.ts +15 -0
  208. package/src/themes/page-links.ts +12 -0
  209. package/src/themes/page-section.ts +14 -0
  210. package/src/themes/page.ts +7 -0
  211. package/src/themes/pagination.ts +17 -0
  212. package/src/themes/pin-input.ts +73 -0
  213. package/src/themes/popover.ts +30 -0
  214. package/src/themes/progress.ts +29 -0
  215. package/src/themes/prose.ts +45 -0
  216. package/src/themes/radio-group.ts +39 -0
  217. package/src/themes/scroll-area.ts +23 -0
  218. package/src/themes/select-menu.ts +71 -0
  219. package/src/themes/select.ts +72 -0
  220. package/src/themes/separator.ts +28 -0
  221. package/src/themes/sidebar.ts +127 -0
  222. package/src/themes/skeleton.ts +15 -0
  223. package/src/themes/slideover.ts +87 -0
  224. package/src/themes/slider.ts +48 -0
  225. package/src/themes/stepper.ts +41 -0
  226. package/src/themes/switch.ts +63 -0
  227. package/src/themes/table.ts +34 -0
  228. package/src/themes/tabs.ts +74 -0
  229. package/src/themes/textarea.ts +77 -0
  230. package/src/themes/timeline.ts +25 -0
  231. package/src/themes/toast.ts +29 -0
  232. package/src/themes/toaster.ts +66 -0
  233. package/src/themes/tooltip.ts +31 -0
  234. package/src/themes/tree.ts +41 -0
  235. package/src/themes/user.ts +28 -0
@@ -0,0 +1,100 @@
1
+ <script setup lang="ts">
2
+ // A merged cluster of canvas controls — zoom in / zoom out, or a set of map modes. Reads as ONE control
3
+ // with several positions, which is why the members share one surface rather than floating as separate
4
+ // tiles.
5
+ //
6
+ // <FCanvasControlGroup>
7
+ // <FCanvasControl icon="i-lucide-plus" label="Zoom in" />
8
+ // <FCanvasControl icon="i-lucide-minus" label="Zoom out" />
9
+ // </FCanvasControlGroup>
10
+ //
11
+ // ★ THE GROUP OWNS THE SURFACE, and its members go transparent. Not cosmetic: the canvas-chrome surface
12
+ // is translucent AND blurred, so a per-member surface would stack alpha and blur at every seam and draw
13
+ // the joins as darker bands — the opposite of merged. One surface, hairlines inside it, radius on the
14
+ // outer corners only.
15
+ //
16
+ // ★ WHY NOT <UFieldGroup>, which already joins controls and supports a vertical orientation. Because it
17
+ // merges BORDERS, not SURFACES: `-space-y-px` overlaps its children by a pixel so their rings collapse
18
+ // into one, and every child keeps its own background. That is exactly right for opaque form controls and
19
+ // wrong here — the canvas surface is translucent AND backdrop-blurred, so per-child surfaces stack their
20
+ // alpha at every overlap and each runs its own blur pass, sampling the map separately. The cluster stops
21
+ // being one pane of glass and shows its joins as darker, differently-blurred bands.
22
+ //
23
+ // So the geometry is the easy half (a flex column, a hairline between, one radius) and the surface is the
24
+ // reason this component exists: ONE fill, ONE blur, members bare.
25
+ //
26
+ // ★ Styled through child selectors rather than provide/inject — not because injection cannot reach slot
27
+ // content (it can: Vue resolves it through the mounted tree, which is how UFieldGroup talks to its own
28
+ // children), but because these members are not ours to instruct. A member is an <FCanvasControl> whose
29
+ // surface comes from a utility class, and a child selector switches that off through the utility's own
30
+ // vars without the member needing to know it is in a group at all.
31
+ //
32
+ // ★ THE EDGE IS THE BUTTON'S EDGE, and it took two measurements to get right. A lone control draws
33
+ // `ring-accented` INSET (the outline button's own recipe); this group first drew `ring-default` OUTSET,
34
+ // which differs twice over — a lighter colour, and a ring outside the box, so the cluster sat a pixel
35
+ // wider than the controls beside it. Merged and lone have to be the same object at different lengths.
36
+ // The inner hairlines take the same token for the same reason: one edge colour per cluster.
37
+ //
38
+ // ★ AND THE EDGE IS PAINTED ON A LAYER ABOVE THE MEMBERS. An inset box-shadow paints above its own
39
+ // element's background but BELOW its descendants, so the moment a member takes a hover fill — which
40
+ // spans the member's full width — it covers the group's ring along that member's edges and the border
41
+ // breaks exactly where the pointer is. An overlay pseudo-element cannot be covered by a sibling's
42
+ // background, so the edge survives every state a member can be in.
43
+ //
44
+ // ★ A PRESSED member keeps its solid fill: it is the one part that must stay opaque, and inside a merged
45
+ // cluster it fills its whole cell so the selection reads as a position rather than a floating tile.
46
+ //
47
+ // What belongs in one group: actions on the same subject. Zoom in and zoom out, yes; zoom and "open
48
+ // filters", no — a merged cluster claims the two things are one control, and unrelated actions in one
49
+ // stack make that claim falsely.
50
+ import { computed } from 'vue';
51
+
52
+ const props = withDefaults(
53
+ defineProps<{
54
+ /**
55
+ * Rails stack, so `vertical` is the default. The hub is the one horizontal area — pass
56
+ * `orientation="horizontal"` there.
57
+ *
58
+ * Not inferred from the area it sits in, and injection could not do it even though it would reach
59
+ * here: a `provide` is one value per HUD instance, and the HUD's areas disagree — its rails are
60
+ * vertical and its hub is horizontal, so a single provided orientation would be wrong in one of them.
61
+ * The alternative (a stylesheet rule keyed on `[data-area]`) would bury HUD knowledge in this
62
+ * component's CSS. The author already chose the area; this says so out loud.
63
+ */
64
+ orientation?: 'vertical' | 'horizontal';
65
+ }>(),
66
+ { orientation: 'vertical' },
67
+ );
68
+
69
+ const vertical = computed(() => props.orientation === 'vertical');
70
+
71
+ // The members: surface off, radius off, and the ring off — the group draws all three once. Only the
72
+ // hairline between members is theirs, and it goes on the leading edge of every member but the first.
73
+ // ★ The surface is switched off through the utility's own VARS, not by out-specifying it. The chrome
74
+ // rule is deliberately doubled (`.forest-canvas-chrome.forest-canvas-chrome`) so component recipes cannot
75
+ // beat it, which means a child selector here loses too — measured, the members kept their own fill and
76
+ // blur and the cluster showed its seams. Declaring the var on the child sidesteps the contest entirely.
77
+ const members = [
78
+ '[&>*]:[--forest-canvas-chrome-fill:transparent] [&>*]:[--forest-canvas-chrome-blur:none]',
79
+ // ★ NO RULE BETWEEN MEMBERS. Mapbox's own control group draws one, and an earlier cut of this did
80
+ // too, on the reasoning that two squares in one pill would otherwise read as a single tall button.
81
+ // Over a canvas they lose that argument: the surface is a small pane of glass on a busy, moving map,
82
+ // and every line inside it competes with the map's own. Hover and press do the separating instead —
83
+ // they land on ONE member and end at its edges, which is a stronger boundary than a hairline because
84
+ // it appears exactly when the pointer is asking where the boundary is.
85
+ '[&>*]:rounded-none [&>*]:ring-0 [&>*]:shadow-none',
86
+ ].join(' ');
87
+ </script>
88
+
89
+ <template>
90
+ <div
91
+ class="forest-canvas-chrome pointer-events-auto relative flex overflow-hidden rounded-md after:pointer-events-none after:absolute after:inset-0 after:rounded-md after:ring after:ring-inset after:ring-accented"
92
+ :class="[
93
+ vertical ? 'flex-col' : 'flex-row',
94
+ members,
95
+ ]"
96
+ role="group"
97
+ >
98
+ <slot />
99
+ </div>
100
+ </template>
@@ -0,0 +1,216 @@
1
+ <script setup lang="ts">
2
+ // One canvas control that opens into a cluster of them, in place.
3
+ //
4
+ // <FCanvasFlyout icon="i-lucide-lasso" label="Selection tools" :items="tools" @select="pick" />
5
+ //
6
+ // ★ WHY IT EXISTS. A rail is a narrow column shared with the zoom pair, the compass and the rest, and
7
+ // four controls stacked is 144px of a 480px map before anything else gets its share. This is one
8
+ // control at rest, and the cluster exists only while a reader is choosing from it — the only moment it
9
+ // says anything.
10
+ //
11
+ // ★ IT OPENS ACROSS THE RAIL, NOT ALONG IT. `orientation` is the axis the cluster opens ALONG, and it
12
+ // defaults to horizontal precisely because rails are vertical: a group that grew downward would push
13
+ // every control below it along, every time someone glanced at the tools. Opening across spends space
14
+ // the canvas has and the rail does not.
15
+ //
16
+ // ★ AND IT IS AN OVERLAY, NOT A RESIZE. The wrapper only ever occupies the trigger; the cluster is
17
+ // absolutely positioned from it. A rail hugs its widest child (`fit-content`), so a cluster that grew
18
+ // in flow would widen the whole column and drag its neighbours sideways — measured, before this was
19
+ // positioned out of flow.
20
+ //
21
+ // ★ NOT A POPOVER. It grows contiguously from the trigger and shares one surface with it, so it reads
22
+ // as one object opening rather than a panel arriving beside it: no anchor to position, nothing to
23
+ // teleport, no second pane of glass. <FMapSearch> takes the same view for the same reason.
24
+ import { computed, ref, watch } from 'vue';
25
+ import FCanvasControl from './FCanvasControl.vue';
26
+
27
+ /** One member of the cluster. `value` is what comes back on select. */
28
+ export interface FlyoutItem {
29
+ value: string;
30
+ icon: string;
31
+ label: string;
32
+ /** lit — for a cluster of modes, the one currently held */
33
+ pressed?: boolean;
34
+ }
35
+
36
+ import FCanvasControlGroup from './FCanvasControlGroup.vue';
37
+
38
+ const open = defineModel<boolean>('open', { default: false });
39
+
40
+ const emit = defineEmits<{ select: [value: string] }>();
41
+
42
+ const props = withDefaults(
43
+ defineProps<{
44
+ /**
45
+ * The trigger's icon at rest.
46
+ *
47
+ * ★ Deliberately not derived. A flyout of MODES usually wants the active one here, so a reader can
48
+ * see what is in their hand without opening anything; a flyout of one-shot ACTIONS wants a fixed
49
+ * icon, because there is no state to report. The component cannot tell which it is in, so it does
50
+ * not try.
51
+ */
52
+ icon: string;
53
+ label: string;
54
+ /** Lit at rest — for a mode flyout, whether the mode it names is active. */
55
+ pressed?: boolean;
56
+ /** The axis the cluster opens along. Horizontal suits a vertical rail, and vice versa. */
57
+ orientation?: 'vertical' | 'horizontal';
58
+ /** Which end the trigger is pinned to, so the cluster opens INWARD rather than off the canvas. */
59
+ side?: 'start' | 'end';
60
+ /**
61
+ * The controls the cluster holds.
62
+ *
63
+ * ★ Items rather than a slot, and the motion is why. A cluster's members enter and leave, and
64
+ * <TransitionGroup> can only drive vnodes it created — hand it slot content and the classes never
65
+ * land on anything (measured: members appeared with the button's own transition and no enter
66
+ * class at all). Owning the members buys working motion; the cost is that a member is an icon
67
+ * control rather than arbitrary markup, which is all a cluster of chrome ever is.
68
+ */
69
+ items?: FlyoutItem[];
70
+ /** Icon for the dismiss the trigger becomes while open. */
71
+ closeIcon?: string;
72
+ /** Accessible name for that dismiss. */
73
+ closeLabel?: string;
74
+ }>(),
75
+ {
76
+ items: () => [],
77
+ pressed: undefined,
78
+ orientation: 'horizontal',
79
+ side: 'end',
80
+ closeIcon: 'i-lucide-x',
81
+ closeLabel: 'Close',
82
+ },
83
+ );
84
+
85
+ const root = ref<HTMLElement | null>(null);
86
+ const close = () => (open.value = false);
87
+
88
+ // Picking closes: a cluster of modes has said everything it has to say once one is chosen.
89
+ function choose(value: string) {
90
+ emit('select', value);
91
+ close();
92
+ }
93
+
94
+ /**
95
+ * Where a member starts from: just inside the trigger, so the cluster reads as unfolding OUT of it
96
+ * rather than materialising beside it.
97
+ *
98
+ * ★ `translate-*`, and the transition names `translate` — not `transform`. In Tailwind v4 the
99
+ * translate utilities set the `translate` property, so a `transition-[opacity,transform]` would
100
+ * animate the fade alone and snap the movement.
101
+ */
102
+ const from = computed(() => {
103
+ const d = '0.5rem';
104
+ if (props.orientation === 'horizontal')
105
+ return { '--forest-flyout-x': props.side === 'end' ? d : `-${d}`, '--forest-flyout-y': '0' };
106
+ return { '--forest-flyout-x': '0', '--forest-flyout-y': props.side === 'end' ? d : `-${d}` };
107
+ });
108
+
109
+ /**
110
+ * ★ A CSS ANIMATION, NOT <TransitionGroup>. Measured: with the members inside a TransitionGroup —
111
+ * both as slot content and rendered here — the enter classes never landed on them at all; every
112
+ * member showed the button's own `transition-property` and no opacity or translate of its own. An
113
+ * animation runs on mount by construction, which is exactly when a member appears, and nothing has
114
+ * to be wired correctly for it to fire.
115
+ */
116
+
117
+ // ★ Focus alone cannot dismiss this. A map canvas is not focusable, so pressing on it may move focus
118
+ // nowhere at all, and the cluster would sit open over the very thing the reader reached for. Watching
119
+ // pointerdown catches the press wherever it lands.
120
+ watch(open, (isOpen, _was, onCleanup) => {
121
+ if (!isOpen) return;
122
+ const away = (e: PointerEvent) => {
123
+ if (!root.value?.contains(e.target as Node)) close();
124
+ };
125
+ // Capture, so a handler that stops propagation cannot leave the cluster open behind its own panel.
126
+ document.addEventListener('pointerdown', away, true);
127
+ onCleanup(() => document.removeEventListener('pointerdown', away, true));
128
+ });
129
+
130
+ // The keyboard still moves focus properly, so this covers tabbing away.
131
+ function onFocusOut() {
132
+ requestAnimationFrame(() => {
133
+ if (!open.value) return;
134
+ if (root.value?.contains(document.activeElement)) return;
135
+ close();
136
+ });
137
+ }
138
+ </script>
139
+
140
+ <style>
141
+ /* One keyframe serves all four placements; the flyout sets the axis it enters from. */
142
+ @keyframes forest-flyout-in {
143
+ from {
144
+ opacity: 0;
145
+ translate: var(--forest-flyout-x, 0) var(--forest-flyout-y, 0);
146
+ }
147
+ }
148
+ .forest-flyout-in {
149
+ /* No var() fallbacks. They had already DRIFTED: 150ms is Tailwind's stock default, not
150
+ --motion-fast's 140ms, and the bare `ease-out` keyword is the weak built-in the doctrine bans
151
+ outright — so the "safety net" quietly contradicted the ladder it was netting. The tokens ship
152
+ with the layer now (packages/tokens/motion/motion.css), so an absent value means a broken
153
+ install, which should look broken rather than silently animate off-doctrine. */
154
+ animation: forest-flyout-in var(--motion-fast) var(--motion-ease-out) both;
155
+ }
156
+ /* The global floor covers transitions; an animation has to be said separately. */
157
+ @media (prefers-reduced-motion: reduce) {
158
+ .forest-flyout-in {
159
+ animation: none;
160
+ }
161
+ }
162
+ </style>
163
+
164
+ <template>
165
+ <div
166
+ ref="root"
167
+ class="pointer-events-auto relative"
168
+ :style="from"
169
+ :class="orientation === 'horizontal' ? 'h-(--forest-canvas-control)' : 'w-(--forest-canvas-control)'"
170
+ @keydown.escape="close"
171
+ @focusout="onFocusOut"
172
+ >
173
+ <FCanvasControlGroup
174
+ :orientation="orientation"
175
+ class="absolute"
176
+ :class="
177
+ orientation === 'horizontal'
178
+ ? ['top-0', side === 'end' ? 'end-0' : 'start-0']
179
+ : ['start-0', side === 'end' ? 'bottom-0' : 'top-0']
180
+ "
181
+ >
182
+ <!-- Order follows `side` so the TRIGGER stays put and the cluster appears beside it. Pinned at
183
+ the end, members render first and the trigger last — otherwise opening would walk the
184
+ trigger off the edge it is anchored to. -->
185
+ <FCanvasControl
186
+ v-for="item in open && side === 'end' ? items : []"
187
+ :key="item.value"
188
+ :icon="item.icon"
189
+ :label="item.label"
190
+ :pressed="item.pressed"
191
+ class="forest-flyout-in"
192
+ @click="choose(item.value)"
193
+ />
194
+
195
+ <!-- Closed it is the thing itself; open it is the way out. A dismiss rather than a member,
196
+ because a member that also closed the cluster would be indistinguishable from one that
197
+ does not. -->
198
+ <FCanvasControl
199
+ :icon="open ? closeIcon : icon"
200
+ :label="open ? closeLabel : label"
201
+ :pressed="open ? undefined : pressed"
202
+ @click="open = !open"
203
+ />
204
+
205
+ <FCanvasControl
206
+ v-for="item in open && side === 'start' ? items : []"
207
+ :key="item.value"
208
+ :icon="item.icon"
209
+ :label="item.label"
210
+ :pressed="item.pressed"
211
+ class="forest-flyout-in"
212
+ @click="choose(item.value)"
213
+ />
214
+ </FCanvasControlGroup>
215
+ </div>
216
+ </template>