@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,125 @@
1
+ // One address shape, and the normalisers that get Mapbox's several into it.
2
+ //
3
+ // ★ THE NORMALISING IS THE POINT, not the requests. Mapbox answers address questions from three
4
+ // different endpoints and each names the parts differently: Autofill flattens them onto W3C-ish keys
5
+ // (`address_line1`, `address_level2`), Geocoding nests them under `context` with its own slot names
6
+ // (`context.place`, `context.region`), and Validation echoes a third variation back. A form binding
7
+ // six inputs does not want to know which endpoint answered — so everything here lands on one shape and
8
+ // the endpoint wrappers stay thin.
9
+
10
+ /** An address, in the shape a form binds to. Every part optional: Mapbox omits what it cannot find. */
11
+ export interface ForestAddress {
12
+ line1?: string;
13
+ line2?: string;
14
+ /**
15
+ * The named area below the city — Islington, Hackney, Shoreditch.
16
+ *
17
+ * ★ THIS IS NOT `city`, and conflating them is the trap. For any London address Mapbox answers
18
+ * `place: "London"`, and the borough a rider would actually name lands in `locality`. A surface
19
+ * writing "Islington to Hackney" that read `city` would print "London to London" and lose the only
20
+ * distinction it was making.
21
+ */
22
+ locality?: string;
23
+ /** finer still — a named neighbourhood inside a locality, where Mapbox has one */
24
+ neighborhood?: string;
25
+ /** the town or city — `address_level2` in W3C terms, `context.place` in Geocoding's */
26
+ city?: string;
27
+ /** county, state or province — `address_level1` / `context.region` */
28
+ region?: string;
29
+ postcode?: string;
30
+ country?: string;
31
+ /** ISO 3166-1 alpha-2, uppercase */
32
+ countryCode?: string;
33
+ /** what Mapbox itself would print for this address, when it offered one */
34
+ formatted?: string;
35
+ /** [lng, lat] — present when the answer came with a position */
36
+ at?: [number, number];
37
+ }
38
+
39
+ /**
40
+ * ★ Absent parts stay `undefined`, never ''. A form binds these straight to inputs, and the two are
41
+ * not interchangeable there: '' blanks a field the reader may have typed into, `undefined` leaves it
42
+ * alone. Autofill routinely omits line2 and region, so this is the common path rather than an edge.
43
+ */
44
+ const text = (v: unknown): string | undefined => (typeof v === 'string' && v.length ? v : undefined);
45
+
46
+ function position(geometry: unknown): [number, number] | undefined {
47
+ const coords = (geometry as { coordinates?: unknown } | undefined)?.coordinates;
48
+ return Array.isArray(coords) && typeof coords[0] === 'number' && typeof coords[1] === 'number'
49
+ ? [coords[0], coords[1]]
50
+ : undefined;
51
+ }
52
+
53
+ /**
54
+ * A retrieved Address Autofill feature → ForestAddress.
55
+ *
56
+ * ★ `properties` is typed loosely on purpose. Mapbox declares a different concrete interface for each
57
+ * endpoint's feature, and naming one here would reject the others at the call site for no gain — this
58
+ * reads a handful of optional string fields and checks each one anyway.
59
+ */
60
+ export function fromAutofill(feature: { properties?: unknown; geometry?: unknown }): ForestAddress {
61
+ const p = (feature.properties ?? {}) as Record<string, unknown>;
62
+ return {
63
+ line1: text(p.address_line1),
64
+ line2: text(p.address_line2),
65
+ // Autofill's level3 is the same rung as Geocoding's `locality`; it has no neighbourhood at all.
66
+ locality: text(p.address_level3),
67
+ neighborhood: undefined,
68
+ city: text(p.address_level2),
69
+ region: text(p.address_level1),
70
+ postcode: text(p.postcode),
71
+ country: text(p.country),
72
+ countryCode: text(p.country_code)?.toUpperCase(),
73
+ formatted: text(p.full_address),
74
+ at: position(feature.geometry),
75
+ };
76
+ }
77
+
78
+ /** A Geocoding feature — forward or reverse — → ForestAddress. */
79
+ export function fromGeocodingFeature(feature: {
80
+ properties?: unknown;
81
+ geometry?: unknown;
82
+ }): ForestAddress {
83
+ const p = (feature.properties ?? {}) as Record<string, unknown>;
84
+ const ctx = (p.context ?? {}) as Record<string, { name?: unknown; country_code?: unknown }>;
85
+ const slot = (key: string) => text(ctx[key]?.name);
86
+
87
+ return {
88
+ // `context.address` is the street line when Mapbox resolved to a building; `properties.name` is
89
+ // what it resolved to at any other precision, which for a reverse lookup is often the street.
90
+ line1: slot('address') ?? text(p.name),
91
+ line2: undefined,
92
+ locality: slot('locality'),
93
+ neighborhood: slot('neighborhood'),
94
+ city: slot('place'),
95
+ region: slot('region'),
96
+ postcode: slot('postcode'),
97
+ country: slot('country'),
98
+ countryCode: text(ctx.country?.country_code)?.toUpperCase(),
99
+ formatted: text(p.full_address),
100
+ at: position(feature.geometry),
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The address as one line, in postal order, skipping whatever is missing.
106
+ *
107
+ * For when Mapbox gave no `formatted` of its own, or the reader has edited the parts since — a
108
+ * surface that printed a stale `formatted` beside edited fields would be showing two addresses.
109
+ */
110
+ /**
111
+ * The most specific name a person would use for where this is — "Shoreditch", "Islington", "London".
112
+ *
113
+ * For naming a place in a sentence rather than addressing an envelope: a ride's start and end, a bay's
114
+ * area, "reported near…". Falls down the rungs because Mapbox does not have every one everywhere —
115
+ * a village has a `place` and nothing below it.
116
+ */
117
+ export function areaLabel(address: ForestAddress): string | undefined {
118
+ return address.neighborhood ?? address.locality ?? address.city;
119
+ }
120
+
121
+ export function formatAddress(address: ForestAddress): string {
122
+ return [address.line1, address.line2, address.city, address.postcode, address.country]
123
+ .filter((part): part is string => !!part)
124
+ .join(', ');
125
+ }
@@ -0,0 +1,78 @@
1
+ // Address autofill — type the first line, fill the other five.
2
+ //
3
+ // ★ THIS IS A FORM CAPABILITY, NOT A MAP ONE, which is why it lives outside src/map. Nothing here
4
+ // needs a map, renders to a canvas, or knows what a vehicle is; a signup screen with no map on it is
5
+ // the main caller. It shares a package with the geocoders because it shares their endpoint family and
6
+ // their session-billing rule, not because it shares their surface.
7
+ //
8
+ // ★ SUGGEST IS FREE-ISH, RETRIEVE IS THE BILLED CALL — one session token spans the keystrokes leading
9
+ // to a single retrieve, exactly as the places source does. Rolling the token per keystroke would bill
10
+ // every letter as its own search.
11
+ import { AddressAutofillCore, SessionToken } from '@mapbox/search-js-core';
12
+ import { fromAutofill, type ForestAddress } from './address';
13
+
14
+ type AutofillClient = Pick<AddressAutofillCore, 'suggest' | 'retrieve'>;
15
+
16
+ export interface AddressSuggestion {
17
+ id: string;
18
+ /** the address line, e.g. "12 Morning Lane" */
19
+ label: string;
20
+ /** the rest of it, e.g. "London, E9 6NU, United Kingdom" */
21
+ description?: string;
22
+ /** Mapbox's own object, handed back to `retrieve` — opaque to callers */
23
+ raw: unknown;
24
+ }
25
+
26
+ export interface AddressAutofillOptions {
27
+ accessToken: string;
28
+ country?: string;
29
+ language?: string;
30
+ limit?: number;
31
+ client?: AutofillClient;
32
+ }
33
+
34
+ export interface AddressAutofill {
35
+ suggest(query: string, signal?: AbortSignal): Promise<AddressSuggestion[]>;
36
+ /** The chosen suggestion, resolved into every part a form binds. Ends the billing session. */
37
+ retrieve(suggestion: AddressSuggestion, signal?: AbortSignal): Promise<ForestAddress | null>;
38
+ }
39
+
40
+ export function addressAutofill(options: AddressAutofillOptions): AddressAutofill {
41
+ const client: AutofillClient =
42
+ options.client ??
43
+ new AddressAutofillCore({
44
+ accessToken: options.accessToken,
45
+ country: options.country,
46
+ language: options.language,
47
+ });
48
+
49
+ let session = new SessionToken();
50
+
51
+ return {
52
+ async suggest(query, signal) {
53
+ const { suggestions } = await client.suggest(query, {
54
+ sessionToken: session,
55
+ limit: options.limit ?? 5,
56
+ signal,
57
+ });
58
+
59
+ return suggestions.map((s) => ({
60
+ id: s.mapbox_id,
61
+ label: s.feature_name,
62
+ // `description` is Mapbox's own "the rest of the address" string; full_address repeats the
63
+ // line the reader is already looking at, so it is the fallback rather than the first choice.
64
+ description: s.description ?? s.full_address,
65
+ raw: s,
66
+ }));
67
+ },
68
+
69
+ async retrieve(suggestion, signal) {
70
+ const { features } = await client.retrieve(suggestion.raw as never, {
71
+ sessionToken: session,
72
+ signal,
73
+ });
74
+ session = new SessionToken();
75
+ return features.length ? fromAutofill(features[0]) : null;
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,55 @@
1
+ // Coordinates → address, and address → coordinates.
2
+ //
3
+ // ★ NOT THE SAME ENDPOINT AS <FMapSearch>'s places source. Search Box is built for a person typing
4
+ // into a field and ranks by relevance; Geocoding answers a precise question about a point or a string
5
+ // and returns postal parts. Reverse in particular has no Search Box equivalent at all — it is the only
6
+ // way to turn a map position into words, which is what a surface needs the moment a reader drops a pin
7
+ // or finishes a ride somewhere.
8
+ import { GeocodingCore } from '@mapbox/search-js-core';
9
+ import { fromGeocodingFeature, type ForestAddress } from './address';
10
+
11
+ /** The two calls this makes, so a test can stand in for the client. */
12
+ type GeocodingClient = Pick<GeocodingCore, 'forward' | 'reverse'>;
13
+
14
+ export interface GeocoderOptions {
15
+ accessToken: string;
16
+ /** ISO country codes to restrict to, e.g. `'gb'` */
17
+ country?: string;
18
+ language?: string;
19
+ /** forward results returned; reverse always takes the closest single answer */
20
+ limit?: number;
21
+ /** stands in for the real client in tests */
22
+ client?: GeocodingClient;
23
+ }
24
+
25
+ export interface Geocoder {
26
+ /** What is at this point. Null when Mapbox has nothing there — mid-ocean, or a blank tile. */
27
+ reverse(at: [number, number], signal?: AbortSignal): Promise<ForestAddress | null>;
28
+ /** Where is this address. Ordered by relevance, empty when nothing matched. */
29
+ forward(query: string, signal?: AbortSignal): Promise<ForestAddress[]>;
30
+ }
31
+
32
+ export function geocoder(options: GeocoderOptions): Geocoder {
33
+ const client: GeocodingClient =
34
+ options.client ??
35
+ new GeocodingCore({
36
+ accessToken: options.accessToken,
37
+ country: options.country,
38
+ language: options.language,
39
+ });
40
+
41
+ return {
42
+ async reverse(at, signal) {
43
+ // limit 1: reverse geocoding returns a stack of ever-coarser features for the same point —
44
+ // the building, then the street, then the postcode, then the city. The first is the answer;
45
+ // the rest are its context, and `fromGeocodingFeature` already reads that off `context`.
46
+ const { features } = await client.reverse(at, { limit: 1, signal });
47
+ return features.length ? fromGeocodingFeature(features[0]) : null;
48
+ },
49
+
50
+ async forward(query, signal) {
51
+ const { features } = await client.forward(query, { limit: options.limit ?? 5, signal });
52
+ return features.map(fromGeocodingFeature);
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,31 @@
1
+ // Forest's address subsystem — the Mapbox address family, normalised onto one shape.
2
+ //
3
+ // Three endpoints answer three different questions and name the answer's parts three different ways.
4
+ // Everything here lands on `ForestAddress`, so a form binding six inputs never learns which one
5
+ // replied:
6
+ //
7
+ // addressAutofill() type the first line, fill the other five — a signup or billing form
8
+ // geocoder() reverse: a point → words. forward: words → a point
9
+ // addressValidator() is it real, and which part is wrong
10
+ //
11
+ // ★ SEPARATE ENTRY POINT (`@humanforest/ui/address`), like the places source. None of this is map
12
+ // chrome — a billing form has no map on it — and an app that only draws maps should not carry the
13
+ // address clients, nor the reverse.
14
+ export type { ForestAddress } from './address';
15
+ export { areaLabel, formatAddress, fromAutofill, fromGeocodingFeature } from './address';
16
+
17
+ export { addressAutofill } from './autofill';
18
+ export type { AddressAutofill, AddressAutofillOptions, AddressSuggestion } from './autofill';
19
+
20
+ export { geocoder } from './geocode';
21
+ export type { Geocoder, GeocoderOptions } from './geocode';
22
+
23
+ export { addressValidator } from './validate';
24
+ export type {
25
+ AddressCheck,
26
+ AddressConfidence,
27
+ AddressPart,
28
+ AddressPartMatch,
29
+ AddressValidator,
30
+ AddressValidatorOptions,
31
+ } from './validate';
@@ -0,0 +1,91 @@
1
+ // Is this address real, and did every part of it match.
2
+ //
3
+ // ★ THE PER-PART VERDICT IS THE USEFUL HALF, not the overall confidence. "That address looks wrong"
4
+ // gives a reader nothing to act on; "the postcode did not match" points at the field to fix. Mapbox
5
+ // returns both, and a form should surface the parts.
6
+ //
7
+ // ★ THE UNIONS ARE DECLARED HERE RATHER THAN IMPORTED. Mapbox ships MatchCodeType and
8
+ // MatchCodeConfidence as `const enum` in a .d.ts — TypeScript inlines those at compile time and emits
9
+ // no runtime object, so importing them as VALUES throws under a bundler. Types only, and our own
10
+ // literals for anything that has to exist at runtime.
11
+ import { ValidationCore, SessionToken } from '@mapbox/search-js-core';
12
+ import { fromGeocodingFeature, formatAddress, type ForestAddress } from './address';
13
+
14
+ type ValidationClient = Pick<ValidationCore, 'validate'>;
15
+
16
+ /** How one component of the address fared. */
17
+ export type AddressPartMatch =
18
+ | 'matched'
19
+ | 'unmatched'
20
+ /** interpolated rather than found — the street is real, the number was estimated */
21
+ | 'plausible'
22
+ /** not part of this address's postal form at all */
23
+ | 'not_applicable'
24
+ /** not submitted, but filled in confidently. Mapbox only does this for country. */
25
+ | 'inferred';
26
+
27
+ export type AddressConfidence = 'exact' | 'high' | 'medium' | 'low';
28
+
29
+ export type AddressPart = 'address_number' | 'street' | 'postcode' | 'place' | 'region' | 'country';
30
+
31
+ export interface AddressCheck {
32
+ confidence: AddressConfidence;
33
+ /** per-component verdicts — the part a form points its error at */
34
+ parts: Partial<Record<AddressPart, AddressPartMatch>>;
35
+ /** what Mapbox believes the address actually is, normalised */
36
+ resolved: ForestAddress | null;
37
+ /** the components that came back `unmatched`, ready to mark in a form */
38
+ problems: AddressPart[];
39
+ }
40
+
41
+ export interface AddressValidatorOptions {
42
+ accessToken: string;
43
+ country?: string;
44
+ client?: ValidationClient;
45
+ }
46
+
47
+ export interface AddressValidator {
48
+ /** Null when Mapbox recognised nothing at all — a typo storm, or an address that does not exist. */
49
+ validate(address: string | ForestAddress, signal?: AbortSignal): Promise<AddressCheck | null>;
50
+ }
51
+
52
+ export function addressValidator(options: AddressValidatorOptions): AddressValidator {
53
+ const client: ValidationClient =
54
+ options.client ??
55
+ new ValidationCore({ accessToken: options.accessToken, country: options.country });
56
+
57
+ return {
58
+ async validate(address, signal) {
59
+ const query = typeof address === 'string' ? address : formatAddress(address);
60
+ if (!query) return null;
61
+
62
+ const { features } = await client.validate(query, { sessionToken: new SessionToken(), signal });
63
+ if (!features.length) return null;
64
+
65
+ const feature = features[0];
66
+ // MatchCode's fields are typed as const-enum members; read as plain strings, since the enum has
67
+ // no runtime object to compare against (see the note at the top of this file).
68
+ const code = (feature.properties?.match_code ?? {}) as unknown as Record<string, string>;
69
+ const parts: Partial<Record<AddressPart, AddressPartMatch>> = {};
70
+ for (const part of [
71
+ 'address_number',
72
+ 'street',
73
+ 'postcode',
74
+ 'place',
75
+ 'region',
76
+ 'country',
77
+ ] as const) {
78
+ if (code[part]) parts[part] = code[part] as AddressPartMatch;
79
+ }
80
+
81
+ return {
82
+ confidence: (code.confidence ?? 'low') as AddressConfidence,
83
+ parts,
84
+ resolved: fromGeocodingFeature(feature as never),
85
+ // `not_applicable` and `inferred` are not faults — a UK address has no region to match, and an
86
+ // inferred country is Mapbox being helpful. Only `unmatched` is worth pointing a reader at.
87
+ problems: (Object.keys(parts) as AddressPart[]).filter((p) => parts[p] === 'unmatched'),
88
+ };
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ // A card floating ON a canvas — a readout, a legend, a stat panel, a filter row.
3
+ //
4
+ // <FCanvasCard>5 bays in view</FCanvasCard>
5
+ // <FCanvasCard><template #header>Live fleet</template>…</FCanvasCard>
6
+ //
7
+ // ★ IT IS A UCard, and adds ONLY what the canvas changes. An earlier cut reimplemented the box — its own
8
+ // radius, its own padding rungs, its own header — and every one of those was a worse copy of a decision
9
+ // the card theme had already made: `soft` is a quiet fill with no ring, `divide-y-0` (so no rule under a
10
+ // header) and a padding scale the rest of Forest shares. Reimplementing it drifted a canvas card away
11
+ // from a page card for no reason anyone could name, and the hand-rolled header put a rule INSIDE the
12
+ // card's own padding, floating in the middle of the tile.
13
+ //
14
+ // What is left here is the difference, and only the difference:
15
+ //
16
+ // · the chrome surface — translucent, backdrop-blurred — so the map stays legible as context under
17
+ // its own chrome rather than being covered by an opaque tile;
18
+ // · the edge, `ring-default`: the same ring the side panel carries, so a card and a panel on one
19
+ // canvas read as the same material rather than as two kinds of glass;
20
+ // · the radius, 16px, matching the side panel rather than the card theme's own 24. A canvas puts a
21
+ // card, a panel and a control side by side, and at the theme's default that was three radii in one
22
+ // frame (24 / 16 / 12). It arrives through the `forest-canvas-card` marker rather than a utility,
23
+ // because the concentric nesting rule in forest.css is UNLAYERED and outranks any `rounded-*` a
24
+ // component can carry — measured: inside a UCard (a map panel on a dashboard, a specimen in these
25
+ // docs) the ramp stepped this card to 8px. The marker also says what is true: chrome on a canvas is
26
+ // not a surface nested in another surface, so the ramp does not describe it.
27
+ //
28
+ // Everything else — padding, the header and footer slots, the ink — belongs to the card theme and stays
29
+ // there. A site that needs a tighter body passes UCard's own `:ui`, which is what it would
30
+ // use for a page card too.
31
+ //
32
+ // ★ NO DROP SHADOW. Forest separates surfaces by ring and tint rather than by lift, and a canvas is
33
+ // where that matters most: a shadow over a moving map reads as a second, softer copy of the card
34
+ // sliding along with it. The edge does the work.
35
+ //
36
+ // ★ IT DOES NOT POSITION ITSELF. Placement on a canvas belongs to <FCanvasHud>'s areas, or to a page
37
+ // with no HUD; a card that placed itself would be a second layout system competing with the rails for
38
+ // the same corners.
39
+ //
40
+ // ★ `interactive` is for a pressable tile — a stat you can open. A pressable tile with ONE icon is an
41
+ // <FCanvasControl> instead; this is for the case where the tile holds a layout a button would have to
42
+ // rebuild inside itself. It adds the affordance once, on the shared motion tokens, where the site that
43
+ // motivated it had hand-rolled its own hover and `active:scale-[.97]`.
44
+ import { computed } from 'vue';
45
+
46
+ const props = withDefaults(
47
+ defineProps<{
48
+ /** Pressable: hover fill, press nudge, focus ring. Pair with `as="button"` so it is reachable. */
49
+ interactive?: boolean;
50
+ /** Forwarded to UCard, so a site can tighten the body exactly as it would on any card. */
51
+ ui?: Record<string, string>;
52
+ }>(),
53
+ { interactive: false, ui: undefined },
54
+ );
55
+
56
+ // `pointer-events-auto` because the HUD's areas are pointer-transparent and opt their children back in:
57
+ // a card dropped into a rail would otherwise let clicks fall through to the map beneath it.
58
+ //
59
+ // The press treatment rides the shared motion tokens rather than a hand-picked duration. The nudge is a
60
+ // scale rather than a lift, because a lift under a translucent surface changes what shows through it.
61
+ const press = computed(() =>
62
+ props.interactive
63
+ ? [
64
+ 'cursor-pointer text-left',
65
+ 'transition-[transform,background-color] duration-(--motion-fast) ease-(--motion-ease-out)',
66
+ 'hover:[--forest-canvas-chrome-fill:var(--ui-bg)] active:scale-[.98]',
67
+ 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
68
+ ].join(' ')
69
+ : '',
70
+ );
71
+
72
+ // ★ The title takes the SIDE PANEL's treatment, not the page card's. On a page a card title is sans at
73
+ // rest (the card theme leaves it to the --fx-* traits); on a canvas a card sits beside a panel whose
74
+ // title is MohrAlt, and two headings in one frame reading differently is the drift a shared surface is
75
+ // supposed to prevent. Same class the sidebar fragment carries: one heading voice per canvas.
76
+ const TITLE = 'font-display';
77
+
78
+ const cardUi = computed(() => ({
79
+ ...props.ui,
80
+ title: [TITLE, props.ui?.title].filter(Boolean).join(' '),
81
+ root: [
82
+ 'forest-canvas-chrome forest-canvas-card pointer-events-auto ring ring-default',
83
+ press.value,
84
+ props.ui?.root,
85
+ ]
86
+ .filter(Boolean)
87
+ .join(' '),
88
+ }));
89
+ </script>
90
+
91
+ <template>
92
+ <UCard :ui="cardUi">
93
+ <template v-if="$slots.header" #header><slot name="header" /></template>
94
+ <slot />
95
+ <template v-if="$slots.footer" #footer><slot name="footer" /></template>
96
+ </UCard>
97
+ </template>
@@ -0,0 +1,121 @@
1
+ <script setup lang="ts">
2
+ // One control on a canvas — a zoom button, a layers toggle, the FAB that opens a panel.
3
+ //
4
+ // <FCanvasControl icon="i-lucide-plus" label="Zoom in" />
5
+ // <FCanvasControl icon="i-lucide-sliders-horizontal" label="Filters" v-model:pressed="panel" />
6
+ //
7
+ // ★ WHY THIS EXISTS AND IS NOT JUST A UButton WITH A CLASS. The canvas-chrome surface replaces
8
+ // `background-color`, which is right for neutral chrome and WRONG for a control carrying its own fill:
9
+ // on a solid button it removes the very fill the label colour was chosen against and leaves that ink on
10
+ // a translucent surface, unreadable in both modes. Every usage site therefore had to know a rule —
11
+ // "apply the surface, except when pressed" — and one of them got it wrong the week the utility landed.
12
+ // Here the two states are one component's business and the rule has nowhere to leak to.
13
+ //
14
+ // It also pins the two things a hand-rolled FAB forgets: the control size the HUD measures its rails in
15
+ // (--forest-canvas-control, so a control and its rail cannot disagree), and the accessible name that an
16
+ // icon-only button has no text to supply.
17
+ import { computed } from 'vue';
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ /** The glyph. Exactly one of `icon` or `text`. */
22
+ icon?: string;
23
+ /**
24
+ * A short string in place of the glyph — `2D`, `N`, a zoom level.
25
+ *
26
+ * ★ SOMETIMES A WORD IS THE ONLY LEGIBLE ICON. A cube does not say "three dimensions" to anyone
27
+ * who has not been told, and nothing says "2D" but "2D". The control keeps its square geometry,
28
+ * its size and both cuts; only the picture changes. Two or three characters — longer than that
29
+ * belongs on a button with a real label.
30
+ */
31
+ text?: string;
32
+ /** The accessible name. Required: a glyph-only control has no prose to fall back on. */
33
+ label: string;
34
+ disabled?: boolean;
35
+ }>(),
36
+ { icon: undefined, text: undefined, disabled: false },
37
+ );
38
+
39
+ /**
40
+ * Bind it and the control becomes a TOGGLE — aria-pressed, and the pressed cut. Left unbound it is a
41
+ * plain action, which is why the model has no default: `undefined` is how a control says it has no
42
+ * pressed state to report, and a default of `false` would make every zoom button claim to be an
43
+ * unpressed toggle.
44
+ */
45
+ // ★ `default: undefined` is load-bearing. Vue casts an ABSENT Boolean prop to `false`, so without it a
46
+ // plain action (no v-model) reported itself as an unpressed toggle — measured: `aria-pressed="false"` on
47
+ // a zoom button, which tells a screen reader it is a toggle that happens to be off.
48
+ const pressed = defineModel<boolean | undefined>('pressed', { default: undefined });
49
+
50
+ const isToggle = computed(() => pressed.value !== undefined);
51
+
52
+ // ★ The pressed cut is SOLID and takes no chrome surface; the resting cut is outline and takes it. That
53
+ // pairing is the whole point of the component — see the note above.
54
+ //
55
+ // The class is applied here rather than in the theme because it is a surface for a PLACE (a canvas), not
56
+ // a property of the button: the same outline button in a page's toolbar wants nothing to do with it.
57
+ const surface = computed(() => (pressed.value ? '' : 'forest-canvas-chrome'));
58
+
59
+ // ★ DISABLED HAS TO READ AS DISABLED, and the button recipe alone does not manage it here. Stock dims
60
+ // with `opacity-75`, which is a fine signal on a page: the control fades against a known background.
61
+ // On a canvas it fails twice — 75% of near-white ink is still near-white, and the opacity also thins
62
+ // the chrome surface, so the map shows through MORE and the control looks lighter rather than quieter.
63
+ // Measured: a disabled control and a live one differed by nothing but that 0.75.
64
+ //
65
+ // So the ink drops to `text-dimmed`: a colour change reads instantly at a glance, where a uniform fade
66
+ // reads as "slightly further away". The icon is targeted directly because it takes its colour by
67
+ // INHERITANCE from the button, and an inherited value loses to any declaration on the element itself.
68
+ //
69
+ // ★ THE EDGE DOES NOT CHANGE, deliberately. Softening the ring as well made a disabled control read as a
70
+ // different KIND of control sitting in the rail rather than the same one switched off — the same reason
71
+ // the merged group had to borrow the button's exact ring. One edge weight across every state; the state
72
+ // lives in the fill and the glyph.
73
+ //
74
+ // ★ AND THE OPACITY IS TURNED OFF, not merely supplemented. Opacity fades the whole element — including
75
+ // the chrome surface, which is already translucent — so a disabled control let MORE map through and read
76
+ // as lighter and less solid, which is the opposite of quiet. Measured against the surface: the colour
77
+ // change alone takes the ink from ~13:1 to ~3.4:1, which is the signal; the fade was adding nothing but
78
+ // a thinner background. Both the `disabled:` and `aria-disabled:` paths are overridden, since the recipe
79
+ // carries the fade on each.
80
+ //
81
+ // ★ THE SURFACE CARRIES THE STATE TOO, not just the glyph. A dimmed ink on an identical tile still reads
82
+ // as pressable, so the disabled fill steps toward the middle — greyer in light, less black in dark, one
83
+ // token doing both. The blur and the 85% stay, so it is the same material rather than a different object.
84
+ //
85
+ // ★ THE SURFACE HAS TO BE RESTATED UNDER THE SAME VARIANT. The recipe also sets a `disabled:bg-*`, and a
86
+ // variant beats the chrome utility on source order even at equal specificity — measured, a disabled
87
+ // control dropped to an OPAQUE tile while every live control beside it was 85% and blurred. It reads as
88
+ // a different kind of object rather than the same object switched off. Referencing the named fill keeps
89
+ // this a pointer to one value rather than a second copy of the colour-mix.
90
+ const disabledLook = computed(() =>
91
+ props.disabled
92
+ ? [
93
+ 'disabled:opacity-100 aria-disabled:opacity-100',
94
+ 'disabled:bg-(--forest-canvas-chrome-fill-disabled) aria-disabled:bg-(--forest-canvas-chrome-fill-disabled)',
95
+ '[&_[data-slot=leadingIcon]]:text-dimmed',
96
+ ].join(' ')
97
+ : '',
98
+ );
99
+ </script>
100
+
101
+ <template>
102
+ <UButton
103
+ :icon="text ? undefined : icon"
104
+ :color="pressed ? 'primary' : 'neutral'"
105
+ :variant="pressed ? 'solid' : 'outline'"
106
+ :disabled="disabled"
107
+ square
108
+ :aria-label="label"
109
+ :aria-pressed="isToggle ? String(!!pressed) : undefined"
110
+ :class="[surface, disabledLook]"
111
+ :ui="{ base: 'size-(--forest-canvas-control) shrink-0' }"
112
+ @click="isToggle && (pressed = !pressed)"
113
+ >
114
+ <!-- The scale bar's treatment, so the lettered chrome on a canvas reads as one voice: bold
115
+ because Forest authors two weights, tabular so a changing readout does not shuffle, and
116
+ `aria-hidden` because the accessible name is the label rather than these two characters. -->
117
+ <span v-if="text" class="text-[0.6875rem] leading-none font-bold tracking-wide tabular-nums" aria-hidden="true">
118
+ {{ text }}
119
+ </span>
120
+ </UButton>
121
+ </template>