@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,215 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The app shell's sidebar — the brand mark, the nav, and the collapsed icon rail.
|
|
3
|
+
//
|
|
4
|
+
// <FAppSidebar :groups="nav" sub-brand="OPS" v-model:open="open" v-model:collapsed="collapsed" />
|
|
5
|
+
//
|
|
6
|
+
// The app supplies the LIST; everything else here is the same in every Forest app. Two of those are
|
|
7
|
+
// pairings that break silently if a sidebar is composed by hand:
|
|
8
|
+
//
|
|
9
|
+
// · The scrollbar is hidden (themes/dashboard-sidebar.ts), so <FScrollShadow> is what tells a reader
|
|
10
|
+
// the nav runs past the fold. Without it a long list clips with no signal at all.
|
|
11
|
+
// · Slot props, NOT the models: the mobile slideover re-renders these slots with collapsed=false, so
|
|
12
|
+
// reading `collapsed` here would leak the desktop rail's icon-only rows into the phone menu.
|
|
13
|
+
import { computed } from 'vue';
|
|
14
|
+
import { F, WORDMARK_H, WORDMARK_W } from '@humanforest/tokens/logo';
|
|
15
|
+
import { setWord, runInk } from '@humanforest/tokens/glyphs';
|
|
16
|
+
import { keycapGeometry, SUBLOGO_MOHR_SIZE } from '@humanforest/tokens/sublogo';
|
|
17
|
+
import { FSubLogo } from '../logo';
|
|
18
|
+
import FScrollShadow from './FScrollShadow.vue';
|
|
19
|
+
import type { FNavGroup, FNavItem } from './nav';
|
|
20
|
+
|
|
21
|
+
// ── The lockup and the room it owns ──
|
|
22
|
+
// The brand's spacing module is the f from the wordmark: one f of clear space on every side, with no
|
|
23
|
+
// type or edge inside that zone. One f is 0.982 × the rendered height, so the expanded lockup sits at
|
|
24
|
+
// 24px in a block of 24 + 2×23.6 ≈ 71px.
|
|
25
|
+
//
|
|
26
|
+
// ★ THE BLOCK IS NOT A HEADER STRIP. UDashboardSidebar's header recipe is `h-(--ui-header-height)`,
|
|
27
|
+
// and the largest lockup that keeps one f inside 48px is 16px, at which the wordmark reads as a
|
|
28
|
+
// caption rather than a mark. So the block takes `h-auto` with the clear space as padding, and the
|
|
29
|
+
// navbar beside it keeps that height — the rail reads as a brand block above the nav, not one strip.
|
|
30
|
+
//
|
|
31
|
+
// ★ THE MOBILE MENU CANNOT READ THE CUSTOM PROPERTIES. They are declared by `:style` on the sidebar
|
|
32
|
+
// root, which is the DESKTOP column; the slideover is a sibling <Menu> teleported to <body>, so the
|
|
33
|
+
// root is not an ancestor of it and `p-(--forest-rail-clear)` there resolves to nothing — the header
|
|
34
|
+
// collapsed to 33px with the lockup 4px off the panel edge. The recipe's classes DO reach both, so the
|
|
35
|
+
// padding splits on the breakpoint the two states already split on: the column is `hidden lg:flex` and
|
|
36
|
+
// the menu content is `lg:hidden`, so `p-6` dresses the menu and `lg:p-(…)` the column. 24px is half a
|
|
37
|
+
// pixel off one f at this size, which is as close as a spacing step gets to the module.
|
|
38
|
+
//
|
|
39
|
+
// The size is a HEIGHT, so the clear space holds whatever the lockup says; the width follows the lead
|
|
40
|
+
// and the sub-brand together, and the mark does not shrink. `forest [CONSOLE]` measures 247px in a
|
|
41
|
+
// 256px default column, so a lead and a word both long enough will run past the expanded rail.
|
|
42
|
+
const LOCKUP_SIZE = 24;
|
|
43
|
+
const oneF = (size: number) => size * (F.h / WORDMARK_H);
|
|
44
|
+
const CLEAR = `${oneF(LOCKUP_SIZE).toFixed(2)}px`;
|
|
45
|
+
// The block the brand owns, in BOTH states. Collapsing already narrows the column and swaps the mark;
|
|
46
|
+
// growing the block as well would shove the whole nav down at the moment the rail is trying to give
|
|
47
|
+
// the nav room. Holding it constant also keeps one f exactly when the rail mark is lockup-sized.
|
|
48
|
+
const BLOCK_H = LOCKUP_SIZE + 2 * oneF(LOCKUP_SIZE);
|
|
49
|
+
|
|
50
|
+
const props = withDefaults(defineProps<{
|
|
51
|
+
/** The nav, in the order it should read. Group labels head each section when expanded. */
|
|
52
|
+
groups?: FNavGroup[];
|
|
53
|
+
/** Sub-brand for the lockup — the keycap word. Drops to the keycap badge on the rail. */
|
|
54
|
+
subBrand?: string;
|
|
55
|
+
/**
|
|
56
|
+
* The word ahead of the keycap — `fleet [CONSOLE]`. 'forest' (default) draws the wordmark.
|
|
57
|
+
* Expanded only: the collapsed rail carries the keycap alone.
|
|
58
|
+
*/
|
|
59
|
+
lead?: string;
|
|
60
|
+
/**
|
|
61
|
+
* The word on the collapsed rail. Defaults to the sub-brand, which is right while it reads at rail
|
|
62
|
+
* size — `DS` and `OPS` do. Set it where the sub-brand does not: `fleet [CONSOLE]` collapses to `C`,
|
|
63
|
+
* and the app says which letter that is rather than a width threshold deciding for it.
|
|
64
|
+
* A single letter draws the app icon; two or more draw the keycap.
|
|
65
|
+
*/
|
|
66
|
+
railWord?: string;
|
|
67
|
+
/** Rail width in the dashboard group's unit, and the drag bounds. */
|
|
68
|
+
width?: number;
|
|
69
|
+
minWidth?: number;
|
|
70
|
+
maxWidth?: number;
|
|
71
|
+
}>(), { groups: () => [], subBrand: 'DS', lead: 'forest', width: 16, minWidth: 14, maxWidth: 22 });
|
|
72
|
+
|
|
73
|
+
const open = defineModel<boolean>('open', { default: false });
|
|
74
|
+
const collapsed = defineModel<boolean>('collapsed', { default: false });
|
|
75
|
+
|
|
76
|
+
// ── The collapsed rail's mark ──
|
|
77
|
+
// The rail word is SHORT by construction — `railWord` exists so it can be — so the mark is sized to
|
|
78
|
+
// the ROW GRID it sits above rather than fitted to the column. 32px is the collapsed nav item's box,
|
|
79
|
+
// which is what the eye compares the mark against; one f either side inside the 4rem rail would hold
|
|
80
|
+
// it to 21px instead, two thirds of the rows below it.
|
|
81
|
+
//
|
|
82
|
+
// ★ THE MODULE YIELDS IN BOTH DIRECTIONS ONCE THE MARK IS SIZED TO THE NAV. One f of a 32px mark is
|
|
83
|
+
// 31.4px, which wants 63px of the 64px column across and a 95px block down — three times the mark, and
|
|
84
|
+
// the nav shoved down by it. The mark centres in what BLOCK_H and the column leave instead, 19.6px
|
|
85
|
+
// down and 16px across for `[C]`. That shortfall is the price of a mark that reads at the nav's scale;
|
|
86
|
+
// the module still sets the block itself, from the expanded lockup.
|
|
87
|
+
//
|
|
88
|
+
// ★ WIDTH IS A CAP, NOT A FIT. `[C]` is 32px wide at full height and `DS` 46.9, so a word past two
|
|
89
|
+
// letters solves its height down instead — `OPS` would want 63.2px and lands at 24px tall. That is the
|
|
90
|
+
// app's signal to pass a shorter `railWord`.
|
|
91
|
+
const RAIL_H = 32; // the collapsed nav item's box — see themes/dashboard-sidebar.ts
|
|
92
|
+
const RAIL_W_MAX = 48; // the rail itself is --sidebar-width-icon (4rem), which :collapsed-size="4" matches
|
|
93
|
+
|
|
94
|
+
// Painted width of a keycap holding `word`, per px of rendered height. Pure maths over the glyph atlas
|
|
95
|
+
// — the same call <FSubLogo> composes through, so the two can never disagree.
|
|
96
|
+
//
|
|
97
|
+
// The `lead` does NOT enter it. keycapGeometry's leadW only sets boxX — where the keycap starts — and
|
|
98
|
+
// the badge viewBox is [boxX - bleed, boxW + 2·bleed], so the lead moves the origin and never the
|
|
99
|
+
// width. The constant below is the drawn wordmark's, and it stays right for any lead.
|
|
100
|
+
const keycapRatio = (word: string) =>
|
|
101
|
+
keycapGeometry(
|
|
102
|
+
WORDMARK_W,
|
|
103
|
+
runInk(setWord(word, { font: 'mohr', size: SUBLOGO_MOHR_SIZE })),
|
|
104
|
+
).badgeVB[1] / WORDMARK_H;
|
|
105
|
+
|
|
106
|
+
const railKeycap = computed(() => (props.railWord ?? props.subBrand).toUpperCase());
|
|
107
|
+
|
|
108
|
+
// ★ A SINGLE LETTER IS THE APP ICON, NOT A ONE-LETTER KEYCAP. Same idea — first letter, square — and
|
|
109
|
+
// scripts/favicons.ts already writes that exact mark per registered product, so a rail composing its
|
|
110
|
+
// own would put a second shape on one brand. They are close enough to miss and far enough to see:
|
|
111
|
+
// keycapGeometry pads the letter's ink equally on four sides and lands portrait (18.9 × 21.4 plate at
|
|
112
|
+
// 24px), where iconGeometry builds the square first and fits the letter to 62% of its cap (19.5 sq).
|
|
113
|
+
const railIsIcon = computed(() => railKeycap.value.length === 1);
|
|
114
|
+
|
|
115
|
+
// the icon is square; a keycap's width follows the word it holds
|
|
116
|
+
const railRatio = computed(() => (railIsIcon.value ? 1 : keycapRatio(railKeycap.value)));
|
|
117
|
+
const railSize = computed(() => Math.min(RAIL_H, Math.floor(RAIL_W_MAX / railRatio.value)));
|
|
118
|
+
// whatever the block has left around the mark. At 32px that is 19.6px a side rather than the module's
|
|
119
|
+
// 31.4 — the same relaxation the column already makes, and for the same reason: a mark sized to the
|
|
120
|
+
// nav cannot also hold a module scaled to itself inside a 4rem rail.
|
|
121
|
+
const railClear = computed(() => `${((BLOCK_H - railSize.value) / 2).toFixed(2)}px`);
|
|
122
|
+
|
|
123
|
+
// <UNavigationMenu> draws a `separator` between each TOP-LEVEL ARRAY, so the item shape carries the
|
|
124
|
+
// group boundary — and the two states want different shapes:
|
|
125
|
+
//
|
|
126
|
+
// expanded — ONE flat array. Each group already announces itself with an uppercase label; a rule
|
|
127
|
+
// as well is a second divider doing one job.
|
|
128
|
+
// collapsed — an array PER GROUP and no labels, since the labels are hidden at that width and the
|
|
129
|
+
// rule is then the only thing marking where a group ends.
|
|
130
|
+
const toItem = (item: FNavItem) => ({
|
|
131
|
+
label: item.label,
|
|
132
|
+
icon: item.icon,
|
|
133
|
+
to: item.to,
|
|
134
|
+
disabled: !item.to,
|
|
135
|
+
...(item.wip
|
|
136
|
+
? { badge: { label: 'WIP', color: 'warning' as const, variant: 'subtle' as const } }
|
|
137
|
+
: !item.to
|
|
138
|
+
? { badge: { label: 'Soon', color: 'neutral' as const, variant: 'subtle' as const } }
|
|
139
|
+
: {}),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const navFlat = computed(() =>
|
|
143
|
+
props.groups.flatMap((group) => [
|
|
144
|
+
{ label: group.label, type: 'label' as const },
|
|
145
|
+
...group.items.map(toItem),
|
|
146
|
+
]),
|
|
147
|
+
);
|
|
148
|
+
const navGrouped = computed(() => props.groups.map((group) => group.items.map(toItem)));
|
|
149
|
+
</script>
|
|
150
|
+
|
|
151
|
+
<template>
|
|
152
|
+
<!-- Two corrections to the phone menu, which is stock's surface rather than the column's:
|
|
153
|
+
· `toggle-side` — stock opens with the close button at the START, which puts a dismiss control
|
|
154
|
+
where the brand goes and shoves the lockup off the panel's own left edge. The mark leads on
|
|
155
|
+
both surfaces; the close follows it.
|
|
156
|
+
· `content: divide-y-0` — the slideover recipe carries `divide-y divide-default`, drawing a rule
|
|
157
|
+
between the brand block and the nav. Same call as the shell's chrome gutter (see FAppShell's style
|
|
158
|
+
block): the block and the list are one surface, and the label above each group is what
|
|
159
|
+
separates them. -->
|
|
160
|
+
<UDashboardSidebar
|
|
161
|
+
v-model:open="open"
|
|
162
|
+
v-model:collapsed="collapsed"
|
|
163
|
+
collapsible
|
|
164
|
+
resizable
|
|
165
|
+
:collapsed-size="4"
|
|
166
|
+
:default-size="width"
|
|
167
|
+
:min-size="minWidth"
|
|
168
|
+
:max-size="maxWidth"
|
|
169
|
+
mode="slideover"
|
|
170
|
+
toggle-side="right"
|
|
171
|
+
:menu="{ inset: true }"
|
|
172
|
+
:style="{ '--forest-rail-clear': CLEAR, '--forest-rail-clear-icon': railClear }"
|
|
173
|
+
:ui="{
|
|
174
|
+
header: 'h-auto p-6 lg:p-(--forest-rail-clear) group-data-[collapsed=true]:px-0'
|
|
175
|
+
+ ' group-data-[collapsed=true]:py-(--forest-rail-clear-icon)',
|
|
176
|
+
content: 'divide-y-0',
|
|
177
|
+
}"
|
|
178
|
+
>
|
|
179
|
+
<template #header="{ collapsed: rail }">
|
|
180
|
+
<slot name="brand" :collapsed="rail">
|
|
181
|
+
<!-- Ink is currentColor, so the mark follows the theme. On the rail the lockup drops to what
|
|
182
|
+
`railWord` says reads at that size — the app icon for one letter, the keycap for more.
|
|
183
|
+
|
|
184
|
+
The NAME does not abbreviate with the mark. FSubLogo builds its label from the word it
|
|
185
|
+
draws, which on the rail is an initial — "fleet C" is what the eye sees and not what the
|
|
186
|
+
mark means. The fallthrough attribute overrides the component's own on the root svg. -->
|
|
187
|
+
<span class="shrink-0 text-[var(--color-forest-950)] dark:text-white" :class="rail && 'mx-auto'">
|
|
188
|
+
<FSubLogo
|
|
189
|
+
:word="rail ? railKeycap : subBrand"
|
|
190
|
+
:aria-label="`${lead} ${subBrand.toUpperCase()}`"
|
|
191
|
+
:lead="lead"
|
|
192
|
+
:variant="rail ? (railIsIcon ? 'icon' : 'badge') : 'lockup'"
|
|
193
|
+
:size="rail ? railSize : LOCKUP_SIZE"
|
|
194
|
+
/>
|
|
195
|
+
</span>
|
|
196
|
+
</slot>
|
|
197
|
+
</template>
|
|
198
|
+
|
|
199
|
+
<template #default="{ collapsed: rail }">
|
|
200
|
+
<FScrollShadow>
|
|
201
|
+
<UNavigationMenu
|
|
202
|
+
:items="rail ? navGrouped : navFlat"
|
|
203
|
+
:collapsed="rail"
|
|
204
|
+
orientation="vertical"
|
|
205
|
+
tooltip
|
|
206
|
+
class="w-full"
|
|
207
|
+
/>
|
|
208
|
+
</FScrollShadow>
|
|
209
|
+
</template>
|
|
210
|
+
|
|
211
|
+
<template v-if="$slots.footer" #footer="{ collapsed: rail }">
|
|
212
|
+
<slot name="footer" :collapsed="rail" />
|
|
213
|
+
</template>
|
|
214
|
+
</UDashboardSidebar>
|
|
215
|
+
</template>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The page's own toolbar — the filters, search and view controls for the body below it, or the
|
|
3
|
+
// section's sub-route navigation.
|
|
4
|
+
//
|
|
5
|
+
// <FPageToolbar>
|
|
6
|
+
// <FFilterBar v-model="clauses" :schema="schema" />
|
|
7
|
+
// </FPageToolbar>
|
|
8
|
+
//
|
|
9
|
+
// Teleports to the top of <FAppShell>'s inset card. A page renders inside the panel and cannot fill a
|
|
10
|
+
// slot on the shell that contains it — slots flow down. What makes teleport the answer rather than a
|
|
11
|
+
// workaround is whose content this is: a navbar's crumb belongs to the app, but THIS route's filters
|
|
12
|
+
// live in this route's setup, and a v-model has to reach them. Teleport moves the DOM node and leaves
|
|
13
|
+
// the component tree alone, so it does.
|
|
14
|
+
//
|
|
15
|
+
// TWO LAYERS, and the split is the point. The root bleeds to the card's edge so the hairline reads as
|
|
16
|
+
// a rule across the whole card; the container inside carries the well, so the controls line up with
|
|
17
|
+
// the page content rather than with the card. On a 1720px card those are 252px apart.
|
|
18
|
+
//
|
|
19
|
+
// A page needing both nav and filters writes two of these. They stack, each keeping its own rule.
|
|
20
|
+
//
|
|
21
|
+
// Renders IN PLACE when no shell is above it. A page without chrome keeps its controls; they just
|
|
22
|
+
// stop pinning.
|
|
23
|
+
import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, useSlots } from 'vue';
|
|
24
|
+
import { TOOLBAR_SEAT_KEY } from './shell';
|
|
25
|
+
import {
|
|
26
|
+
pageToolbarSurface,
|
|
27
|
+
pageToolbarWellClass,
|
|
28
|
+
type PageToolbarVariant,
|
|
29
|
+
type PageToolbarWell,
|
|
30
|
+
} from './pageToolbar';
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(defineProps<{
|
|
33
|
+
/** Which rung the controls align to. Match the page body's own well, or they will not line up. */
|
|
34
|
+
well?: PageToolbarWell;
|
|
35
|
+
/**
|
|
36
|
+
* How the bar draws itself: a hairline rule across the card, or an inset panel sitting on it.
|
|
37
|
+
* A page stacking navigation over filters gives the navigation a rule and the filters a card.
|
|
38
|
+
*/
|
|
39
|
+
variant?: PageToolbarVariant;
|
|
40
|
+
}>(), { well: 'default', variant: 'rule' });
|
|
41
|
+
|
|
42
|
+
const seat = inject(TOOLBAR_SEAT_KEY, null);
|
|
43
|
+
const target = computed(() => seat?.toolbar.value ?? null);
|
|
44
|
+
const wellClass = computed(() => pageToolbarWellClass(props.well));
|
|
45
|
+
const surface = computed(() => pageToolbarSurface(props.variant));
|
|
46
|
+
|
|
47
|
+
const slots = useSlots();
|
|
48
|
+
|
|
49
|
+
// What pushes a view's actions to the right, and it moves depending on how the bar was filled. The
|
|
50
|
+
// `right` wrapper carries `ms-auto`, and it only exists in the DEFAULT-slot fallback — a bar filled
|
|
51
|
+
// through the default slot has no such wrapper, so nothing would push the seam over and the actions
|
|
52
|
+
// would strand against the bar's own content.
|
|
53
|
+
//
|
|
54
|
+
// Never both: two auto margins SHARE the free space rather than one taking it, which opens a gap
|
|
55
|
+
// between the bar's own right-hand content and the actions teleported after it.
|
|
56
|
+
const seamPush = computed(() => (slots.default ? 'ms-auto' : ''));
|
|
57
|
+
|
|
58
|
+
const actions = ref<HTMLElement | null>(null);
|
|
59
|
+
|
|
60
|
+
// Claim while on screen, release when off it. The shell keeps claimants in claim order and derives
|
|
61
|
+
// the seam from the first of them, so a section stacking a nav row over a filter row keeps the nav
|
|
62
|
+
// row's seam while it is there, and promotes the filter row if the nav row goes.
|
|
63
|
+
onMounted(() => {
|
|
64
|
+
if (seat && actions.value) seat.claimActions(actions.value);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
onBeforeUnmount(() => {
|
|
68
|
+
if (seat && actions.value) seat.releaseActions(actions.value);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// BOTH pairs, and neither is spare. A toolbar cached by <KeepAlive> — an ordinary tabbed console
|
|
72
|
+
// section — fires activated/deactivated and NEITHER of the pair above. A Teleport leaves its content
|
|
73
|
+
// in the target while its component is cached, so a deactivated toolbar's bar is still sitting in the
|
|
74
|
+
// seat: one that kept its claim would hold the seam on the bar of a tab nobody is looking at, and the
|
|
75
|
+
// active view's actions would land there. Outside <KeepAlive> only the mount pair fires. Claiming and
|
|
76
|
+
// releasing are idempotent, so the two firing together — first appearance, or a cached toolbar
|
|
77
|
+
// finally unmounting — costs nothing.
|
|
78
|
+
//
|
|
79
|
+
// Reappearing claims afresh, at the BACK of the list. A cached nav row returning to a section whose
|
|
80
|
+
// filter row claimed while it was away leaves the seam where it now is.
|
|
81
|
+
onActivated(() => {
|
|
82
|
+
if (seat && actions.value) seat.claimActions(actions.value);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
onDeactivated(() => {
|
|
86
|
+
if (seat && actions.value) seat.releaseActions(actions.value);
|
|
87
|
+
});
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<template>
|
|
91
|
+
<!-- `disabled` rather than v-if: with no shell above, the bar still renders where it was written,
|
|
92
|
+
it just stops moving. Teleport wants a valid `to` even when disabled, hence the body fallback. -->
|
|
93
|
+
<Teleport :to="target ?? 'body'" :disabled="!target">
|
|
94
|
+
<!-- `px-0 sm:px-0` — BOTH, or tailwind-merge keeps stock's sm:px-6 and the bar is double-guttered.
|
|
95
|
+
The well owns the gutter now.
|
|
96
|
+
`overflow-x-visible`, NOT overflow-visible: tailwind-merge keeps `overflow` and `overflow-x`
|
|
97
|
+
in separate groups, so the plain one leaves stock's overflow-x-auto in place and the bleed
|
|
98
|
+
scrolls instead of the controls.
|
|
99
|
+
The height comes from themes/dashboard-toolbar.ts and is not restated here. -->
|
|
100
|
+
<UDashboardToolbar :ui="{ root: ['px-0 sm:px-0 overflow-x-visible', surface.root] }">
|
|
101
|
+
<!-- The lane <FAppShell> measures on the page's own scrollport (0px when it does not scroll).
|
|
102
|
+
This wrapper carries it, not the root above: the root's rule has to stay full-bleed, but
|
|
103
|
+
the well below has to centre over the same box the scrollport does, or the two drift apart
|
|
104
|
+
the moment a page is long enough to grow a scrollbar. -->
|
|
105
|
+
<div class="w-full pe-(--forest-scrollport-lane)">
|
|
106
|
+
<UContainer :class="wellClass">
|
|
107
|
+
<!-- The row, and the layer the `card` variant fills. It has to be INSIDE the container
|
|
108
|
+
rather than be it: a panel has to start where the content starts, and the container is
|
|
109
|
+
the thing that knows where that is. -->
|
|
110
|
+
<div :class="['flex items-center gap-1.5 overflow-x-auto', surface.row]">
|
|
111
|
+
<slot>
|
|
112
|
+
<div class="flex items-center gap-1.5"><slot name="left" /></div>
|
|
113
|
+
<div class="flex items-center gap-1.5 ms-auto"><slot name="right" /></div>
|
|
114
|
+
</slot>
|
|
115
|
+
|
|
116
|
+
<!-- Filled from below by <FToolbarActions>. LAST, and outside the <slot>, for two reasons:
|
|
117
|
+
a view's controls land after whatever the section put in #right, and a toolbar using
|
|
118
|
+
the default slot — which has no `right` region at all — can still receive them.
|
|
119
|
+
`ms-auto` on `right` pushes that group over, so either way these end up rightmost. -->
|
|
120
|
+
<div ref="actions" :class="['flex items-center gap-1.5 empty:hidden', seamPush]" />
|
|
121
|
+
</div>
|
|
122
|
+
</UContainer>
|
|
123
|
+
</div>
|
|
124
|
+
</UDashboardToolbar>
|
|
125
|
+
</Teleport>
|
|
126
|
+
</template>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Fades the top and bottom edges of the scroll container this sits inside, so a long column reads as
|
|
3
|
+
// "more below" rather than as a hard clipped edge. Wraps Nuxt UI's useScrollShadow, whose mask only
|
|
4
|
+
// appears while the container overflows and has not reached that edge.
|
|
5
|
+
//
|
|
6
|
+
// It is the overflow signal for the sidebar, which hides its scrollbar (themes/dashboard-sidebar.ts) —
|
|
7
|
+
// a rail 64px wide has no room for a bar beside the icons, and expanded it draws hard against the
|
|
8
|
+
// inset panel's edge. Without this the nav loses both, and a long list clips with nothing to say so.
|
|
9
|
+
//
|
|
10
|
+
// The scroller is the PARENT: UDashboardSidebar owns the scrolling `data-slot="body"` div and a child
|
|
11
|
+
// cannot style it through a binding, so this wrapper is `display: contents` — no box of its own — and
|
|
12
|
+
// reaches up. Scoping per mount also covers the sidebar rendering its default slot twice, for the
|
|
13
|
+
// desktop rail and the mobile slideover: each gets its own instance and its own ref.
|
|
14
|
+
import { onMounted, ref, watchEffect } from 'vue';
|
|
15
|
+
import { useScrollShadow } from '@nuxt/ui/composables';
|
|
16
|
+
|
|
17
|
+
const { size = 20 } = defineProps<{
|
|
18
|
+
/** Depth of the fade, in px. */
|
|
19
|
+
size?: number;
|
|
20
|
+
}>();
|
|
21
|
+
|
|
22
|
+
const root = ref<HTMLElement | null>(null);
|
|
23
|
+
const scroller = ref<HTMLElement | null>(null);
|
|
24
|
+
onMounted(() => {
|
|
25
|
+
scroller.value = root.value?.parentElement ?? null;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const { style } = useScrollShadow(scroller, { size: () => size });
|
|
29
|
+
|
|
30
|
+
watchEffect(() => {
|
|
31
|
+
const el = scroller.value;
|
|
32
|
+
if (!el) return;
|
|
33
|
+
const mask = style.value?.maskImage ?? '';
|
|
34
|
+
el.style.maskImage = mask;
|
|
35
|
+
el.style.webkitMaskImage = mask;
|
|
36
|
+
});
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<div ref="root" style="display: contents">
|
|
41
|
+
<slot />
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A column that belongs to a CONTAINER rather than to the app — a settings sub-nav inside a card, a
|
|
3
|
+
// filter or list panel over a map canvas.
|
|
4
|
+
//
|
|
5
|
+
// <FSidePanel v-model:open="filters" title="Filters">…</FSidePanel> a page's own column
|
|
6
|
+
// <FSidePanel v-model:open="filters" behaviour="overlay" size="lg" title="Filters"> over a canvas
|
|
7
|
+
//
|
|
8
|
+
// ★ WHAT THIS IS NOT. It is not the app's sidebar — that is <FAppSidebar>, which is viewport-scoped,
|
|
9
|
+
// carries navigation, persists its width and hands over to a menu on a phone. This one answers "what am
|
|
10
|
+
// I looking at", not "where am I in the app", and it dies with the view it belongs to.
|
|
11
|
+
//
|
|
12
|
+
// ★ WHY IT EXISTS AT ALL, given <USidebar> is right there. One reason, and it is structural rather than
|
|
13
|
+
// cosmetic: `collapsible="offcanvas"` is `hidden lg:block` and portals itself into a page-level slideover
|
|
14
|
+
// under 1024px. Those are VIEWPORT decisions, and this column's container is a card that can be 600px
|
|
15
|
+
// wide on a 1600px screen — the same panel would get the desktop treatment in one and a slideover flying
|
|
16
|
+
// in from the page edge in the other. No `:ui` override reaches that; it is `collapsible`'s own
|
|
17
|
+
// structure. So this uses `collapsible="none"` — which renders a single flow element, no gap spacer, no
|
|
18
|
+
// fixed container, no rail — and owns the positioning, the open/close travel and the reflow itself.
|
|
19
|
+
//
|
|
20
|
+
// What it still takes from <USidebar>: the header/body/footer skeleton, the divided inner, and Forest's
|
|
21
|
+
// sidebar theme fragment (the MohrAlt title), so a panel's heading and the app rail's
|
|
22
|
+
// heading cannot drift apart.
|
|
23
|
+
//
|
|
24
|
+
// ★ `variant` is PINNED here rather than inherited. The Forest fragment defaults USidebar to `floating`
|
|
25
|
+
// for the app rail's sake; a panel that let that default through would silently move the day the rail's
|
|
26
|
+
// surface is retuned.
|
|
27
|
+
import { computed, onBeforeUnmount, ref, watchEffect } from 'vue';
|
|
28
|
+
import {
|
|
29
|
+
CANVAS_INSET_DURATION_VAR,
|
|
30
|
+
CANVAS_INSET_VAR,
|
|
31
|
+
SIDE_PANEL_GUTTER_PX,
|
|
32
|
+
SIDE_PANEL_WIDTH,
|
|
33
|
+
type SidePanelBehaviour,
|
|
34
|
+
type SidePanelSide,
|
|
35
|
+
type SidePanelSize,
|
|
36
|
+
} from './sidePanel';
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(
|
|
39
|
+
defineProps<{
|
|
40
|
+
/** Does content beside it move over (`push`), or does it lie on top (`overlay`)? */
|
|
41
|
+
behaviour?: SidePanelBehaviour;
|
|
42
|
+
/** Which edge of the container it takes. `end` by default — see the note on the default. */
|
|
43
|
+
side?: SidePanelSide;
|
|
44
|
+
/** `md` matches the HUD's rail; `lg` is for a list of rows. */
|
|
45
|
+
size?: SidePanelSize;
|
|
46
|
+
title?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
/** The close affordance in the header. On by default for overlay, which has no other way out. */
|
|
49
|
+
close?: boolean;
|
|
50
|
+
}>(),
|
|
51
|
+
// ★ `end` by default, which is the opposite of what a lone column would want. A panel is SECONDARY
|
|
52
|
+
// chrome: the shell it sits in already has FAppSidebar at the start edge, so a panel there stacks two
|
|
53
|
+
// columns on one side and shoves the content across — while the end edge keeps the content beside the
|
|
54
|
+
// navigation and the weight split between two edges. It is what a console with a left rail does
|
|
55
|
+
// everywhere (Linear, Figma, Retool). Pass `side="start"` where the panel IS the navigation for its
|
|
56
|
+
// view, which in practice means a settings sub-nav.
|
|
57
|
+
{ behaviour: 'push', side: 'end', size: 'md' },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const open = defineModel<boolean>('open', { default: true });
|
|
61
|
+
|
|
62
|
+
const width = computed(() => SIDE_PANEL_WIDTH[props.size].css);
|
|
63
|
+
const overlay = computed(() => props.behaviour === 'overlay');
|
|
64
|
+
const showClose = computed(() => props.close ?? overlay.value);
|
|
65
|
+
|
|
66
|
+
// ── The reflow ────────────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// ★ Measured off the CONTAINER, never the viewport — the whole reason this component exists. Below the
|
|
68
|
+
// threshold the panel takes the container whole: a 22rem column inside a 24rem card leaves 2rem for what
|
|
69
|
+
// it is filtering, which is worse than covering it outright and being dismissed.
|
|
70
|
+
//
|
|
71
|
+
// A ResizeObserver rather than a CSS container query because the query would have to be declared on the
|
|
72
|
+
// PARENT, and a child cannot stamp that on an element it does not own — a panel dropped into an
|
|
73
|
+
// arbitrary card would silently never reflow. This measures whatever it is actually inside.
|
|
74
|
+
const FULL_WIDTH_BELOW = 480;
|
|
75
|
+
const el = ref<HTMLElement | null>(null);
|
|
76
|
+
const narrow = ref(false);
|
|
77
|
+
let ro: ResizeObserver | null = null;
|
|
78
|
+
|
|
79
|
+
watchEffect(() => {
|
|
80
|
+
ro?.disconnect();
|
|
81
|
+
const parent = el.value?.parentElement;
|
|
82
|
+
if (!parent || typeof ResizeObserver === 'undefined') return;
|
|
83
|
+
ro = new ResizeObserver(() => {
|
|
84
|
+
narrow.value = parent.clientWidth > 0 && parent.clientWidth < FULL_WIDTH_BELOW;
|
|
85
|
+
});
|
|
86
|
+
ro.observe(parent);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── The inset it reports ──────────────────────────────────────────────────────────────────────────
|
|
90
|
+
// An open overlay panel declares the strip it covers on its PARENT, where <FCanvasHud> reads it and
|
|
91
|
+
// insets its rails on that edge — so a rail card steps aside instead of hiding underneath, with no
|
|
92
|
+
// z-index fight and no bookkeeping at the usage site. Written to the parent because the HUD is a
|
|
93
|
+
// SIBLING: the panel has nothing to pass it, but they share an ancestor.
|
|
94
|
+
//
|
|
95
|
+
// The camera is not reachable this way — `map.setPadding` takes a number — so a map reads the same
|
|
96
|
+
// measure through `sidePanelInsetPx()` instead. One module, two consumers, no third copy.
|
|
97
|
+
const insetVar = computed(() => CANVAS_INSET_VAR[props.side]);
|
|
98
|
+
|
|
99
|
+
watchEffect((onCleanup) => {
|
|
100
|
+
const parent = el.value?.parentElement;
|
|
101
|
+
if (!parent || !overlay.value) return;
|
|
102
|
+
const prop = insetVar.value;
|
|
103
|
+
const covered = open.value && !narrow.value;
|
|
104
|
+
// ★ ONE gutter, not two: this is the strip the panel physically OCCUPIES — its own outer gutter plus
|
|
105
|
+
// its width. The HUD adds it to a safe inset and its areas then apply the chrome gutter as their own
|
|
106
|
+
// padding, so publishing a trailing gutter here would be counted twice and leave a 32px trough
|
|
107
|
+
// between the panel and the first rail item where every other pair of chrome shares 16 (measured).
|
|
108
|
+
// The CAMERA is the opposite case and keeps both gutters — see sidePanelInsetPx: a marker parked
|
|
109
|
+
// flush against the panel's edge reads as hidden behind it, so it wants clearance the rails do not.
|
|
110
|
+
parent.style.setProperty(prop, covered ? `calc(${width.value} + ${SIDE_PANEL_GUTTER_PX}px)` : '0px');
|
|
111
|
+
|
|
112
|
+
// ★ The inset alone makes followers JUMP: they get the new value in one frame while the panel takes its
|
|
113
|
+
// full travel to arrive, so a gesture that is one movement renders as three separate events. Publishing
|
|
114
|
+
// the duration too lets them run on the panel's clock — including its asymmetry, since a fast exit that
|
|
115
|
+
// the HUD glides out of over 240ms trails the panel visibly.
|
|
116
|
+
//
|
|
117
|
+
// Resolved to a time here rather than passed as `var(--motion-slow)`: the map reads this from JS and
|
|
118
|
+
// has no way to resolve a token, and one resolver is better than two that can disagree.
|
|
119
|
+
const token = open.value ? '--motion-slow' : '--motion-fast';
|
|
120
|
+
parent.style.setProperty(
|
|
121
|
+
CANVAS_INSET_DURATION_VAR,
|
|
122
|
+
getComputedStyle(parent).getPropertyValue(token).trim() || '0ms',
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
onCleanup(() => {
|
|
126
|
+
parent.style.removeProperty(prop);
|
|
127
|
+
parent.style.removeProperty(CANVAS_INSET_DURATION_VAR);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
onBeforeUnmount(() => ro?.disconnect());
|
|
132
|
+
|
|
133
|
+
// ── Placement, and the two motions it implies ─────────────────────────────────────────────────────
|
|
134
|
+
// The behaviours travel differently, so the motion doctrine sorts them into different buckets. Neither
|
|
135
|
+
// is a taste call — both are read off the tokens' own definitions (@humanforest/tokens/motion/motion.css).
|
|
136
|
+
//
|
|
137
|
+
// PUSH collapses by WIDTH, so the content beside it widens in sympathy — but the panel is ENTERING and
|
|
138
|
+
// LEAVING, not resizing. `ease-in-out` belongs to the persistent case the doctrine names (the tabs
|
|
139
|
+
// indicator, the nav resize, the app rail's collapse: an element visible in BOTH states, changing size).
|
|
140
|
+
// Read as travel-along-an-axis this looked like the same thing; it is not, and the difference is
|
|
141
|
+
// audible. ★ MEASURED with ease-in-out (cubic-bezier(0.77, 0, 0.175, 1), an easeInOutQuart): the column
|
|
142
|
+
// moved 50px in the first 92ms, then 158px in the next 41ms — a ~4px/ms burst through the middle, with
|
|
143
|
+
// the pushed text rewrapping all the way through it. That mid-travel snap is what reads as jank; the
|
|
144
|
+
// content was tracking perfectly the whole time. So: `ease-out`, monotonically decelerating, same as the
|
|
145
|
+
// overlay, with the same enter/exit asymmetry.
|
|
146
|
+
//
|
|
147
|
+
// OVERLAY enters and leaves the surface. Enters and exits take `ease-out` — `ease-in-out` would ease
|
|
148
|
+
// IN at the start of the exit, and ease-in is banned outright for starting slow at the moment the user
|
|
149
|
+
// is watching. And the two directions are NOT symmetric: `slow` for the enter (a slideover panel's
|
|
150
|
+
// travel), `fast` for the exit, because the doctrine buckets overlay EXITS there — a dismissal should
|
|
151
|
+
// get out of the way rather than perform its departure.
|
|
152
|
+
//
|
|
153
|
+
// The reduced-motion floor in the motion tokens covers the opt-out for both, globally.
|
|
154
|
+
const wrapperClass = computed(() => [
|
|
155
|
+
overlay.value
|
|
156
|
+
? [
|
|
157
|
+
'pointer-events-none absolute inset-y-0 z-20 flex p-(--forest-chrome-gutter)',
|
|
158
|
+
props.side === 'start' ? 'start-0' : 'end-0',
|
|
159
|
+
'transition-[translate,opacity] ease-(--motion-ease-out)',
|
|
160
|
+
open.value
|
|
161
|
+
? 'translate-x-0 opacity-100 duration-(--motion-slow)'
|
|
162
|
+
: [
|
|
163
|
+
'opacity-0 duration-(--motion-fast)',
|
|
164
|
+
props.side === 'start' ? '-translate-x-full rtl:translate-x-full' : 'translate-x-full rtl:-translate-x-full',
|
|
165
|
+
],
|
|
166
|
+
]
|
|
167
|
+
: [
|
|
168
|
+
'h-full shrink-0 overflow-hidden',
|
|
169
|
+
// ★ `side` has to mean the same thing in both behaviours. Overlay anchors itself, but a push
|
|
170
|
+
// panel is a FLOW item, so without this its position would come from where it happens to be
|
|
171
|
+
// authored and `side="end"` would only flip which edge carries the hairline — the prop would
|
|
172
|
+
// silently do nothing about the side. `order-last` puts it after its siblings whatever the
|
|
173
|
+
// markup order, and is inert when it is already last. (Its container is a flex row either
|
|
174
|
+
// way: a push panel has nothing to push otherwise.)
|
|
175
|
+
props.side === 'end' && 'order-last',
|
|
176
|
+
'transition-[width] ease-(--motion-ease-out)',
|
|
177
|
+
open.value ? 'duration-(--motion-slow)' : 'duration-(--motion-fast)',
|
|
178
|
+
],
|
|
179
|
+
narrow.value && 'w-full',
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
const wrapperStyle = computed(() => {
|
|
183
|
+
if (narrow.value) return undefined;
|
|
184
|
+
return overlay.value ? undefined : { width: open.value ? width.value : '0px' };
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// The column itself. Overlay takes the canvas-chrome surface (translucent + blurred, so the map stays
|
|
188
|
+
// legible as context through its own controls); push is a flush column with the hairline edge that
|
|
189
|
+
// separates it from the content beside it.
|
|
190
|
+
//
|
|
191
|
+
// ★ THE RING AND THE RADIUS BELONG TO `inner`, AND ONLY THERE. The floating variant already rings the
|
|
192
|
+
// inner and gives it the radius, so adding either to the root draws a SECOND rounded rectangle around
|
|
193
|
+
// the first — visibly, because the same variant's `collapsible="none"` compound also puts `p-4` on the
|
|
194
|
+
// root, holding the two frames 16px apart.
|
|
195
|
+
//
|
|
196
|
+
// ★ `p-0` cancels that compound's padding, and it is a measurement fix rather than a cosmetic one. The
|
|
197
|
+
// root is `w-(--sidebar-width)` and border-box, so its own padding comes OUT of the panel: at `lg` the
|
|
198
|
+
// root measured 352px while the visible column was 320 (measured), i.e. the size rung quietly delivered
|
|
199
|
+
// 20rem where it promised 22, and the strip the camera was told to keep clear no longer matched what was
|
|
200
|
+
// covered. The gutter an overlay floats in is the wrapper's, once — the same --forest-chrome-gutter
|
|
201
|
+
// every HUD card is inset by, which is what puts a panel and a rail card on one line.
|
|
202
|
+
const sidebarUi = computed(() => ({
|
|
203
|
+
root: ['h-full', narrow.value ? 'w-full' : '', overlay.value ? 'pointer-events-auto p-0' : '']
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
.join(' '),
|
|
206
|
+
// ★ NO DIVIDERS on the canvas cut. Stock's skeleton rules its header, body and footer apart, which is
|
|
207
|
+
// right on a page and wrong over a map: the panel is a pane of glass on a moving, already-busy surface,
|
|
208
|
+
// and every line inside it competes with the map's own. The push cut keeps them — there the panel sits
|
|
209
|
+
// against a static page, where a rule reads as structure rather than as a seam in the glass.
|
|
210
|
+
// ★ The header loses its height FLOOR. Stock's `min-h-(--ui-header-height)` exists so the app rail's
|
|
211
|
+
// header lines up with the navbar beside it — a shell-level alignment. This panel is container-scoped:
|
|
212
|
+
// there is no navbar on its row, so the floor only made its title sit in a 48px box while a card two
|
|
213
|
+
// inches away sat in a 38px one, on the same canvas. `p-4 pb-0` is the card's own header rhythm, so a
|
|
214
|
+
// panel title and a card title share a baseline instead of nearly sharing one.
|
|
215
|
+
header: 'min-h-0 p-4 pb-0',
|
|
216
|
+
inner: overlay.value ? 'forest-canvas-chrome divide-y-0' : '',
|
|
217
|
+
}));
|
|
218
|
+
</script>
|
|
219
|
+
|
|
220
|
+
<template>
|
|
221
|
+
<div ref="el" :class="wrapperClass" :style="wrapperStyle">
|
|
222
|
+
<USidebar
|
|
223
|
+
collapsible="none"
|
|
224
|
+
:variant="overlay ? 'floating' : 'sidebar'"
|
|
225
|
+
:side="side === 'start' ? 'left' : 'right'"
|
|
226
|
+
:title="title"
|
|
227
|
+
:description="description"
|
|
228
|
+
:style="{ '--sidebar-width': narrow ? '100%' : width }"
|
|
229
|
+
:ui="sidebarUi"
|
|
230
|
+
>
|
|
231
|
+
<template v-if="$slots.header" #header="slotProps">
|
|
232
|
+
<slot name="header" v-bind="slotProps" />
|
|
233
|
+
</template>
|
|
234
|
+
|
|
235
|
+
<template v-if="showClose" #actions>
|
|
236
|
+
<slot name="actions" />
|
|
237
|
+
<UButton
|
|
238
|
+
icon="i-lucide-x"
|
|
239
|
+
color="neutral"
|
|
240
|
+
variant="ghost"
|
|
241
|
+
size="xs"
|
|
242
|
+
square
|
|
243
|
+
aria-label="Close panel"
|
|
244
|
+
@click="open = false"
|
|
245
|
+
/>
|
|
246
|
+
</template>
|
|
247
|
+
<template v-else-if="$slots.actions" #actions>
|
|
248
|
+
<slot name="actions" />
|
|
249
|
+
</template>
|
|
250
|
+
|
|
251
|
+
<slot />
|
|
252
|
+
|
|
253
|
+
<template v-if="$slots.footer" #footer>
|
|
254
|
+
<slot name="footer" />
|
|
255
|
+
</template>
|
|
256
|
+
</USidebar>
|
|
257
|
+
</div>
|
|
258
|
+
</template>
|