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