@humanforest/nuxt-layer 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.
- package/LICENSE +67 -0
- package/README.md +208 -0
- package/app.config.ts +10 -0
- package/assets/icons/forest/bike-asterisk.svg +21 -0
- package/assets/icons/forest/bike-down.svg +19 -0
- package/assets/icons/forest/bike-off.svg +20 -0
- package/assets/icons/forest/bike-up.svg +19 -0
- package/assets/icons/forest/bike-x.svg +19 -0
- package/assets/icons/forest/bike.svg +18 -0
- package/assets/icons/forest/mark-fill.svg +6 -0
- package/assets/icons/forest/mark.svg +6 -0
- package/assets/icons/forest/parking.svg +3 -0
- package/assets/icons/forest/star-fill.svg +4 -0
- package/assets/icons/forest/star.svg +4 -0
- package/assets/icons/forest/traffic-light-caution.svg +8 -0
- package/assets/icons/forest/traffic-light-go.svg +8 -0
- package/assets/icons/forest/traffic-light-lit.svg +8 -0
- package/assets/icons/forest/traffic-light-stop.svg +8 -0
- package/assets/icons/forest/traffic-light.svg +8 -0
- package/nuxt.config.ts +81 -0
- package/package.json +35 -0
- package/packages/charts/src/FBoroughShape.vue +52 -0
- package/packages/charts/src/FCalendarGrid.vue +332 -0
- package/packages/charts/src/FCellLegend.vue +54 -0
- package/packages/charts/src/FChartFrame.vue +201 -0
- package/packages/charts/src/FChartFrameSingle.vue +91 -0
- package/packages/charts/src/FChartLegend.vue +26 -0
- package/packages/charts/src/FDistributionBar.vue +224 -0
- package/packages/charts/src/FDottedMap.vue +335 -0
- package/packages/charts/src/FLondonMap.vue +399 -0
- package/packages/charts/src/FSparkline.vue +198 -0
- package/packages/charts/src/FStatusTrack.vue +151 -0
- package/packages/charts/src/cellSize.ts +46 -0
- package/packages/charts/src/distribution.ts +99 -0
- package/packages/charts/src/engine.ts +15 -0
- package/packages/charts/src/forestTooltip.ts +110 -0
- package/packages/charts/src/fromCategories.ts +43 -0
- package/packages/charts/src/index.ts +87 -0
- package/packages/charts/src/londonAreas.ts +75 -0
- package/packages/charts/src/londonGrid.ts +67 -0
- package/packages/charts/src/motionDuration.ts +49 -0
- package/packages/charts/src/presets.ts +143 -0
- package/packages/charts/src/resolveVar.ts +21 -0
- package/packages/charts/src/snapTooltip.ts +154 -0
- package/packages/charts/src/tooltipStandIn.ts +58 -0
- package/packages/charts/src/useCellScale.ts +155 -0
- package/packages/charts/src/useChartPalette.ts +61 -0
- package/packages/charts/src/useChartRepaintKey.ts +22 -0
- package/packages/charts/src/useThemeVersion.ts +17 -0
- package/packages/frames/src/FBrowserFrame.vue +53 -0
- package/packages/frames/src/FDeviceFrame.vue +242 -0
- package/packages/frames/src/FFitFrame.vue +83 -0
- package/packages/frames/src/art/iphone-11.svg +124 -0
- package/packages/frames/src/art/iphone-12.svg +601 -0
- package/packages/frames/src/art/iphone-13-pro.svg +820 -0
- package/packages/frames/src/art/iphone-13.svg +849 -0
- package/packages/frames/src/art/iphone-14.svg +681 -0
- package/packages/frames/src/art/iphone-15-pro.svg +633 -0
- package/packages/frames/src/art/iphone-15.svg +713 -0
- package/packages/frames/src/art/iphone-16-plus.svg +685 -0
- package/packages/frames/src/art/iphone-16-pro-max.svg +611 -0
- package/packages/frames/src/art/iphone-16-pro.svg +631 -0
- package/packages/frames/src/art/iphone-16.svg +713 -0
- package/packages/frames/src/art/iphone-17-pro-max.svg +355 -0
- package/packages/frames/src/art/iphone-17.svg +305 -0
- package/packages/frames/src/art/iphone-air.svg +389 -0
- package/packages/frames/src/art/iphone-se-3.svg +473 -0
- package/packages/frames/src/art/iphone-x.svg +652 -0
- package/packages/frames/src/art/pixel-10-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-10-pro.svg +32 -0
- package/packages/frames/src/art/pixel-10.svg +32 -0
- package/packages/frames/src/art/pixel-8-pro.svg +208 -0
- package/packages/frames/src/art/pixel-8.svg +208 -0
- package/packages/frames/src/art/pixel-8a.svg +8 -0
- package/packages/frames/src/art/pixel-9-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-9-pro.svg +34 -0
- package/packages/frames/src/art/pixel-9a.svg +25 -0
- package/packages/frames/src/art.ts +29 -0
- package/packages/frames/src/devices.ts +337 -0
- package/packages/frames/src/index.ts +15 -0
- package/packages/tokens/geo/london-boroughs.json +1 -0
- package/packages/tokens/geo/london-dots-coarse.json +1 -0
- package/packages/tokens/geo/london-dots-fine.json +1 -0
- package/packages/tokens/geo/london-dots-medium.json +1 -0
- package/packages/tokens/geo/london-dots-ultra.json +1 -0
- package/packages/tokens/geo/london-wards.json +1 -0
- package/packages/tokens/glyphs/glyphs.json +1 -0
- package/packages/tokens/logos/f-rot.svg +4 -0
- package/packages/tokens/logos/f.svg +4 -0
- package/packages/tokens/logos/favicon-c2w.svg +9 -0
- package/packages/tokens/logos/favicon-console.svg +9 -0
- package/packages/tokens/logos/favicon-ds.svg +9 -0
- package/packages/tokens/logos/favicon-fleet.svg +9 -0
- package/packages/tokens/logos/favicon-flex.svg +9 -0
- package/packages/tokens/logos/favicon-plus.svg +9 -0
- package/packages/tokens/logos/favicon-radar.svg +9 -0
- package/packages/tokens/logos/favicon-tower.svg +9 -0
- package/packages/tokens/logos/lockup-horizontal.svg +17 -0
- package/packages/tokens/logos/lockup-vertical.svg +17 -0
- package/packages/tokens/logos/mark-mono.svg +4 -0
- package/packages/tokens/logos/mark.svg +7 -0
- package/packages/tokens/logos/wordmark.svg +9 -0
- package/packages/tokens/src/colourEngine.ts +604 -0
- package/packages/tokens/src/glyphs.ts +127 -0
- package/packages/tokens/src/logo.ts +50 -0
- package/packages/tokens/src/subbrands.ts +44 -0
- package/packages/tokens/src/sublogo.ts +80 -0
- package/packages/ui/icons/forest/map/forest-place-count.svg +21 -0
- package/packages/ui/icons/forest/map/forest-place-marker.svg +17 -0
- package/packages/ui/icons/forest/map/forest-vehicle-marker.svg +73 -0
- package/packages/ui/icons/forest/map/manifest.json +373 -0
- package/packages/ui/icons/forest/map/place-count-manifest.json +83 -0
- package/packages/ui/icons/forest/map/place-manifest.json +74 -0
- package/packages/ui/src/address/address.ts +125 -0
- package/packages/ui/src/address/autofill.ts +78 -0
- package/packages/ui/src/address/geocode.ts +55 -0
- package/packages/ui/src/address/index.ts +31 -0
- package/packages/ui/src/address/validate.ts +91 -0
- package/packages/ui/src/canvas/FCanvasCard.vue +97 -0
- package/packages/ui/src/canvas/FCanvasControl.vue +121 -0
- package/packages/ui/src/canvas/FCanvasControlGroup.vue +100 -0
- package/packages/ui/src/canvas/FCanvasFlyout.vue +216 -0
- package/packages/ui/src/canvas/FCanvasHud.vue +402 -0
- package/packages/ui/src/canvas/canvasSurface.ts +39 -0
- package/packages/ui/src/canvas/index.ts +17 -0
- package/packages/ui/src/contexts/ForestContext.vue +53 -0
- package/packages/ui/src/contexts/contexts.ts +153 -0
- package/packages/ui/src/contexts/index.ts +23 -0
- package/packages/ui/src/contexts/marketing-theme.ts +214 -0
- package/packages/ui/src/contexts/mobile-theme.ts +402 -0
- package/packages/ui/src/filter/FFilterBar.vue +72 -0
- package/packages/ui/src/filter/FFilterChip.vue +362 -0
- package/packages/ui/src/filter/FFilterClauses.vue +104 -0
- package/packages/ui/src/filter/FFilterTrigger.vue +183 -0
- package/packages/ui/src/filter/filter.theme.ts +27 -0
- package/packages/ui/src/filter/filterClause.ts +265 -0
- package/packages/ui/src/filter/filterPredicate.ts +134 -0
- package/packages/ui/src/filter/filterSchema.ts +82 -0
- package/packages/ui/src/filter/index.ts +36 -0
- package/packages/ui/src/forest-preset.ts +68 -0
- package/packages/ui/src/icons.ts +79 -0
- package/packages/ui/src/index.ts +17 -0
- package/packages/ui/src/kpi/FKpi.vue +253 -0
- package/packages/ui/src/kpi/FKpiGroup.vue +161 -0
- package/packages/ui/src/kpi/index.ts +20 -0
- package/packages/ui/src/kpi/kpi.theme.ts +168 -0
- package/packages/ui/src/kpi/kpiDelta.ts +82 -0
- package/packages/ui/src/kpi/kpiFormat.ts +72 -0
- package/packages/ui/src/kpi/kpiGroupState.ts +63 -0
- package/packages/ui/src/logo/FLogo.vue +52 -0
- package/packages/ui/src/logo/FSubLogo.vue +214 -0
- package/packages/ui/src/logo/index.ts +8 -0
- package/packages/ui/src/map/FMap.vue +355 -0
- package/packages/ui/src/map/FMapCompass.vue +154 -0
- package/packages/ui/src/map/FMapCoordinates.vue +134 -0
- package/packages/ui/src/map/FMapDrawTools.vue +116 -0
- package/packages/ui/src/map/FMapFullscreen.vue +92 -0
- package/packages/ui/src/map/FMapGeolocate.vue +208 -0
- package/packages/ui/src/map/FMapLayers.vue +75 -0
- package/packages/ui/src/map/FMapLegend.vue +45 -0
- package/packages/ui/src/map/FMapPopup.vue +129 -0
- package/packages/ui/src/map/FMapRamp.vue +69 -0
- package/packages/ui/src/map/FMapRegion.vue +77 -0
- package/packages/ui/src/map/FMapScale.vue +107 -0
- package/packages/ui/src/map/FMapSearch.vue +362 -0
- package/packages/ui/src/map/FMapTilt.vue +87 -0
- package/packages/ui/src/map/FMapTools.vue +61 -0
- package/packages/ui/src/map/FMapZoom.vue +81 -0
- package/packages/ui/src/map/FMarker.vue +218 -0
- package/packages/ui/src/map/FTrackScrubber.vue +94 -0
- package/packages/ui/src/map/FVehicleLegend.vue +72 -0
- package/packages/ui/src/map/FVehicleMark.vue +166 -0
- package/packages/ui/src/map/bin.ts +196 -0
- package/packages/ui/src/map/coordinates.ts +83 -0
- package/packages/ui/src/map/density.ts +401 -0
- package/packages/ui/src/map/draw.ts +356 -0
- package/packages/ui/src/map/ease.ts +67 -0
- package/packages/ui/src/map/fullscreen.ts +59 -0
- package/packages/ui/src/map/heat.ts +160 -0
- package/packages/ui/src/map/index.ts +351 -0
- package/packages/ui/src/map/liveMarkers.ts +60 -0
- package/packages/ui/src/map/map.css +116 -0
- package/packages/ui/src/map/mapColor.ts +31 -0
- package/packages/ui/src/map/mapContext.ts +12 -0
- package/packages/ui/src/map/mapLayers.ts +55 -0
- package/packages/ui/src/map/mapLegend.ts +70 -0
- package/packages/ui/src/map/mapSearch.theme.ts +79 -0
- package/packages/ui/src/map/mapSearch.ts +279 -0
- package/packages/ui/src/map/mapUrl.ts +117 -0
- package/packages/ui/src/map/markerAsset.ts +3 -0
- package/packages/ui/src/map/padding.ts +39 -0
- package/packages/ui/src/map/place.ts +192 -0
- package/packages/ui/src/map/ramp.ts +95 -0
- package/packages/ui/src/map/regionSelect.ts +163 -0
- package/packages/ui/src/map/route.ts +167 -0
- package/packages/ui/src/map/scale.ts +329 -0
- package/packages/ui/src/map/scaleBar.ts +67 -0
- package/packages/ui/src/map/selectionModel.ts +50 -0
- package/packages/ui/src/map/sources/coordinates.ts +58 -0
- package/packages/ui/src/map/sources/mapboxPlaces.ts +71 -0
- package/packages/ui/src/map/style.ts +14 -0
- package/packages/ui/src/map/track.ts +279 -0
- package/packages/ui/src/map/useFeatureSelect.ts +230 -0
- package/packages/ui/src/map/useLiveMarkers.ts +84 -0
- package/packages/ui/src/map/useMap.ts +150 -0
- package/packages/ui/src/map/useMapDraw.ts +776 -0
- package/packages/ui/src/map/useMapOverlay.ts +421 -0
- package/packages/ui/src/map/useMapTableSelection.ts +101 -0
- package/packages/ui/src/map/useMapUrlState.ts +155 -0
- package/packages/ui/src/map/useMarkerSource.ts +163 -0
- package/packages/ui/src/map/useRegionSelect.ts +309 -0
- package/packages/ui/src/map/useTokenMode.ts +61 -0
- package/packages/ui/src/map/useTrackPlayback.ts +131 -0
- package/packages/ui/src/map/useVehicleFilter.ts +51 -0
- package/packages/ui/src/map/useVisibleFeatures.ts +124 -0
- package/packages/ui/src/map/vehicle.ts +807 -0
- package/packages/ui/src/map/vehicleFilter.ts +284 -0
- package/packages/ui/src/map/vehicleLegend.ts +155 -0
- package/packages/ui/src/map/visibleFeatures.ts +192 -0
- package/packages/ui/src/map/zone.ts +291 -0
- package/packages/ui/src/shell/FAppNavbar.vue +68 -0
- package/packages/ui/src/shell/FAppShell.vue +163 -0
- package/packages/ui/src/shell/FAppSidebar.vue +215 -0
- package/packages/ui/src/shell/FPageToolbar.vue +126 -0
- package/packages/ui/src/shell/FScrollShadow.vue +43 -0
- package/packages/ui/src/shell/FSidePanel.vue +258 -0
- package/packages/ui/src/shell/FToolbarActions.vue +32 -0
- package/packages/ui/src/shell/index.ts +33 -0
- package/packages/ui/src/shell/nav.ts +37 -0
- package/packages/ui/src/shell/pageToolbar.ts +69 -0
- package/packages/ui/src/shell/shell.ts +74 -0
- package/packages/ui/src/shell/sidePanel.ts +115 -0
- package/packages/ui/src/themes/accordion.ts +36 -0
- package/packages/ui/src/themes/alert.ts +16 -0
- package/packages/ui/src/themes/auth-form.ts +19 -0
- package/packages/ui/src/themes/avatar-group.ts +9 -0
- package/packages/ui/src/themes/avatar.ts +18 -0
- package/packages/ui/src/themes/badge.ts +51 -0
- package/packages/ui/src/themes/banner.ts +21 -0
- package/packages/ui/src/themes/breadcrumb.ts +10 -0
- package/packages/ui/src/themes/button.mechanics.ts +191 -0
- package/packages/ui/src/themes/button.ts +136 -0
- package/packages/ui/src/themes/calendar.ts +29 -0
- package/packages/ui/src/themes/card.ts +67 -0
- package/packages/ui/src/themes/carousel.ts +41 -0
- package/packages/ui/src/themes/checkbox-group.ts +20 -0
- package/packages/ui/src/themes/checkbox.ts +47 -0
- package/packages/ui/src/themes/chip.ts +50 -0
- package/packages/ui/src/themes/collapsible.ts +26 -0
- package/packages/ui/src/themes/color-picker.ts +13 -0
- package/packages/ui/src/themes/command-palette.ts +37 -0
- package/packages/ui/src/themes/container.ts +14 -0
- package/packages/ui/src/themes/context-menu.ts +38 -0
- package/packages/ui/src/themes/dashboard-group.ts +11 -0
- package/packages/ui/src/themes/dashboard-navbar.ts +10 -0
- package/packages/ui/src/themes/dashboard-panel.ts +18 -0
- package/packages/ui/src/themes/dashboard-resize-handle.ts +35 -0
- package/packages/ui/src/themes/dashboard-sidebar-collapse.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar-toggle.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar.ts +58 -0
- package/packages/ui/src/themes/dashboard-toolbar.ts +15 -0
- package/packages/ui/src/themes/drawer.ts +45 -0
- package/packages/ui/src/themes/dropdown-menu.ts +38 -0
- package/packages/ui/src/themes/empty.ts +43 -0
- package/packages/ui/src/themes/field-group.ts +52 -0
- package/packages/ui/src/themes/file-upload.ts +38 -0
- package/packages/ui/src/themes/footer-columns.ts +13 -0
- package/packages/ui/src/themes/footer.ts +9 -0
- package/packages/ui/src/themes/form-field.ts +25 -0
- package/packages/ui/src/themes/form.ts +22 -0
- package/packages/ui/src/themes/header.ts +12 -0
- package/packages/ui/src/themes/icon.ts +61 -0
- package/packages/ui/src/themes/index.ts +99 -0
- package/packages/ui/src/themes/input-date.ts +73 -0
- package/packages/ui/src/themes/input-menu.ts +66 -0
- package/packages/ui/src/themes/input-number.ts +58 -0
- package/packages/ui/src/themes/input-rating.ts +25 -0
- package/packages/ui/src/themes/input-tags.ts +63 -0
- package/packages/ui/src/themes/input-time.ts +70 -0
- package/packages/ui/src/themes/input.ts +148 -0
- package/packages/ui/src/themes/kbd.ts +27 -0
- package/packages/ui/src/themes/link.ts +27 -0
- package/packages/ui/src/themes/listbox.ts +48 -0
- package/packages/ui/src/themes/main.ts +6 -0
- package/packages/ui/src/themes/marquee.ts +45 -0
- package/packages/ui/src/themes/modal.ts +46 -0
- package/packages/ui/src/themes/navigation-menu.ts +80 -0
- package/packages/ui/src/themes/page-anchors.ts +7 -0
- package/packages/ui/src/themes/page-aside.ts +7 -0
- package/packages/ui/src/themes/page-card.ts +22 -0
- package/packages/ui/src/themes/page-cta.ts +18 -0
- package/packages/ui/src/themes/page-feature.ts +13 -0
- package/packages/ui/src/themes/page-header.ts +16 -0
- package/packages/ui/src/themes/page-hero.ts +15 -0
- package/packages/ui/src/themes/page-links.ts +12 -0
- package/packages/ui/src/themes/page-section.ts +14 -0
- package/packages/ui/src/themes/page.ts +7 -0
- package/packages/ui/src/themes/pagination.ts +17 -0
- package/packages/ui/src/themes/pin-input.ts +73 -0
- package/packages/ui/src/themes/popover.ts +30 -0
- package/packages/ui/src/themes/progress.ts +29 -0
- package/packages/ui/src/themes/prose.ts +45 -0
- package/packages/ui/src/themes/radio-group.ts +39 -0
- package/packages/ui/src/themes/scroll-area.ts +23 -0
- package/packages/ui/src/themes/select-menu.ts +71 -0
- package/packages/ui/src/themes/select.ts +72 -0
- package/packages/ui/src/themes/separator.ts +28 -0
- package/packages/ui/src/themes/sidebar.ts +127 -0
- package/packages/ui/src/themes/skeleton.ts +15 -0
- package/packages/ui/src/themes/slideover.ts +87 -0
- package/packages/ui/src/themes/slider.ts +48 -0
- package/packages/ui/src/themes/stepper.ts +41 -0
- package/packages/ui/src/themes/switch.ts +63 -0
- package/packages/ui/src/themes/table.ts +34 -0
- package/packages/ui/src/themes/tabs.ts +74 -0
- package/packages/ui/src/themes/textarea.ts +77 -0
- package/packages/ui/src/themes/timeline.ts +25 -0
- package/packages/ui/src/themes/toast.ts +29 -0
- package/packages/ui/src/themes/toaster.ts +66 -0
- package/packages/ui/src/themes/tooltip.ts +31 -0
- package/packages/ui/src/themes/tree.ts +41 -0
- package/packages/ui/src/themes/user.ts +28 -0
- package/styles.cdn.gen.css +2047 -0
- package/theme.gen.json +1 -0
|
@@ -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>
|
|
@@ -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>
|