@conduction/nextcloud-vue 2.100.0 → 2.102.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/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +22 -0
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +8 -6
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +29 -4
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js +9 -1
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue.js +1 -1
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js +22 -1
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +26 -16
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +70 -6
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +105 -74
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue.js +9 -0
- package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue.js.map +1 -0
- package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue2.js +139 -0
- package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue3.js +76 -0
- package/dist/esm/components/CnExportsWidget/CnExportsWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +160 -2
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +206 -94
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/filesReview.js +61 -0
- package/dist/esm/components/CnFilesBrowser/filesReview.js.map +1 -0
- package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue.js +9 -0
- package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue.js.map +1 -0
- package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue2.js +279 -0
- package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue3.js +122 -0
- package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnGroupedCardsWidget/index.js +29 -0
- package/dist/esm/components/CnGroupedCardsWidget/index.js.map +1 -0
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +320 -74
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +323 -213
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/useNamedSource.js +30 -3
- package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
- package/dist/esm/components/CnMapWidget/CnMapWidget.vue.js +1 -1
- package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js +15 -0
- package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnMapWidget/CnMapWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue.js +1 -1
- package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue2.js +25 -1
- package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +14 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +14 -6
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +10 -0
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +6 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +1 -1
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +91 -4
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -1
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +63 -38
- package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -1
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue.js +1 -1
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js +106 -4
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js.map +1 -1
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js +70 -25
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js.map +1 -1
- package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue.js +9 -0
- package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue.js.map +1 -0
- package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue2.js +175 -0
- package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue3.js +77 -0
- package/dist/esm/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +35 -2
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/builtInWidgets.js +4 -0
- package/dist/esm/components/CnWidgetGrid/builtInWidgets.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
- package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue.js +1 -1
- package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +2 -23
- package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
- package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
- package/dist/esm/composables/indexSources.js +176 -20
- package/dist/esm/composables/indexSources.js.map +1 -1
- package/dist/esm/composables/useEndpointSource.js +28 -6
- package/dist/esm/composables/useEndpointSource.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +41 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +39 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/library-style.js +1 -1
- package/dist/esm/nextcloud-vue.css +939 -376
- package/dist/esm/utils/builtInFormatters.js +24 -4
- package/dist/esm/utils/builtInFormatters.js.map +1 -1
- package/dist/esm/utils/languageMapText.js +50 -0
- package/dist/esm/utils/languageMapText.js.map +1 -0
- package/dist/esm/utils/libraryWidgetKeys.js +3 -0
- package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
- package/dist/esm/utils/mapList.js +71 -0
- package/dist/esm/utils/mapList.js.map +1 -0
- package/dist/esm/utils/rowClassRules.js +43 -0
- package/dist/esm/utils/rowClassRules.js.map +1 -0
- package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/esm/utils/visibleWhen.js +19 -2
- package/dist/esm/utils/visibleWhen.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +9681 -7136
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +939 -376
- package/l10n/en.json +41 -1
- package/l10n/nl.json +39 -1
- package/package.json +1 -1
- package/src/components/CnActionsBar/CnActionsBar.md +1 -0
- package/src/components/CnActionsBar/CnActionsBar.vue +31 -4
- package/src/components/CnChartWidget/CnChartWidget.md +3 -2
- package/src/components/CnChartWidget/CnChartWidget.vue +42 -4
- package/src/components/CnChoiceCards/CnChoiceCards.md +39 -0
- package/src/components/CnChoiceCards/CnChoiceCards.vue +93 -16
- package/src/components/CnDashboardPage/CnDashboardPage.vue +110 -21
- package/src/components/CnExportsWidget/CnExportsWidget.vue +233 -0
- package/src/components/CnExportsWidget/index.js +7 -0
- package/src/components/CnFilesBrowser/CnFilesBrowser.vue +324 -0
- package/src/components/CnFilesBrowser/filesReview.js +57 -0
- package/src/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue +433 -0
- package/src/components/CnGroupedCardsWidget/index.js +28 -0
- package/src/components/CnIndexPage/CnIndexPage.md +5 -1
- package/src/components/CnIndexPage/CnIndexPage.vue +313 -9
- package/src/components/CnIndexPage/useNamedSource.js +30 -3
- package/src/components/CnMapWidget/CnMapWidget.vue +37 -0
- package/src/components/CnMenuWidget/CnMenuWidget.vue +25 -1
- package/src/components/CnObjectListWidget/CnObjectListWidget.vue +29 -1
- package/src/components/CnObjectSidebar/CnFilesTab.vue +15 -0
- package/src/components/CnOrderedListField/CnOrderedListField.vue +151 -5
- package/src/components/CnQuickFilterBar/CnQuickFilterBar.md +1 -0
- package/src/components/CnQuickFilterBar/CnQuickFilterBar.vue +159 -5
- package/src/components/CnSavedViewAlertWidget/CnSavedViewAlertWidget.vue +272 -0
- package/src/components/CnSavedViewAlertWidget/index.js +7 -0
- package/src/components/CnSetupWizard/CnSetupWizard.vue +35 -2
- package/src/components/CnWidgetGrid/builtInWidgets.js +4 -0
- package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +2 -23
- package/src/components/index.js +3 -0
- package/src/composables/indexSources.js +175 -19
- package/src/composables/useEndpointSource.js +28 -6
- package/src/css/index-page.css +117 -0
- package/src/css/look-board-index.css +10 -0
- package/src/css/look-board.css +44 -0
- package/src/index.js +1 -0
- package/src/schemas/app-manifest-v2.schema.json +85 -3
- package/src/utils/builtInFormatters.js +24 -4
- package/src/utils/languageMapText.js +46 -0
- package/src/utils/libraryWidgetKeys.js +3 -0
- package/src/utils/mapList.js +67 -0
- package/src/utils/rowClassRules.js +39 -0
- package/src/utils/validateManifestV2.compiled.js +3 -3
- package/src/utils/visibleWhen.js +19 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CnMapWidget.vue3.js","sources":["../../../../src/components/CnMapWidget/CnMapWidget.vue?vue&type=template&id=62ad3809&lang.js"],"sourcesContent":["<!--\n CnMapWidget — Renders a Leaflet map with declarative layers + markers.\n\n A library-first wrapper around Leaflet that consumes the manifest's\n page-shape directly: `{ center, zoom, layers, markers, clustering,\n height, autoFit }`. Layer dispatch picks `L.tileLayer |\n L.tileLayer.wms | L.geoJSON` per `layer.type`; unknown types log a\n console.warn and are skipped (matches CnChartWidget's\n unknown-`chartKind` posture).\n\n Marker support is opt-in:\n - `markers.features[]` — inline GeoJSON FeatureCollection.\n - `markers.dataSource.url` — fetched on mount; the response may\n be either a GeoJSON FeatureCollection OR a flat array of rows\n (in which case `latField`, `lngField`, `popupField` drive the\n conversion).\n - `markers.dataSource.{register, schema}` — plots the objects of an\n OpenRegister register/schema, reading each one's `@self.geo`\n (falling back to `latField` / `lngField` on the object itself).\n Objects with no usable location are skipped, not plotted at (0, 0).\n\n `leaflet.markercluster`'s JS lazy-loads only when `clustering: true`\n (or `markers.clustering: true`), so a consumer without clustering does\n not pay the cluster bundle cost. Its two stylesheets are imported\n eagerly with Leaflet's own, because a cluster with no CSS paints as a\n bare number in the corner of the map.\n\n This component is the page's primitive — `CnMapPage` wraps it for\n manifest-driven `type: \"map\"` routes. Apps that want to embed a map\n inside a custom dashboard slot can `<CnMapWidget v-bind=\"props\" />`\n directly.\n\n Spec: REQ-MMW-* (manifest-map-widget).\n-->\n<template>\n\t<div\n\t\tclass=\"cn-map-widget\"\n\t\t:class=\"{ 'cn-map-widget--fullscreen': isFullscreen }\"\n\t\t:style=\"{ height: isFullscreen ? null : resolvedHeight }\">\n\t\t<div\n\t\t\tv-if=\"leafletAvailable\"\n\t\t\tref=\"mapEl\"\n\t\t\tclass=\"cn-map-widget__leaflet\"\n\t\t\trole=\"application\"\n\t\t\t:aria-label=\"ariaLabel\"\n\t\t\ttabindex=\"0\" />\n\t\t<div v-else class=\"cn-map-widget__fallback\">\n\t\t\t<!--\n\t\t\t\t@slot fallback\n\t\t\t\t@description Shown when Leaflet is not available (e.g. test environments where\n\t\t\t\tthe global `L` cannot be loaded).\n\t\t\t-->\n\t\t\t<slot name=\"fallback\">\n\t\t\t\t<p class=\"cn-map-widget__error\">\n\t\t\t\t\t{{ unavailableLabel }}\n\t\t\t\t</p>\n\t\t\t</slot>\n\t\t</div>\n\t\t<p v-if=\"unplottedCount > 0\" class=\"cn-map-widget__note\" data-testid=\"cn-map-unplotted\">\n\t\t\t{{ unplottedLabel }}\n\t\t</p>\n\t\t<div v-if=\"$slots.legend || $slots.legend\" class=\"cn-map-widget__legend\">\n\t\t\t<!--\n\t\t\t\t@slot legend\n\t\t\t\t@description Custom legend overlay. Receives `{ layers, markers }` as scoped props.\n\t\t\t\tPositioned absolute (top-right) by default; consumers MAY override with their own\n\t\t\t\tcontainer.\n\t\t\t-->\n\t\t\t<slot name=\"legend\" :layers=\"cfg.layers\" :markers=\"cfg.markers\" />\n\t\t</div>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport DOMPurify from 'dompurify'\nimport { objectToGeoFeature } from '../../utils/geo.js'\nimport { prefixUrl } from '../../utils/headers.js'\nimport { objectDisplayName } from '../../utils/objectName.js'\nimport { safeHref } from '../../utils/safeHref.js'\nimport { SAFE_MARKDOWN_DOMPURIFY_CONFIG } from '../../utils/safeMarkdownDompurifyConfig.js'\nimport { fromImagePoint, hasImagePosition, imageBounds, paddedImageBounds, toImagePoint } from './imageCoordinates.js'\n\n// Leaflet's own stylesheet positions the map panes, tiles the tile\n// images, and places the zoom/attribution controls. The JS is lazy-loaded\n// in mounted(), but the CSS must be present whenever this widget renders —\n// otherwise the map paints unstyled (no basemap, mispositioned controls).\n// Import it here so the widget is self-styling for every consumer rather\n// than relying on another component (e.g. a location picker) to pull it in.\nimport 'leaflet/dist/leaflet.css'\n// The cluster plugin's JS stays lazy, but its CSS cannot be: a cluster is a\n// `<div class=\"marker-cluster\">` with a count in it, and with no stylesheet it\n// paints as a bare number in the top-left corner of the map instead of a\n// bubble on the cluster's position. Two small files, ~2 KB together, and\n// `leaflet.markercluster` is a declared dependency either way.\nimport 'leaflet.markercluster/dist/MarkerCluster.css'\nimport 'leaflet.markercluster/dist/MarkerCluster.Default.css'\n\nconst ALLOWED_LAYER_TYPES = ['tile', 'wms', 'wfs', 'geojson', 'image']\n\n// Raw input on the map container, which is what tells a user's pan or zoom from\n// ours. Leaflet's own `movestart` / `zoomstart` fire for `fitBounds` too, so\n// they cannot separate the two; these events Leaflet never synthesises. A\n// gesture only ARMS the flag — `moveend` / `zoomend` commits it — because half\n// of these change nothing on their own: a page scroll over a map with\n// `scrollWheelZoom` off is a wheel event, and tabbing through is a keydown.\nconst VIEW_GESTURE_EVENTS = ['mousedown', 'touchstart', 'wheel', 'keydown']\n\n// Nextcloud sends `Referrer-Policy: no-referrer` on every page, and OpenStreetMap's\n// tile CDN answers a refererless request with a \"not following the tile usage policy\"\n// tile instead of the map. An `<img>`-level policy overrides the document's, and\n// `origin` sends only the scheme+host, never the path a case id could sit in.\nconst TILE_REFERRER_POLICY = 'strict-origin-when-cross-origin'\n\n/**\n * Apply the tile defaults a consumer did not set.\n *\n * @param {object} opts Leaflet layer options, already shallow-copied.\n * @return {object} The same object, with `referrerPolicy` defaulted.\n */\nfunction withTileDefaults(opts) {\n\tif (opts.referrerPolicy === undefined) {\n\t\topts.referrerPolicy = TILE_REFERRER_POLICY\n\t}\n\treturn opts\n}\n\n/**\n * A comparable snapshot of one config value, for the render watchers.\n *\n * @param {*} value The config value.\n * @return {string|object} A stable string; a fresh object when it will not\n * serialise, which always compares unequal and so keeps rendering.\n */\nfunction configKey(value) {\n\ttry {\n\t\treturn JSON.stringify(value ?? null)\n\t} catch {\n\t\treturn {}\n\t}\n}\n\n// Fallback background used when the consumer configures no `basemaps` and no\n// `tile`/`wms` entry in `layers` — otherwise the map paints white. Consuming\n// apps MUST allow this host in their Content-Security-Policy `img-src`\n// (Nextcloud blocks external images by default) or the tiles are CSP-blocked\n// and the map stays blank.\nconst DEFAULT_BASEMAP = {\n\turl: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\n\toptions: {\n\t\tattribution: '© OpenStreetMap contributors',\n\t\tmaxZoom: 19,\n\t},\n}\n\n// MDI paths for the custom control buttons, inlined so the widget carries no\n// icon-font dependency and renders identically inside a Leaflet control bar.\nconst ICON_FIT = 'M9.5,13.09L10.91,14.5L6.41,19H10V21H3V14H5V17.59L9.5,13.09M10.91,9.5L9.5,10.91L5,6.41V10H3V3H10V5H6.41L10.91,9.5M14.5,13.09L19,17.59V14H21V21H14V19H17.59L13.09,14.5L14.5,13.09M13.09,9.5L17.59,5H14V3H21V10H19V6.41L14.5,10.91L13.09,9.5Z'\nconst ICON_LOCATE = 'M12,8A4,4 0 0,0 8,12A4,4 0 0,0 12,16A4,4 0 0,0 16,12A4,4 0 0,0 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z'\nconst ICON_FULLSCREEN = 'M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z'\nconst ICON_FULLSCREEN_EXIT = 'M14,14H19V16H16V19H14V14M5,14H10V19H8V16H5V14M8,5H10V10H5V8H8V5M19,8V10H14V5H16V8H19Z'\n\n/**\n * Wrap an MDI path in a 24x24 SVG sized for a Leaflet control button.\n *\n * @param {string} path The MDI `d` attribute.\n * @return {string} SVG markup.\n */\nfunction controlIcon(path) {\n\treturn `<svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" d=\"${path}\" /></svg>`\n}\n\n/**\n * CnMapWidget — Leaflet wrapper for declarative manifest-driven maps.\n *\n * Manifest usage — when CnPageRenderer dispatches a `type: \"map\"` page\n * it mounts CnMapPage, which forwards `pages[].config.{center, zoom,\n * layers, markers, height, clustering}` to this component. Manifest\n * authors do NOT mount this component themselves — declare a\n * `type: \"map\"` page instead. See CnMapPage's leading docblock for\n * the manifest example.\n *\n * Direct (non-manifest) usage:\n *\n * ```vue\n * <CnMapWidget\n * :center=\"[52.13, 5.29]\"\n * :zoom=\"7\"\n * :layers=\"[\n * { type: 'tile', url: 'https://service.pdok.nl/.../EPSG:3857/{z}/{x}/{y}.png',\n * options: { attribution: '© Kadaster', maxZoom: 19 } },\n * { type: 'wms', url: 'https://service.pdok.nl/lv/bag/wms/v2_0',\n * options: { layers: 'pand', transparent: true, opacity: 0.6 } },\n * ]\"\n * :markers=\"{\n * dataSource: { url: '/api/cases/geo' },\n * latField: 'lat',\n * lngField: 'lng',\n * popupField: 'title',\n * }\"\n * clustering\n * height=\"500px\"\n * @marker-click=\"onMarkerClick\" />\n * ```\n *\n * @event map-ready Fired once after Leaflet has loaded and the map is mounted. Payload: `{ map }` — the underlying Leaflet `L.Map` instance.\n * @event marker-click Fired when a marker is clicked. Payload: `{ feature, latlng, originalEvent }`.\n * @event bounds-change Fired after the map's visible bounds change (pan / zoom settle). Payload: `{ bounds, zoom }` where `bounds = { north, south, east, west }`.\n * @event click Fired when the user clicks the map background (no marker hit). Payload: `{ latlng, originalEvent }`.\n *\n * @slot legend Custom legend overlay positioned over the map. Scoped props: `{ layers, markers }`.\n * @slot popup Per-marker popup body override. Scoped props: `{ feature, properties }`. When omitted, the widget renders the value of `markers.popupField` (or no popup at all when unset).\n * @slot fallback Replaces the default \"map unavailable\" message when Leaflet fails to load (test environments, CSP-blocked builds, offline dev). No scoped props.\n */\nexport default {\n\tname: 'CnMapWidget',\n\n\tprops: {\n\t\t/**\n\t\t * Initial map center as `[latitude, longitude]`. Optional: on a dashboard\n\t\t * the centre arrives via `content.center` (see the `cfg` computed), and when\n\t\t * neither source supplies a valid pair it defaults to `[52.13, 5.29]` (the\n\t\t * Netherlands).\n\t\t *\n\t\t * @type {[number, number]}\n\t\t */\n\t\tcenter: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [52.13, 5.29],\n\t\t\tvalidator: (v) => Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number' && Number.isFinite(n)),\n\t\t},\n\n\t\t/**\n\t\t * Initial zoom level.\n\t\t *\n\t\t * @type {number}\n\t\t */\n\t\tzoom: {\n\t\t\ttype: Number,\n\t\t\tdefault: 7,\n\t\t},\n\n\t\t/**\n\t\t * Layer definitions. Each entry: `{ type: 'tile'|'wms'|'wfs'|'geojson'|'image', url, options }`.\n\t\t * `geojson` MAY supply inline `data` (FeatureCollection) instead of `url`.\n\t\t * `image` is `{ type: 'image', url, width, height }`: the map becomes a picture in flat\n\t\t * pixel coordinates ((0, 0) top left); other layers beside it are skipped.\n\t\t * Unknown types log a console.warn and are skipped.\n\t\t *\n\t\t * @type {Array<object>}\n\t\t */\n\t\tlayers: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [],\n\t\t},\n\n\t\t/**\n\t\t * Marker config. `{ features?, dataSource?, latField?, lngField?, xField?, yField?,\n\t\t * popupField?, clustering?, iconColor?, iconUrl?, centerMarker? }`. On an image map\n\t\t * (a layer of type `image`) markers read `xField` / `yField` (default `x` / `y`,\n\t\t * pixels from the picture's top left) instead of `latField` / `lngField`. `features[]` is inline;\n\t\t * `dataSource.url` is HTTP-fetched on mount; `dataSource.{register, schema}`\n\t\t * plots the objects of an OpenRegister register/schema via their `@self.geo`.\n\t\t * `centerMarker: true` adds an extra pin at the map's `center`, alongside any\n\t\t * source markers.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tmarkers: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Enable marker clustering. When true, lazy-loads `leaflet.markercluster`\n\t\t * on first mount. `markers.clustering` overrides this prop when set.\n\t\t *\n\t\t * @type {boolean}\n\t\t */\n\t\tclustering: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Container height. Forwarded to the wrapper div's `style.height`.\n\t\t *\n\t\t * @type {string|number}\n\t\t */\n\t\theight: {\n\t\t\ttype: [String, Number],\n\t\t\tdefault: '500px',\n\t\t},\n\n\t\t/**\n\t\t * Frame the map on every loaded feature. Applies on load and on each\n\t\t * marker update until the user pans or zooms themselves, after which\n\t\t * their view is kept and only the `Fit all markers` control re-frames.\n\t\t *\n\t\t * @type {boolean}\n\t\t */\n\t\tautoFit: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Show the \"fit all markers\" control — re-centres and re-zooms the map so\n\t\t * every marker is back in view (the position the map opens at).\n\t\t *\n\t\t * @type {boolean}\n\t\t */\n\t\tfitControl: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Show the fullscreen toggle. Expands the widget to fill the viewport via a\n\t\t * CSS overlay, so it needs no Fullscreen-API permission prompt.\n\t\t *\n\t\t * @type {boolean}\n\t\t */\n\t\tfullscreenControl: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Show the \"locate me\" control, which centres the map on the visitor's own\n\t\t * position via the browser geolocation API. Warns (does not throw) if denied.\n\t\t *\n\t\t * @type {boolean}\n\t\t */\n\t\tlocateControl: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Switchable base maps: `[{ name, url, attribution, options }]`. The first\n\t\t * entry is active on load and a layer switcher appears when more than one is\n\t\t * given. Supply these INSTEAD of a `tile` entry in `layers` for the background\n\t\t * map. Empty by default, so consumers that declare their background through\n\t\t * `layers` are unaffected.\n\t\t *\n\t\t * @type {Array<object>}\n\t\t */\n\t\tbasemaps: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [],\n\t\t},\n\n\t\t/**\n\t\t * Aria-label for the map application region.\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tariaLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'Map'),\n\t\t},\n\n\t\t/**\n\t\t * Label shown when Leaflet is not available.\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tunavailableLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'Map library not available'),\n\t\t},\n\n\t\t/**\n\t\t * Dashboard `content` blob. When this widget is placed on a dashboard, the\n\t\t * grid stores its config here and each key (`center`, `zoom`, `markers`,\n\t\t * `clustering`, `height`, `autoFit`, `layers`, `basemaps`) overrides the\n\t\t * matching flat prop. Direct (non-dashboard) consumers omit this and pass\n\t\t * the flat props instead. See the `cfg` computed for the merge rules.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tcontent: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Dashboard placement record. Declared so the grid's `:placement` binding is\n\t\t * consumed as a prop instead of leaking onto the root element; unused here.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tplacement: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\t/**\n\t * Events:\n\t *\n\t * @event map-ready\n\t * @description Fired once after Leaflet has loaded and the map is mounted. Payload: `{ map }` — the underlying Leaflet `L.Map` instance. Consumers MAY use the instance to register custom controls or layers beyond the manifest shape.\n\t *\n\t * @event marker-click\n\t * @description Fired when a marker is clicked. Payload: `{ feature, latlng, originalEvent }`.\n\t *\n\t * @event bounds-change\n\t * @description Fired after the map's visible bounds change (pan / zoom settle). Payload: `{ bounds, zoom }` where `bounds = { north, south, east, west }`. Useful for triggering a re-fetch of viewport-scoped data.\n\t *\n\t * @event click\n\t * @description Fired when the user clicks the map background (no marker hit). Payload: `{ latlng, originalEvent }`.\n\t */\n\temits: ['map-ready', 'marker-click', 'bounds-change', 'click'],\n\n\tdata() {\n\t\treturn {\n\t\t\tL: null,\n\t\t\tmap: null,\n\t\t\tlayerInstances: [],\n\t\t\tmarkerLayer: null,\n\t\t\tclusterGroup: null,\n\t\t\tleafletAvailable: true,\n\t\t\tboundsTimer: null,\n\t\t\t// Rows of an image map with no usable x and y, counted in a note.\n\t\t\tunplottedCount: 0,\n\t\t\t// Set once the user has framed the view themselves; stops autoFit.\n\t\t\tuserFramedView: false,\n\t\t\t// A raw gesture has been seen; the next real view change is theirs.\n\t\t\tpendingViewGesture: false,\n\t\t\t// Controls / sizing\n\t\t\tisFullscreen: false,\n\t\t\tcontrolBar: null,\n\t\t\tlayersControl: null,\n\t\t\tfullscreenButton: null,\n\t\t\tresizeObserver: null,\n\t\t\tresizeTimer: null,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * Effective configuration. On a dashboard the placement `content` blob\n\t\t * carries the widget's config, so each key falls back to `content.<key>`\n\t\t * when present and otherwise uses the matching flat prop. This lets\n\t\t * CnMapWidget work both directly (flat props) and as a dashboard renderer\n\t\t * (a grid that binds only `:content` never spreads the keys as props).\n\t\t *\n\t\t * @return {object} the resolved `{ center, zoom, layers, markers,\n\t\t * clustering, height, autoFit, basemaps }`.\n\t\t */\n\t\tcfg() {\n\t\t\tconst c = this.content || {}\n\t\t\tconst p = this.$props\n\t\t\tconst validCentre = Array.isArray(c.center) && c.center.length === 2\n\t\t\t\t&& c.center.every((n) => Number.isFinite(n))\n\t\t\treturn {\n\t\t\t\tcenter: validCentre ? c.center : p.center,\n\t\t\t\tzoom: Number.isFinite(c.zoom) ? c.zoom : p.zoom,\n\t\t\t\tlayers: Array.isArray(c.layers) ? c.layers : p.layers,\n\t\t\t\tmarkers: (c.markers !== undefined && c.markers !== null) ? c.markers : p.markers,\n\t\t\t\tclustering: typeof c.clustering === 'boolean' ? c.clustering : p.clustering,\n\t\t\t\theight: (c.height !== undefined && c.height !== null && c.height !== '') ? c.height : p.height,\n\t\t\t\tautoFit: typeof c.autoFit === 'boolean' ? c.autoFit : p.autoFit,\n\t\t\t\tbasemaps: Array.isArray(c.basemaps) ? c.basemaps : p.basemaps,\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * The image layer, when the map is a picture: the first `image` layer with\n\t\t * a safe URL and a positive width and height. Null for a geographic map.\n\t\t *\n\t\t * @return {{url: string, width: number, height: number}|null}\n\t\t */\n\t\timageLayer() {\n\t\t\tconst def = (this.cfg.layers || []).find((l) => l && l.type === 'image')\n\t\t\tif (!def || safeHref(def.url) === '#' || !(def.width > 0) || !(def.height > 0)) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn { url: safeHref(def.url), width: Number(def.width), height: Number(def.height) }\n\t\t},\n\n\t\tunplottedLabel() {\n\t\t\treturn t('nextcloud-vue', '{count} places have no position', { count: this.unplottedCount })\n\t\t},\n\n\t\tresolvedHeight() {\n\t\t\tif (typeof this.cfg.height === 'number') {\n\t\t\t\treturn `${this.cfg.height}px`\n\t\t\t}\n\t\t\treturn this.cfg.height\n\t\t},\n\n\t\tclusteringEnabled() {\n\t\t\tif (this.cfg.markers && typeof this.cfg.markers.clustering === 'boolean') {\n\t\t\t\treturn this.cfg.markers.clustering\n\t\t\t}\n\t\t\treturn this.cfg.clustering\n\t\t},\n\n\t\t/**\n\t\t * Comparable snapshot of the layer config.\n\t\t *\n\t\t * The render watchers read `cfg`, which reads every prop, and a `deep`\n\t\t * watcher fires whenever its effect re-runs rather than when its value\n\t\t * changes — so a new marker set rebuilt the base tile layer and the map\n\t\t * went white until the tiles came back. A key the watcher can actually\n\t\t * compare renders only on a real change.\n\t\t */\n\t\tlayersKey() {\n\t\t\treturn configKey(this.cfg.layers)\n\t\t},\n\n\t\tmarkersKey() {\n\t\t\treturn configKey(this.cfg.markers)\n\t\t},\n\t},\n\n\twatch: {\n\t\t// Keyed, never `deep: true` — see `layersKey`.\n\t\tlayersKey() {\n\t\t\tif (this.map) {\n\t\t\t\tthis.renderLayers()\n\t\t\t}\n\t\t},\n\n\t\tmarkersKey() {\n\t\t\tif (this.map) {\n\t\t\t\tthis.renderMarkers()\n\t\t\t}\n\t\t},\n\n\t\t// Re-plot when the centre moves, but only while the centre pin is on —\n\t\t// otherwise the centre is an initial-view concern, not a marker one.\n\t\t'cfg.center': {\n\t\t\thandler() {\n\t\t\t\tif (this.map && this.cfg.markers && this.cfg.markers.centerMarker) {\n\t\t\t\t\tthis.renderMarkers()\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t},\n\n\tasync mounted() {\n\t\ttry {\n\t\t\t// Leaflet is loaded as a runtime dep. Default-export the namespace.\n\t\t\tconst mod = await import('leaflet')\n\t\t\tthis.L = mod.default || mod\n\t\t} catch (err) {\n\t\t\t// Fallback when Leaflet can't load (test envs, CSP-blocked CDNs).\n\t\t\t// Surface the fallback slot rather than blanking the page.\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.warn('[CnMapWidget] Leaflet unavailable', err)\n\t\t\tthis.leafletAvailable = false\n\t\t\treturn\n\t\t}\n\t\tthis.initMap()\n\t},\n\n\tbeforeUnmount() {\n\t\tclearTimeout(this.boundsTimer)\n\t\tclearTimeout(this.resizeTimer)\n\t\tif (this.$refs.mapEl) {\n\t\t\tfor (const type of VIEW_GESTURE_EVENTS) {\n\t\t\t\tthis.$refs.mapEl.removeEventListener(type, this.onViewGesture, { capture: true })\n\t\t\t}\n\t\t}\n\t\tif (this.resizeObserver) {\n\t\t\tthis.resizeObserver.disconnect()\n\t\t\tthis.resizeObserver = null\n\t\t}\n\t\tif (this.map) {\n\t\t\tthis.map.remove()\n\t\t\tthis.map = null\n\t\t}\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Mount the Leaflet map instance, attach event handlers, and\n\t\t * render the initial layer + marker set.\n\t\t */\n\t\tinitMap() {\n\t\t\tconst L = this.L\n\t\t\tconst image = this.imageLayer\n\t\t\t// A picture is not the earth: flat pixel coordinates, (0, 0) at the\n\t\t\t// top left, and a view that cannot be dragged far off the picture.\n\t\t\tconst mapOptions = image\n\t\t\t\t? {\n\t\t\t\t\t\tcrs: L.CRS && L.CRS.Simple,\n\t\t\t\t\t\tcenter: [-image.height / 2, image.width / 2],\n\t\t\t\t\t\tzoom: 0,\n\t\t\t\t\t\tminZoom: -5,\n\t\t\t\t\t\tmaxZoom: 2,\n\t\t\t\t\t\tzoomSnap: 0.25,\n\t\t\t\t\t\tmaxBounds: paddedImageBounds(image.width, image.height),\n\t\t\t\t\t\tmaxBoundsViscosity: 1,\n\t\t\t\t\t\tzoomControl: true,\n\t\t\t\t\t\tattributionControl: true,\n\t\t\t\t\t}\n\t\t\t\t: {\n\t\t\t\t\t\tcenter: this.cfg.center,\n\t\t\t\t\t\tzoom: this.cfg.zoom,\n\t\t\t\t\t\tzoomControl: true,\n\t\t\t\t\t\tattributionControl: true,\n\t\t\t\t\t}\n\t\t\tthis.map = L.map(this.$refs.mapEl, mapOptions)\n\n\t\t\t// Capture, because Leaflet stops propagation on its own handles.\n\t\t\tif (this.$refs.mapEl) {\n\t\t\t\tfor (const type of VIEW_GESTURE_EVENTS) {\n\t\t\t\t\tthis.$refs.mapEl.addEventListener(type, this.onViewGesture, { capture: true, passive: true })\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthis.map.on('moveend', this.onViewSettled)\n\t\t\tthis.map.on('zoomend', this.onViewSettled)\n\n\t\t\tthis.map.on('click', (e) => {\n\t\t\t\t/**\n\t\t\t\t * Map background click event. Fired when the user clicks the map outside any marker.\n\t\t\t\t *\n\t\t\t\t * @event click\n\t\t\t\t * @type {{lat: number, lng: number}|{x: number, y: number}} `{ lat, lng }` on a geographic map, `{ x, y }` in the picture's whole pixels on an image map.\n\t\t\t\t */\n\t\t\t\tthis.$emit('click', this.imageLayer ? fromImagePoint(e.latlng, this.imageLayer) : { lat: e.latlng.lat, lng: e.latlng.lng })\n\t\t\t})\n\n\t\t\tthis.map.on('moveend', () => {\n\t\t\t\tclearTimeout(this.boundsTimer)\n\t\t\t\tthis.boundsTimer = setTimeout(() => {\n\t\t\t\t\tif (!this.map) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconst b = this.map.getBounds()\n\t\t\t\t\t/**\n\t\t\t\t\t * Viewport bounds change event. Fired (debounced) after pan / zoom settles.\n\t\t\t\t\t *\n\t\t\t\t\t * @event bounds-change\n\t\t\t\t\t * @type {{north: number, south: number, east: number, west: number, zoom: number}}\n\t\t\t\t\t */\n\t\t\t\t\tthis.$emit('bounds-change', {\n\t\t\t\t\t\tnorth: b.getNorth(),\n\t\t\t\t\t\tsouth: b.getSouth(),\n\t\t\t\t\t\teast: b.getEast(),\n\t\t\t\t\t\twest: b.getWest(),\n\t\t\t\t\t\tzoom: this.map.getZoom(),\n\t\t\t\t\t})\n\t\t\t\t}, 100)\n\t\t\t})\n\n\t\t\t// Geolocation is best-effort — a denied permission or an insecure origin\n\t\t\t// must not break the map, so just warn.\n\t\t\tthis.map.on('locationerror', (e) => {\n\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\tconsole.warn('[CnMapWidget] Geolocation unavailable', e && e.message)\n\t\t\t})\n\n\t\t\tthis.renderLayers()\n\t\t\tthis.renderMarkers()\n\t\t\tthis.addControls()\n\n\t\t\t// The container may grow (fill-height layouts) or start hidden behind a\n\t\t\t// view toggle. Re-flow whenever its box actually changes — otherwise\n\t\t\t// Leaflet keeps the stale size it measured at mount and the tiles and\n\t\t\t// markers land in the wrong place.\n\t\t\tif (typeof ResizeObserver !== 'undefined' && this.$refs.mapEl) {\n\t\t\t\tthis.resizeObserver = new ResizeObserver(() => {\n\t\t\t\t\tclearTimeout(this.resizeTimer)\n\t\t\t\t\tthis.resizeTimer = setTimeout(() => {\n\t\t\t\t\t\tthis.refreshMapSize()\n\t\t\t\t\t}, 100)\n\t\t\t\t})\n\t\t\t\tthis.resizeObserver.observe(this.$refs.mapEl)\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * Map ready event. Fired once after Leaflet has loaded and the map is mounted.\n\t\t\t *\n\t\t\t * @event map-ready\n\t\t\t * @type {{map: object}}\n\t\t\t */\n\t\t\tthis.$emit('map-ready', { map: this.map })\n\n\t\t\t// Re-flow size if the container was hidden when first mounted.\n\t\t\tthis.$nextTick(() => {\n\t\t\t\tthis.refreshMapSize()\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Mount the configured `layers[]` onto the Leaflet map. Each entry\n\t\t * dispatches by `type` to a Leaflet factory; unknown types warn and\n\t\t * skip. `geojson` may supply inline `data` to skip the fetch.\n\t\t */\n\t\trenderLayers() {\n\t\t\tconst L = this.L\n\t\t\tif (!this.map || !L) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Tear down old layers\n\t\t\tfor (const layer of this.layerInstances) {\n\t\t\t\tthis.map.removeLayer(layer)\n\t\t\t}\n\t\t\tthis.layerInstances = []\n\t\t\tif (this.layersControl) {\n\t\t\t\tthis.map.removeControl(this.layersControl)\n\t\t\t\tthis.layersControl = null\n\t\t\t}\n\n\t\t\tconst image = this.imageLayer\n\t\t\tif (image) {\n\t\t\t\tthis.renderImageLayer(image)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.renderBasemaps()\n\n\t\t\tfor (const def of this.cfg.layers) {\n\t\t\t\tif (!def || typeof def !== 'object') {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif (def.type === 'image') {\n\t\t\t\t\t// Unusable image layer (no safe url, or no width/height).\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn('[CnMapWidget] Image layer needs a url, width and height; skipping.')\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif (!ALLOWED_LAYER_TYPES.includes(def.type)) {\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn(`[CnMapWidget] Unknown layer type \"${def.type}\", skipping.`)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tconst opts = { ...(def.options || {}) }\n\t\t\t\tif (def.attribution && !opts.attribution) {\n\t\t\t\t\topts.attribution = def.attribution\n\t\t\t\t}\n\n\t\t\t\tlet instance = null\n\t\t\t\tif (def.type === 'tile') {\n\t\t\t\t\tif (typeof def.url !== 'string' || def.url.length === 0) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tinstance = L.tileLayer(def.url, withTileDefaults(opts))\n\t\t\t\t} else if (def.type === 'wms') {\n\t\t\t\t\tif (typeof def.url !== 'string' || def.url.length === 0) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tinstance = L.tileLayer.wms(def.url, withTileDefaults(opts))\n\t\t\t\t} else if (def.type === 'wfs') {\n\t\t\t\t\tif (typeof def.url !== 'string' || def.url.length === 0) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tthis.fetchAndAddGeoJson(def.url, opts)\n\t\t\t\t\tcontinue\n\t\t\t\t} else if (def.type === 'geojson') {\n\t\t\t\t\tif (def.data && typeof def.data === 'object') {\n\t\t\t\t\t\tinstance = L.geoJSON(def.data, opts)\n\t\t\t\t\t} else if (typeof def.url === 'string' && def.url.length > 0) {\n\t\t\t\t\t\tthis.fetchAndAddGeoJson(def.url, opts)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (instance) {\n\t\t\t\t\tinstance.addTo(this.map)\n\t\t\t\t\tthis.layerInstances.push(instance)\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Show the picture: an image overlay on the flat bounds, fitted on open.\n\t\t * Any other layer beside it is skipped with a warning (one picture per map).\n\t\t *\n\t\t * @param {{url: string, width: number, height: number}} image The image layer.\n\t\t */\n\t\trenderImageLayer(image) {\n\t\t\tconst L = this.L\n\t\t\tfor (const def of this.cfg.layers) {\n\t\t\t\tif (def && typeof def === 'object' && def.type !== 'image') {\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn(`[CnMapWidget] Layer type \"${def.type}\" is skipped on an image map.`)\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst bounds = imageBounds(image.width, image.height)\n\t\t\tconst overlay = L.imageOverlay(image.url, bounds)\n\t\t\toverlay.addTo(this.map)\n\t\t\tthis.layerInstances.push(overlay)\n\t\t\tthis.map.fitBounds(bounds)\n\t\t\t// The whole picture is the furthest out; four times native size the furthest in.\n\t\t\tif (typeof this.map.getBoundsZoom === 'function') {\n\t\t\t\tconst fit = this.map.getBoundsZoom(bounds)\n\t\t\t\tif (Number.isFinite(fit)) {\n\t\t\t\t\tif (typeof this.map.setMinZoom === 'function') {\n\t\t\t\t\t\tthis.map.setMinZoom(fit)\n\t\t\t\t\t}\n\t\t\t\t\tif (typeof this.map.setMaxZoom === 'function') {\n\t\t\t\t\t\tthis.map.setMaxZoom(Math.max(2, fit))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Fetch a GeoJSON-shaped resource and add it to the map. Used by\n\t\t * the WFS branch and the geojson-from-url branch.\n\t\t *\n\t\t * @param {string} url Endpoint that returns GeoJSON.\n\t\t * @param {object} opts Leaflet `geoJSON` options.\n\t\t */\n\t\tfetchAndAddGeoJson(url, opts) {\n\t\t\tconst L = this.L\n\t\t\tfetch(prefixUrl(url))\n\t\t\t\t.then((r) => r.json())\n\t\t\t\t.then((json) => {\n\t\t\t\t\tif (!this.map) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconst layer = L.geoJSON(json, opts)\n\t\t\t\t\tlayer.addTo(this.map)\n\t\t\t\t\tthis.layerInstances.push(layer)\n\t\t\t\t})\n\t\t\t\t.catch((err) => {\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn('[CnMapWidget] Failed to load layer', url, err)\n\t\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Mount the configured `markers` onto the Leaflet map. Reads from\n\t\t * `features[]` (inline) OR `dataSource.url` (HTTP fetch). When\n\t\t * `clusteringEnabled`, lazy-loads `leaflet.markercluster` and\n\t\t * groups markers under a single cluster layer.\n\t\t */\n\t\tasync renderMarkers() {\n\t\t\tconst L = this.L\n\t\t\tif (!this.map || !L || !this.cfg.markers) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Tear down previous marker layer\n\t\t\tif (this.markerLayer) {\n\t\t\t\tthis.map.removeLayer(this.markerLayer)\n\t\t\t\tthis.markerLayer = null\n\t\t\t}\n\t\t\tif (this.clusterGroup) {\n\t\t\t\tthis.map.removeLayer(this.clusterGroup)\n\t\t\t\tthis.clusterGroup = null\n\t\t\t}\n\n\t\t\tif (!this.imageLayer) {\n\t\t\t\tthis.unplottedCount = 0\n\t\t\t}\n\t\t\tconst features = await this.collectFeatures()\n\t\t\tif (!features || features.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst layer = L.geoJSON({ type: 'FeatureCollection', features }, {\n\t\t\t\tpointToLayer: (feature, latlng) => {\n\t\t\t\t\tconst color = (feature.properties && feature.properties.markerColor)\n\t\t\t\t\t\t|| (this.cfg.markers && this.cfg.markers.iconColor)\n\t\t\t\t\t\t|| 'var(--color-primary-element, #2196F3)'\n\t\t\t\t\tif (this.cfg.markers && this.cfg.markers.iconUrl) {\n\t\t\t\t\t\treturn L.marker(latlng, {\n\t\t\t\t\t\t\ticon: L.icon({ iconUrl: this.cfg.markers.iconUrl, iconSize: [25, 41], iconAnchor: [12, 41] }),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\treturn L.circleMarker(latlng, {\n\t\t\t\t\t\tradius: 8,\n\t\t\t\t\t\tfillColor: color,\n\t\t\t\t\t\tcolor: '#fff',\n\t\t\t\t\t\tweight: 2,\n\t\t\t\t\t\topacity: 1,\n\t\t\t\t\t\tfillOpacity: 0.85,\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t\tonEachFeature: (feature, lyr) => {\n\t\t\t\t\tconst popupField = this.cfg.markers && this.cfg.markers.popupField\n\t\t\t\t\tconst popupHtml = popupField && feature.properties ? feature.properties[popupField] : null\n\t\t\t\t\tif (popupHtml) {\n\t\t\t\t\t\t// Security: marker data may come from an external URL\n\t\t\t\t\t\t// (markers.dataSource.url) or arbitrary GeoJSON feature\n\t\t\t\t\t\t// properties (often an OR-backed data source). Leaflet's\n\t\t\t\t\t\t// bindPopup renders its string argument as HTML (innerHTML),\n\t\t\t\t\t\t// so sanitize with the shared strict config (C1) before\n\t\t\t\t\t\t// injecting — a `<script>` / `onerror=` payload cannot\n\t\t\t\t\t\t// execute in the Nextcloud origin.\n\t\t\t\t\t\tconst safeHtml = DOMPurify.sanitize(String(popupHtml), SAFE_MARKDOWN_DOMPURIFY_CONFIG)\n\t\t\t\t\t\tlyr.bindPopup(safeHtml)\n\t\t\t\t\t}\n\n\t\t\t\t\t// Hover tooltip: a marker with no label is a mystery dot. Name it\n\t\t\t\t\t// with the object's own display name — the backend already derives\n\t\t\t\t\t// one and publishes it as `@self.name`, so a Cow (`name`), a Barn\n\t\t\t\t\t// (`title`), and a Case (`reference`) all read correctly without the\n\t\t\t\t\t// widget knowing the schema. bindTooltip also renders HTML, so\n\t\t\t\t\t// sanitize the same way bindPopup does.\n\t\t\t\t\tconst label = objectDisplayName(feature.properties || {})\n\t\t\t\t\tif (label !== '') {\n\t\t\t\t\t\tconst safeLabel = DOMPurify.sanitize(label, SAFE_MARKDOWN_DOMPURIFY_CONFIG)\n\t\t\t\t\t\tlyr.bindTooltip(safeLabel, { direction: 'top' })\n\t\t\t\t\t}\n\t\t\t\t\tlyr.on('click', (e) => {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Marker click event. Fired when a marker is clicked.\n\t\t\t\t\t\t *\n\t\t\t\t\t\t * @event marker-click\n\t\t\t\t\t\t * @type {{feature: object, latlng: object}}\n\t\t\t\t\t\t */\n\t\t\t\t\t\tthis.$emit('marker-click', { feature, latlng: e.latlng })\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t})\n\n\t\t\tif (this.clusteringEnabled) {\n\t\t\t\ttry {\n\t\t\t\t\t// TWO THINGS HAVE TO BE TRUE BEFORE THIS PLUGIN CAN RUN, AND\n\t\t\t\t\t// NEITHER OF THEM WAS.\n\t\t\t\t\t//\n\t\t\t\t\t// 1. The import has to reach a file. This line used to carry a\n\t\t\t\t\t// `webpackIgnore: true` magic comment, on the stated\n\t\t\t\t\t// reasoning that it stopped a downstream bundler resolving\n\t\t\t\t\t// the specifier against\n\t\t\t\t\t// `node_modules/@conduction/nextcloud-vue/dist/` and failing\n\t\t\t\t\t// with \"Module not found\". That reasoning does not hold, and\n\t\t\t\t\t// the marker is what broke it. `webpackIgnore` does not mean\n\t\t\t\t\t// \"skip the resolution\"; it means \"leave this import for the\n\t\t\t\t\t// BROWSER\", and a browser on a Nextcloud page cannot resolve\n\t\t\t\t\t// a module specifier. Rollup had already rewritten the\n\t\t\t\t\t// specifier in the published ESM build to\n\t\t\t\t\t// `../../node_modules/leaflet.markercluster/dist/\n\t\t\t\t\t// leaflet.markercluster-src.js` and emitted that file inside\n\t\t\t\t\t// the package, so every consumer requested\n\t\t\t\t\t// `node_modules/leaflet.markercluster/...` off the page URL\n\t\t\t\t\t// and got a 404. Clustering has never run in any app.\n\t\t\t\t\t// Without the marker, webpack resolves the relative path\n\t\t\t\t\t// against the file the package actually ships and emits it\n\t\t\t\t\t// as a lazy chunk, so the import stays lazy and clustering\n\t\t\t\t\t// costs nothing to a consumer that does not ask for it.\n\t\t\t\t\t//\n\t\t\t\t\t// 2. The plugin has to find Leaflet. leaflet.markercluster is a\n\t\t\t\t\t// CLASSIC Leaflet plugin: it exports nothing and instead\n\t\t\t\t\t// writes `L.MarkerClusterGroup` onto the GLOBAL `L`. This\n\t\t\t\t\t// widget imports Leaflet as an ES module and keeps it on\n\t\t\t\t\t// `this.L`, so there was no global for the plugin to attach\n\t\t\t\t\t// to, and even a plugin that loaded would have left\n\t\t\t\t\t// `L.markerClusterGroup` undefined. Publishing our own\n\t\t\t\t\t// instance on `window.L` first is what makes the plugin\n\t\t\t\t\t// augment the same object this component then reads.\n\t\t\t\t\t//\n\t\t\t\t\t// An existing `window.L` is left alone: if the page already has\n\t\t\t\t\t// Leaflet, the plugin must extend THAT one, and overwriting it\n\t\t\t\t\t// would break whatever put it there.\n\t\t\t\t\tif (typeof window !== 'undefined' && !window.L) {\n\t\t\t\t\t\twindow.L = L\n\t\t\t\t\t}\n\t\t\t\t\tawait import('leaflet.markercluster')\n\t\t\t\t\tif (typeof L.markerClusterGroup === 'function') {\n\t\t\t\t\t\tthis.clusterGroup = L.markerClusterGroup()\n\t\t\t\t\t\tthis.clusterGroup.addLayer(layer)\n\t\t\t\t\t\tthis.clusterGroup.addTo(this.map)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlayer.addTo(this.map)\n\t\t\t\t\t\tthis.markerLayer = layer\n\t\t\t\t\t}\n\t\t\t\t} catch (err) {\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn('[CnMapWidget] Cluster plugin unavailable', err)\n\t\t\t\t\tlayer.addTo(this.map)\n\t\t\t\t\tthis.markerLayer = layer\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlayer.addTo(this.map)\n\t\t\t\tthis.markerLayer = layer\n\t\t\t}\n\n\t\t\t// Not once per render: a consumer that reloads its markers (a filter,\n\t\t\t// a poll, a save) re-renders them, and fitting again threw away\n\t\t\t// whatever the user had panned or zoomed to. Their framing wins from\n\t\t\t// the moment they touch the map; the Fit all markers control brings\n\t\t\t// this one back on request.\n\t\t\tif (this.cfg.autoFit && !this.userFramedView && !this.imageLayer) {\n\t\t\t\t// Wait a tick so the container has its final box — fill-height layouts\n\t\t\t\t// and the hidden→visible view toggle both settle after render.\n\t\t\t\t// fitToMarkers() then measures before it fits.\n\t\t\t\tthis.$nextTick(() => this.fitToMarkers())\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Mount the switchable base maps and, when more than one is configured, a\n\t\t * Leaflet layer switcher. Falls back to a single OpenStreetMap base map when\n\t\t * the consumer supplied neither `basemaps` nor a `tile` entry in `layers`, so\n\t\t * a map is never left with a blank background.\n\t\t */\n\t\trenderBasemaps() {\n\t\t\tconst L = this.L\n\t\t\tif (!this.map || !L || typeof L.tileLayer !== 'function') {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst basemaps = (this.cfg.basemaps || []).filter((b) => b && typeof b.url === 'string' && b.url.length > 0)\n\t\t\tif (basemaps.length === 0) {\n\t\t\t\t// No explicit basemaps. Unless the consumer draws its own background\n\t\t\t\t// via a `tile`/`wms` entry in `layers`, add a single OpenStreetMap\n\t\t\t\t// base map so the map is never left blank (see DEFAULT_BASEMAP).\n\t\t\t\tconst hasTileLayer = (this.cfg.layers || []).some((l) => l\n\t\t\t\t\t&& (l.type === 'tile' || l.type === 'wms')\n\t\t\t\t\t&& typeof l.url === 'string' && l.url.length > 0)\n\t\t\t\tif (!hasTileLayer) {\n\t\t\t\t\tconst fallback = L.tileLayer(DEFAULT_BASEMAP.url, withTileDefaults({ ...DEFAULT_BASEMAP.options }))\n\t\t\t\t\tfallback.addTo(this.map)\n\t\t\t\t\tthis.layerInstances.push(fallback)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst baseLayers = {}\n\t\t\tbasemaps.forEach((bm, index) => {\n\t\t\t\tconst opts = { ...(bm.options || {}) }\n\t\t\t\tif (bm.attribution && !opts.attribution) {\n\t\t\t\t\topts.attribution = bm.attribution\n\t\t\t\t}\n\t\t\t\tconst instance = L.tileLayer(bm.url, withTileDefaults(opts))\n\t\t\t\tbaseLayers[bm.name || `${index + 1}`] = instance\n\t\t\t\t// Only the first base map is live on load; the switcher swaps in the rest.\n\t\t\t\tif (index === 0) {\n\t\t\t\t\tinstance.addTo(this.map)\n\t\t\t\t}\n\t\t\t\tthis.layerInstances.push(instance)\n\t\t\t})\n\n\t\t\tif (Object.keys(baseLayers).length > 1 && L.control && typeof L.control.layers === 'function') {\n\t\t\t\tthis.layersControl = L.control.layers(baseLayers, {}, { position: 'topright' })\n\t\t\t\tthis.layersControl.addTo(this.map)\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Mount the custom control bar (fit-all / locate / fullscreen). Leaflet's own\n\t\t * zoom + attribution controls are enabled separately in `initMap`.\n\t\t */\n\t\taddControls() {\n\t\t\tconst L = this.L\n\t\t\t// L.Control is absent from lightweight Leaflet stubs (tests); the map is\n\t\t\t// still perfectly usable without the extra bar, so degrade quietly.\n\t\t\tif (!this.map || !L || !L.Control || typeof L.Control.extend !== 'function') {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst buttons = []\n\t\t\tif (this.fitControl) {\n\t\t\t\tbuttons.push({ key: 'fit', title: t('nextcloud-vue', 'Fit all markers'), icon: ICON_FIT, onClick: () => this.fitToMarkers() })\n\t\t\t}\n\t\t\tif (this.locateControl) {\n\t\t\t\tbuttons.push({ key: 'locate', title: t('nextcloud-vue', 'Show my location'), icon: ICON_LOCATE, onClick: () => this.locateMe() })\n\t\t\t}\n\t\t\tif (this.fullscreenControl) {\n\t\t\t\tbuttons.push({ key: 'fullscreen', title: t('nextcloud-vue', 'Toggle fullscreen'), icon: ICON_FULLSCREEN, onClick: () => this.toggleFullscreen() })\n\t\t\t}\n\t\t\tif (buttons.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// `onAdd()` below is invoked by Leaflet with `this` bound to the\n\t\t\t// L.Control instance, so the component has to be captured here to\n\t\t\t// stash the fullscreen button ref on it.\n\t\t\t// eslint-disable-next-line @typescript-eslint/no-this-alias\n\t\t\tconst self = this\n\t\t\tconst ControlBar = L.Control.extend({\n\t\t\t\tonAdd() {\n\t\t\t\t\tconst bar = L.DomUtil.create('div', 'leaflet-bar cn-map-widget__controls')\n\t\t\t\t\tfor (const button of buttons) {\n\t\t\t\t\t\tconst anchor = L.DomUtil.create('a', `cn-map-widget__control cn-map-widget__control--${button.key}`, bar)\n\t\t\t\t\t\tanchor.href = '#'\n\t\t\t\t\t\tanchor.title = button.title\n\t\t\t\t\t\tanchor.setAttribute('role', 'button')\n\t\t\t\t\t\tanchor.setAttribute('aria-label', button.title)\n\t\t\t\t\t\tanchor.innerHTML = controlIcon(button.icon)\n\t\t\t\t\t\tif (button.key === 'fullscreen') {\n\t\t\t\t\t\t\tself.fullscreenButton = anchor\n\t\t\t\t\t\t}\n\t\t\t\t\t\tL.DomEvent.on(anchor, 'click', L.DomEvent.stop).on(anchor, 'click', button.onClick)\n\t\t\t\t\t}\n\t\t\t\t\t// Keep clicks / wheel on the bar from panning or zooming the map.\n\t\t\t\t\tL.DomEvent.disableClickPropagation(bar)\n\t\t\t\t\tL.DomEvent.disableScrollPropagation(bar)\n\t\t\t\t\treturn bar\n\t\t\t\t},\n\t\t\t})\n\n\t\t\tthis.controlBar = new ControlBar({ position: 'topleft' })\n\t\t\tthis.controlBar.addTo(this.map)\n\t\t},\n\n\t\t/**\n\t\t * Arm the hand-over: the next view change the map reports is the user's.\n\t\t */\n\t\tonViewGesture() {\n\t\t\tthis.pendingViewGesture = true\n\t\t},\n\n\t\t/**\n\t\t * Commit an armed gesture once the view has actually moved — and only\n\t\t * then, so a gesture that framed nothing cannot disable `autoFit`.\n\t\t */\n\t\tonViewSettled() {\n\t\t\tif (this.pendingViewGesture) {\n\t\t\t\tthis.pendingViewGesture = false\n\t\t\t\tthis.userFramedView = true\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Re-measure the container. Also drops an armed gesture: a size change is\n\t\t * ours, so the `moveend` it may fire is not the user framing the view.\n\t\t */\n\t\trefreshMapSize() {\n\t\t\tif (!this.map || typeof this.map.invalidateSize !== 'function') {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.pendingViewGesture = false\n\t\t\tthis.map.invalidateSize()\n\t\t},\n\n\t\t/**\n\t\t * Re-centre and re-zoom so every marker is back in view — the same frame\n\t\t * `autoFit` lands on at load. Public: consumers MAY call it through `$refs`.\n\t\t */\n\t\tfitToMarkers() {\n\t\t\tif (!this.map) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (this.imageLayer) {\n\t\t\t\t// On a picture, \"fit\" is the whole picture.\n\t\t\t\tthis.refreshMapSize()\n\t\t\t\tthis.map.fitBounds(imageBounds(this.imageLayer.width, this.imageLayer.height))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst target = this.clusterGroup || this.markerLayer\n\t\t\tif (!target) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst bounds = target.getBounds()\n\t\t\t\tif (!bounds || !bounds.isValid()) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// Measure first — a stale container size yields a wrong fit.\n\t\t\t\tthis.refreshMapSize()\n\t\t\t\tthis.map.fitBounds(bounds, { padding: [50, 50] })\n\t\t\t} catch {\n\t\t\t\t// ignore — empty layer set means no bounds to fit\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Centre the map on the visitor's own position. Failures (denied permission,\n\t\t * insecure origin) surface via the `locationerror` handler in `initMap`.\n\t\t */\n\t\tlocateMe() {\n\t\t\tif (!this.map || typeof this.map.locate !== 'function') {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.map.locate({ setView: true, maxZoom: 16 })\n\t\t},\n\n\t\t/**\n\t\t * Toggle the CSS fullscreen overlay and re-flow Leaflet into the new box.\n\t\t */\n\t\ttoggleFullscreen() {\n\t\t\tthis.isFullscreen = !this.isFullscreen\n\t\t\tif (this.fullscreenButton) {\n\t\t\t\tthis.fullscreenButton.innerHTML = controlIcon(this.isFullscreen ? ICON_FULLSCREEN_EXIT : ICON_FULLSCREEN)\n\t\t\t}\n\t\t\tthis.$nextTick(() => {\n\t\t\t\tthis.refreshMapSize()\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Resolve the features set for the current `markers` config.\n\t\t *\n\t\t * Returns the inline `features[]` when set; otherwise fetches\n\t\t * `dataSource.url` and converts the response into GeoJSON\n\t\t * features. The response MAY be either a GeoJSON\n\t\t * FeatureCollection OR an array of flat rows (in which case\n\t\t * `latField`, `lngField`, `popupField` drive the conversion).\n\t\t *\n\t\t * `dataSource.{register, schema}` plots the objects of an OpenRegister\n\t\t * register/schema (see fetchRegisterFeatures).\n\t\t *\n\t\t * @return {Promise<Array<object>>} GeoJSON Feature array.\n\t\t */\n\t\tasync collectFeatures() {\n\t\t\tif (!this.cfg.markers) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\tif (this.imageLayer) {\n\t\t\t\treturn this.imageFeatures(await this.collectSourceRows())\n\t\t\t}\n\t\t\tlet features = await this.collectSourceFeatures()\n\t\t\t// Optional pin at the configured centre, plotted alongside the object\n\t\t\t// markers (`markers.centerMarker`). Spread into a new array so an inline\n\t\t\t// `features[]` prop is never mutated.\n\t\t\tif (this.cfg.markers.centerMarker) {\n\t\t\t\tconst centre = this.centreMarkerFeature()\n\t\t\t\tif (centre) {\n\t\t\t\t\tfeatures = [...features, centre]\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn features\n\t\t},\n\n\t\t/**\n\t\t * The rows behind an image map's markers: inline features' properties, a\n\t\t * fetched `dataSource.url` (rows, or a FeatureCollection's properties) or an\n\t\t * OpenRegister `dataSource.{register, schema}`.\n\t\t *\n\t\t * @return {Promise<Array<object>>} The rows.\n\t\t */\n\t\tasync collectSourceRows() {\n\t\t\tconst m = this.cfg.markers\n\t\t\tconst fromFeatures = (list) => (Array.isArray(list) ? list.map((f) => (f && f.properties ? f.properties : f)) : [])\n\t\t\tif (Array.isArray(m.features)) {\n\t\t\t\treturn fromFeatures(m.features)\n\t\t\t}\n\t\t\tconst ds = m.dataSource\n\t\t\tif (!ds) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tif (typeof ds.url === 'string' && ds.url.length > 0) {\n\t\t\t\t\tconst json = await (await fetch(prefixUrl(ds.url))).json()\n\t\t\t\t\treturn Array.isArray(json) ? json : fromFeatures(json && json.features)\n\t\t\t\t}\n\t\t\t\tif (ds.register && ds.schema) {\n\t\t\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([import('@nextcloud/axios'), import('@nextcloud/router')])\n\t\t\t\t\tconst url = generateUrl('/apps/openregister/api/objects/{register}/{schema}', { register: ds.register, schema: ds.schema })\n\t\t\t\t\tconst res = await axios.get(url, { params: { _limit: ds.limit || 500 } })\n\t\t\t\t\treturn (res && res.data && res.data.results) || []\n\t\t\t\t}\n\t\t\t} catch (err) {\n\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\tconsole.warn('[CnMapWidget] Failed to load markers', ds, err)\n\t\t\t}\n\t\t\treturn []\n\t\t},\n\n\t\t/**\n\t\t * Place rows on the picture by `xField` / `yField` (default `x` / `y`).\n\t\t * A row without both numbers is not plotted and is counted in the note.\n\t\t *\n\t\t * @param {Array<object>} rows The rows.\n\t\t * @return {Array<object>} GeoJSON Point features in the flat coordinates.\n\t\t */\n\t\timageFeatures(rows) {\n\t\t\tconst xField = (this.cfg.markers && this.cfg.markers.xField) || 'x'\n\t\t\tconst yField = (this.cfg.markers && this.cfg.markers.yField) || 'y'\n\t\t\tconst features = []\n\t\t\tlet skipped = 0\n\t\t\tfor (const row of rows) {\n\t\t\t\tif (!hasImagePosition(row, xField, yField)) {\n\t\t\t\t\tskipped += 1\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tconst point = toImagePoint(row[xField], row[yField])\n\t\t\t\tfeatures.push({\n\t\t\t\t\ttype: 'Feature',\n\t\t\t\t\tgeometry: { type: 'Point', coordinates: [point.lng, point.lat] },\n\t\t\t\t\tproperties: { ...row },\n\t\t\t\t})\n\t\t\t}\n\t\t\tthis.unplottedCount = skipped\n\t\t\treturn features\n\t\t},\n\n\t\t/**\n\t\t * Resolve the marker features from the configured source only —\n\t\t * inline `features[]`, a fetched `dataSource.url`, or an OpenRegister\n\t\t * `dataSource.{register, schema}`. The optional centre pin is layered on\n\t\t * by collectFeatures().\n\t\t *\n\t\t * @return {Promise<Array<object>>} GeoJSON Feature array.\n\t\t */\n\t\tasync collectSourceFeatures() {\n\t\t\tif (Array.isArray(this.cfg.markers.features)) {\n\t\t\t\treturn this.cfg.markers.features\n\t\t\t}\n\t\t\tconst ds = this.cfg.markers.dataSource\n\t\t\tif (!ds) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\tif (typeof ds.url === 'string' && ds.url.length > 0) {\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await fetch(prefixUrl(ds.url))\n\t\t\t\t\tconst json = await response.json()\n\t\t\t\t\treturn this.normaliseFeatures(json)\n\t\t\t\t} catch (err) {\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn('[CnMapWidget] Failed to fetch markers', ds.url, err)\n\t\t\t\t\treturn []\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (ds.register && ds.schema) {\n\t\t\t\treturn await this.fetchRegisterFeatures(ds)\n\t\t\t}\n\t\t\treturn []\n\t\t},\n\n\t\t/**\n\t\t * Build a GeoJSON point Feature at the resolved centre, or null when the\n\t\t * centre is not a valid `[lat, lng]` pair. GeoJSON coordinates are\n\t\t * `[lng, lat]`, the reverse of the `center` prop's order.\n\t\t *\n\t\t * @return {object|null} the centre Feature, or null.\n\t\t */\n\t\tcentreMarkerFeature() {\n\t\t\tconst c = this.cfg.center\n\t\t\tif (!Array.isArray(c) || c.length !== 2 || !c.every((n) => Number.isFinite(n))) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn {\n\t\t\t\ttype: 'Feature',\n\t\t\t\tgeometry: { type: 'Point', coordinates: [c[1], c[0]] },\n\t\t\t\tproperties: {},\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Plot the objects of an OpenRegister register/schema.\n\t\t *\n\t\t * Each object's location comes from `@self.geo` (where CnObjectGeoWidget writes\n\t\t * it), falling back to plain `latField` / `lngField` properties so a schema that\n\t\t * models coordinates itself still plots. Objects with no usable location are\n\t\t * skipped rather than dropped at (0, 0) — an invisible wrong marker is worse\n\t\t * than an absent one.\n\t\t *\n\t\t * @param {{register: string, schema: string, limit?: number}} ds The data source.\n\t\t * @return {Promise<Array<object>>} GeoJSON Feature array.\n\t\t */\n\t\tasync fetchRegisterFeatures(ds) {\n\t\t\ttry {\n\t\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([\n\t\t\t\t\timport('@nextcloud/axios'),\n\t\t\t\t\timport('@nextcloud/router'),\n\t\t\t\t])\n\t\t\t\tconst url = generateUrl(\n\t\t\t\t\t'/apps/openregister/api/objects/{register}/{schema}',\n\t\t\t\t\t{ register: ds.register, schema: ds.schema },\n\t\t\t\t)\n\t\t\t\tconst res = await axios.get(url, { params: { _limit: ds.limit || 500 } })\n\t\t\t\tconst rows = (res && res.data && res.data.results) || []\n\n\t\t\t\tconst latField = this.cfg.markers && this.cfg.markers.latField\n\t\t\t\tconst lngField = this.cfg.markers && this.cfg.markers.lngField\n\n\t\t\t\treturn rows\n\t\t\t\t\t.map((row) => objectToGeoFeature(row, { latField, lngField }))\n\t\t\t\t\t.filter(Boolean)\n\t\t\t} catch (err) {\n\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\tconsole.warn('[CnMapWidget] Failed to load objects for the map', ds, err)\n\t\t\t\treturn []\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Normalise a fetched payload into a GeoJSON Feature array.\n\t\t * Accepts FeatureCollection, raw Feature[], or flat rows that\n\t\t * declare `latField` / `lngField` (plus optional `popupField`\n\t\t * for popup HTML).\n\t\t *\n\t\t * @param {object|Array} json The fetched payload.\n\t\t * @return {Array<object>} GeoJSON Feature array.\n\t\t */\n\t\tnormaliseFeatures(json) {\n\t\t\tif (!json) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\tif (Array.isArray(json.features)) {\n\t\t\t\treturn json.features\n\t\t\t}\n\t\t\tif (Array.isArray(json)) {\n\t\t\t\tconst latField = (this.cfg.markers && this.cfg.markers.latField) || 'lat'\n\t\t\t\tconst lngField = (this.cfg.markers && this.cfg.markers.lngField) || 'lng'\n\t\t\t\treturn json\n\t\t\t\t\t.filter((row) => row !== null && row !== undefined && Number.isFinite(row[latField]) && Number.isFinite(row[lngField]))\n\t\t\t\t\t.map((row) => ({\n\t\t\t\t\t\ttype: 'Feature',\n\t\t\t\t\t\tgeometry: { type: 'Point', coordinates: [row[lngField], row[latField]] },\n\t\t\t\t\t\tproperties: { ...row },\n\t\t\t\t\t}))\n\t\t\t}\n\t\t\treturn []\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-map-widget {\n\tposition: relative;\n\twidth: 100%;\n\tmin-height: 200px;\n}\n\n.cn-map-widget__leaflet {\n\twidth: 100%;\n\theight: 100%;\n\t/* Leaflet paints its container #ddd, which reads as a white flash before the\n\t tiles arrive — and a bright one in a dark theme. */\n\tbackground: var(--color-background-dark);\n}\n\n.cn-map-widget__note {\n\tmargin: 4px 0 0;\n\tfont-size: 13px;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-map-widget__fallback {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\theight: 100%;\n\tmin-height: 150px;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-map-widget__error {\n\tfont-size: 14px;\n\tmargin: 0;\n}\n\n.cn-map-widget__legend {\n\tposition: absolute;\n\ttop: 12px;\n\tright: 12px;\n\tz-index: 1000;\n\tbackground: var(--color-main-background);\n\tpadding: 8px 12px;\n\tborder-radius: var(--border-radius-large, 8px);\n\tbox-shadow: 0 2px 8px rgba(0, 0, 0, .15);\n}\n\n/* Fullscreen overlay. A CSS-fixed box rather than the Fullscreen API, so it needs\n no permission prompt; the z-index clears the Nextcloud header. */\n.cn-map-widget--fullscreen {\n\tposition: fixed;\n\ttop: 0;\n\tleft: 0;\n\twidth: 100vw;\n\theight: 100vh;\n\tz-index: 3000;\n}\n\n/* Custom control bar (fit / locate / fullscreen), themed to Nextcloud rather than\n Leaflet's default white-on-grey buttons.\n ::v-deep is required — Leaflet builds the control DOM via L.DomUtil.create, so\n those nodes never receive this SFC's scoped data-v attribute. */\n.cn-map-widget ::v-deep .cn-map-widget__control {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\twidth: 30px;\n\theight: 30px;\n\tcolor: var(--color-main-text);\n\tbackground: var(--color-main-background);\n}\n\n.cn-map-widget ::v-deep .cn-map-widget__control:hover,\n.cn-map-widget ::v-deep .cn-map-widget__control:focus-visible {\n\tbackground: var(--color-background-hover);\n}\n</style>\n"],"names":["_normalizeClass"],"mappings":";;;;;;;;;;;;;;;;;;;;IAsEE,KAAI,EAAAA,cAAA,CAAA,CAAA,eAAA,EAAA,EAAA,2BAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
|
1
|
+
{"version":3,"file":"CnMapWidget.vue3.js","sources":["../../../../src/components/CnMapWidget/CnMapWidget.vue?vue&type=template&id=21c47690&lang.js"],"sourcesContent":["<!--\n CnMapWidget — Renders a Leaflet map with declarative layers + markers.\n\n A library-first wrapper around Leaflet that consumes the manifest's\n page-shape directly: `{ center, zoom, layers, markers, clustering,\n height, autoFit }`. Layer dispatch picks `L.tileLayer |\n L.tileLayer.wms | L.geoJSON` per `layer.type`; unknown types log a\n console.warn and are skipped (matches CnChartWidget's\n unknown-`chartKind` posture).\n\n Marker support is opt-in:\n - `markers.features[]` — inline GeoJSON FeatureCollection.\n - `markers.dataSource.url` — fetched on mount; the response may\n be either a GeoJSON FeatureCollection OR a flat array of rows\n (in which case `latField`, `lngField`, `popupField` drive the\n conversion).\n - `markers.dataSource.{register, schema}` — plots the objects of an\n OpenRegister register/schema, reading each one's `@self.geo`\n (falling back to `latField` / `lngField` on the object itself).\n Objects with no usable location are skipped, not plotted at (0, 0).\n\n `leaflet.markercluster`'s JS lazy-loads only when `clustering: true`\n (or `markers.clustering: true`), so a consumer without clustering does\n not pay the cluster bundle cost. Its two stylesheets are imported\n eagerly with Leaflet's own, because a cluster with no CSS paints as a\n bare number in the corner of the map.\n\n This component is the page's primitive — `CnMapPage` wraps it for\n manifest-driven `type: \"map\"` routes. Apps that want to embed a map\n inside a custom dashboard slot can `<CnMapWidget v-bind=\"props\" />`\n directly.\n\n Spec: REQ-MMW-* (manifest-map-widget).\n-->\n<template>\n\t<div\n\t\tclass=\"cn-map-widget\"\n\t\t:class=\"{ 'cn-map-widget--fullscreen': isFullscreen }\"\n\t\t:style=\"{ height: isFullscreen ? null : resolvedHeight }\">\n\t\t<div\n\t\t\tv-if=\"leafletAvailable\"\n\t\t\tref=\"mapEl\"\n\t\t\tclass=\"cn-map-widget__leaflet\"\n\t\t\trole=\"application\"\n\t\t\t:aria-label=\"ariaLabel\"\n\t\t\ttabindex=\"0\" />\n\t\t<div v-else class=\"cn-map-widget__fallback\">\n\t\t\t<!--\n\t\t\t\t@slot fallback\n\t\t\t\t@description Shown when Leaflet is not available (e.g. test environments where\n\t\t\t\tthe global `L` cannot be loaded).\n\t\t\t-->\n\t\t\t<slot name=\"fallback\">\n\t\t\t\t<p class=\"cn-map-widget__error\">\n\t\t\t\t\t{{ unavailableLabel }}\n\t\t\t\t</p>\n\t\t\t</slot>\n\t\t</div>\n\t\t<p v-if=\"unplottedCount > 0\" class=\"cn-map-widget__note\" data-testid=\"cn-map-unplotted\">\n\t\t\t{{ unplottedLabel }}\n\t\t</p>\n\t\t<div v-if=\"$slots.legend || $slots.legend\" class=\"cn-map-widget__legend\">\n\t\t\t<!--\n\t\t\t\t@slot legend\n\t\t\t\t@description Custom legend overlay. Receives `{ layers, markers }` as scoped props.\n\t\t\t\tPositioned absolute (top-right) by default; consumers MAY override with their own\n\t\t\t\tcontainer.\n\t\t\t-->\n\t\t\t<slot name=\"legend\" :layers=\"cfg.layers\" :markers=\"cfg.markers\" />\n\t\t</div>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport DOMPurify from 'dompurify'\nimport { objectToGeoFeature } from '../../utils/geo.js'\nimport { prefixUrl } from '../../utils/headers.js'\nimport { objectDisplayName } from '../../utils/objectName.js'\nimport { safeHref } from '../../utils/safeHref.js'\nimport { SAFE_MARKDOWN_DOMPURIFY_CONFIG } from '../../utils/safeMarkdownDompurifyConfig.js'\nimport { fromImagePoint, hasImagePosition, imageBounds, paddedImageBounds, toImagePoint } from './imageCoordinates.js'\n\n// Leaflet's own stylesheet positions the map panes, tiles the tile\n// images, and places the zoom/attribution controls. The JS is lazy-loaded\n// in mounted(), but the CSS must be present whenever this widget renders —\n// otherwise the map paints unstyled (no basemap, mispositioned controls).\n// Import it here so the widget is self-styling for every consumer rather\n// than relying on another component (e.g. a location picker) to pull it in.\nimport 'leaflet/dist/leaflet.css'\n// The cluster plugin's JS stays lazy, but its CSS cannot be: a cluster is a\n// `<div class=\"marker-cluster\">` with a count in it, and with no stylesheet it\n// paints as a bare number in the top-left corner of the map instead of a\n// bubble on the cluster's position. Two small files, ~2 KB together, and\n// `leaflet.markercluster` is a declared dependency either way.\nimport 'leaflet.markercluster/dist/MarkerCluster.css'\nimport 'leaflet.markercluster/dist/MarkerCluster.Default.css'\n\nconst ALLOWED_LAYER_TYPES = ['tile', 'wms', 'wfs', 'geojson', 'image']\n\n// Raw input on the map container, which is what tells a user's pan or zoom from\n// ours. Leaflet's own `movestart` / `zoomstart` fire for `fitBounds` too, so\n// they cannot separate the two; these events Leaflet never synthesises. A\n// gesture only ARMS the flag — `moveend` / `zoomend` commits it — because half\n// of these change nothing on their own: a page scroll over a map with\n// `scrollWheelZoom` off is a wheel event, and tabbing through is a keydown.\nconst VIEW_GESTURE_EVENTS = ['mousedown', 'touchstart', 'wheel', 'keydown']\n\n// Nextcloud sends `Referrer-Policy: no-referrer` on every page, and OpenStreetMap's\n// tile CDN answers a refererless request with a \"not following the tile usage policy\"\n// tile instead of the map. An `<img>`-level policy overrides the document's, and\n// `origin` sends only the scheme+host, never the path a case id could sit in.\nconst TILE_REFERRER_POLICY = 'strict-origin-when-cross-origin'\n\n/**\n * Apply the tile defaults a consumer did not set.\n *\n * @param {object} opts Leaflet layer options, already shallow-copied.\n * @return {object} The same object, with `referrerPolicy` defaulted.\n */\nfunction withTileDefaults(opts) {\n\tif (opts.referrerPolicy === undefined) {\n\t\topts.referrerPolicy = TILE_REFERRER_POLICY\n\t}\n\treturn opts\n}\n\n/**\n * A comparable snapshot of one config value, for the render watchers.\n *\n * @param {*} value The config value.\n * @return {string|object} A stable string; a fresh object when it will not\n * serialise, which always compares unequal and so keeps rendering.\n */\nfunction configKey(value) {\n\ttry {\n\t\treturn JSON.stringify(value ?? null)\n\t} catch {\n\t\treturn {}\n\t}\n}\n\n// Fallback background used when the consumer configures no `basemaps` and no\n// `tile`/`wms` entry in `layers` — otherwise the map paints white. Consuming\n// apps MUST allow this host in their Content-Security-Policy `img-src`\n// (Nextcloud blocks external images by default) or the tiles are CSP-blocked\n// and the map stays blank.\nconst DEFAULT_BASEMAP = {\n\turl: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\n\toptions: {\n\t\tattribution: '© OpenStreetMap contributors',\n\t\tmaxZoom: 19,\n\t},\n}\n\n// MDI paths for the custom control buttons, inlined so the widget carries no\n// icon-font dependency and renders identically inside a Leaflet control bar.\nconst ICON_FIT = 'M9.5,13.09L10.91,14.5L6.41,19H10V21H3V14H5V17.59L9.5,13.09M10.91,9.5L9.5,10.91L5,6.41V10H3V3H10V5H6.41L10.91,9.5M14.5,13.09L19,17.59V14H21V21H14V19H17.59L13.09,14.5L14.5,13.09M13.09,9.5L17.59,5H14V3H21V10H19V6.41L14.5,10.91L13.09,9.5Z'\nconst ICON_LOCATE = 'M12,8A4,4 0 0,0 8,12A4,4 0 0,0 12,16A4,4 0 0,0 16,12A4,4 0 0,0 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z'\nconst ICON_FULLSCREEN = 'M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z'\nconst ICON_FULLSCREEN_EXIT = 'M14,14H19V16H16V19H14V14M5,14H10V19H8V16H5V14M8,5H10V10H5V8H8V5M19,8V10H14V5H16V8H19Z'\n\n/**\n * Wrap an MDI path in a 24x24 SVG sized for a Leaflet control button.\n *\n * @param {string} path The MDI `d` attribute.\n * @return {string} SVG markup.\n */\nfunction controlIcon(path) {\n\treturn `<svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" d=\"${path}\" /></svg>`\n}\n\n/**\n * CnMapWidget — Leaflet wrapper for declarative manifest-driven maps.\n *\n * Manifest usage — when CnPageRenderer dispatches a `type: \"map\"` page\n * it mounts CnMapPage, which forwards `pages[].config.{center, zoom,\n * layers, markers, height, clustering}` to this component. Manifest\n * authors do NOT mount this component themselves — declare a\n * `type: \"map\"` page instead. See CnMapPage's leading docblock for\n * the manifest example.\n *\n * Direct (non-manifest) usage:\n *\n * ```vue\n * <CnMapWidget\n * :center=\"[52.13, 5.29]\"\n * :zoom=\"7\"\n * :layers=\"[\n * { type: 'tile', url: 'https://service.pdok.nl/.../EPSG:3857/{z}/{x}/{y}.png',\n * options: { attribution: '© Kadaster', maxZoom: 19 } },\n * { type: 'wms', url: 'https://service.pdok.nl/lv/bag/wms/v2_0',\n * options: { layers: 'pand', transparent: true, opacity: 0.6 } },\n * ]\"\n * :markers=\"{\n * dataSource: { url: '/api/cases/geo' },\n * latField: 'lat',\n * lngField: 'lng',\n * popupField: 'title',\n * }\"\n * clustering\n * height=\"500px\"\n * @marker-click=\"onMarkerClick\" />\n * ```\n *\n * @event map-ready Fired once after Leaflet has loaded and the map is mounted. Payload: `{ map }` — the underlying Leaflet `L.Map` instance.\n * @event marker-click Fired when a marker is clicked. Payload: `{ feature, latlng, originalEvent }`.\n * @event bounds-change Fired after the map's visible bounds change (pan / zoom settle). Payload: `{ bounds, zoom }` where `bounds = { north, south, east, west }`.\n * @event click Fired when the user clicks the map background (no marker hit). Payload: `{ latlng, originalEvent }`.\n *\n * @slot legend Custom legend overlay positioned over the map. Scoped props: `{ layers, markers }`.\n * @slot popup Per-marker popup body override. Scoped props: `{ feature, properties }`. When omitted, the widget renders the value of `markers.popupField` (or no popup at all when unset).\n * @slot fallback Replaces the default \"map unavailable\" message when Leaflet fails to load (test environments, CSP-blocked builds, offline dev). No scoped props.\n */\nexport default {\n\tname: 'CnMapWidget',\n\n\tprops: {\n\t\t/**\n\t\t * Initial map center as `[latitude, longitude]`. Optional: on a dashboard\n\t\t * the centre arrives via `content.center` (see the `cfg` computed), and when\n\t\t * neither source supplies a valid pair it defaults to `[52.13, 5.29]` (the\n\t\t * Netherlands).\n\t\t *\n\t\t * @type {[number, number]}\n\t\t */\n\t\tcenter: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [52.13, 5.29],\n\t\t\tvalidator: (v) => Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number' && Number.isFinite(n)),\n\t\t},\n\n\t\t/**\n\t\t * Initial zoom level.\n\t\t *\n\t\t * @type {number}\n\t\t */\n\t\tzoom: {\n\t\t\ttype: Number,\n\t\t\tdefault: 7,\n\t\t},\n\n\t\t/**\n\t\t * Layer definitions. Each entry: `{ type: 'tile'|'wms'|'wfs'|'geojson'|'image', url, options }`.\n\t\t * `geojson` MAY supply inline `data` (FeatureCollection) instead of `url`.\n\t\t * `image` is `{ type: 'image', url, width, height }`: the map becomes a picture in flat\n\t\t * pixel coordinates ((0, 0) top left); other layers beside it are skipped.\n\t\t * Unknown types log a console.warn and are skipped.\n\t\t *\n\t\t * @type {Array<object>}\n\t\t */\n\t\tlayers: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [],\n\t\t},\n\n\t\t/**\n\t\t * Marker config. `{ features?, dataSource?, latField?, lngField?, xField?, yField?,\n\t\t * popupField?, clustering?, iconColor?, iconUrl?, centerMarker? }`. On an image map\n\t\t * (a layer of type `image`) markers read `xField` / `yField` (default `x` / `y`,\n\t\t * pixels from the picture's top left) instead of `latField` / `lngField`. `features[]` is inline;\n\t\t * `dataSource.url` is HTTP-fetched on mount; `dataSource.{register, schema}`\n\t\t * plots the objects of an OpenRegister register/schema via their `@self.geo`.\n\t\t * `centerMarker: true` adds an extra pin at the map's `center`, alongside any\n\t\t * source markers.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tmarkers: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Enable marker clustering. When true, lazy-loads `leaflet.markercluster`\n\t\t * on first mount. `markers.clustering` overrides this prop when set.\n\t\t *\n\t\t * @type {boolean}\n\t\t */\n\t\tclustering: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Container height. Forwarded to the wrapper div's `style.height`.\n\t\t *\n\t\t * @type {string|number}\n\t\t */\n\t\theight: {\n\t\t\ttype: [String, Number],\n\t\t\tdefault: '500px',\n\t\t},\n\n\t\t/**\n\t\t * Frame the map on every loaded feature. Applies on load and on each\n\t\t * marker update until the user pans or zooms themselves, after which\n\t\t * their view is kept and only the `Fit all markers` control re-frames.\n\t\t *\n\t\t * @type {boolean}\n\t\t */\n\t\tautoFit: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Show the \"fit all markers\" control — re-centres and re-zooms the map so\n\t\t * every marker is back in view (the position the map opens at).\n\t\t *\n\t\t * @type {boolean}\n\t\t */\n\t\tfitControl: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Show the fullscreen toggle. Expands the widget to fill the viewport via a\n\t\t * CSS overlay, so it needs no Fullscreen-API permission prompt.\n\t\t *\n\t\t * @type {boolean}\n\t\t */\n\t\tfullscreenControl: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Show the \"locate me\" control, which centres the map on the visitor's own\n\t\t * position via the browser geolocation API. Warns (does not throw) if denied.\n\t\t *\n\t\t * @type {boolean}\n\t\t */\n\t\tlocateControl: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Switchable base maps: `[{ name, url, attribution, options }]`. The first\n\t\t * entry is active on load and a layer switcher appears when more than one is\n\t\t * given. Supply these INSTEAD of a `tile` entry in `layers` for the background\n\t\t * map. Empty by default, so consumers that declare their background through\n\t\t * `layers` are unaffected.\n\t\t *\n\t\t * @type {Array<object>}\n\t\t */\n\t\tbasemaps: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [],\n\t\t},\n\n\t\t/**\n\t\t * Aria-label for the map application region.\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tariaLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'Map'),\n\t\t},\n\n\t\t/**\n\t\t * Label shown when Leaflet is not available.\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tunavailableLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'Map library not available'),\n\t\t},\n\n\t\t/**\n\t\t * Dashboard `content` blob. When this widget is placed on a dashboard, the\n\t\t * grid stores its config here and each key (`center`, `zoom`, `markers`,\n\t\t * `clustering`, `height`, `autoFit`, `layers`, `basemaps`) overrides the\n\t\t * matching flat prop. Direct (non-dashboard) consumers omit this and pass\n\t\t * the flat props instead. See the `cfg` computed for the merge rules.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tcontent: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Dashboard placement record. Declared so the grid's `:placement` binding is\n\t\t * consumed as a prop instead of leaking onto the root element; unused here.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tplacement: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\t/**\n\t * Events:\n\t *\n\t * @event map-ready\n\t * @description Fired once after Leaflet has loaded and the map is mounted. Payload: `{ map }` — the underlying Leaflet `L.Map` instance. Consumers MAY use the instance to register custom controls or layers beyond the manifest shape.\n\t *\n\t * @event marker-click\n\t * @description Fired when a marker is clicked. Payload: `{ feature, latlng, originalEvent }`.\n\t *\n\t * @event bounds-change\n\t * @description Fired after the map's visible bounds change (pan / zoom settle). Payload: `{ bounds, zoom }` where `bounds = { north, south, east, west }`. Useful for triggering a re-fetch of viewport-scoped data.\n\t *\n\t * @event click\n\t * @description Fired when the user clicks the map background (no marker hit). Payload: `{ latlng, originalEvent }`.\n\t */\n\temits: ['map-ready', 'marker-click', 'bounds-change', 'click'],\n\n\tdata() {\n\t\treturn {\n\t\t\tL: null,\n\t\t\tmap: null,\n\t\t\tlayerInstances: [],\n\t\t\tmarkerLayer: null,\n\t\t\tclusterGroup: null,\n\t\t\tleafletAvailable: true,\n\t\t\tboundsTimer: null,\n\t\t\t// Rows of an image map with no usable x and y, counted in a note.\n\t\t\tunplottedCount: 0,\n\t\t\t// Set once the user has framed the view themselves; stops autoFit.\n\t\t\tuserFramedView: false,\n\t\t\t// A raw gesture has been seen; the next real view change is theirs.\n\t\t\tpendingViewGesture: false,\n\t\t\t// Controls / sizing\n\t\t\tisFullscreen: false,\n\t\t\tcontrolBar: null,\n\t\t\tlayersControl: null,\n\t\t\tfullscreenButton: null,\n\t\t\tresizeObserver: null,\n\t\t\tresizeTimer: null,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * Effective configuration. On a dashboard the placement `content` blob\n\t\t * carries the widget's config, so each key falls back to `content.<key>`\n\t\t * when present and otherwise uses the matching flat prop. This lets\n\t\t * CnMapWidget work both directly (flat props) and as a dashboard renderer\n\t\t * (a grid that binds only `:content` never spreads the keys as props).\n\t\t *\n\t\t * @return {object} the resolved `{ center, zoom, layers, markers,\n\t\t * clustering, height, autoFit, basemaps }`.\n\t\t */\n\t\tcfg() {\n\t\t\tconst c = this.content || {}\n\t\t\tconst p = this.$props\n\t\t\tconst validCentre = Array.isArray(c.center) && c.center.length === 2\n\t\t\t\t&& c.center.every((n) => Number.isFinite(n))\n\t\t\treturn {\n\t\t\t\tcenter: validCentre ? c.center : p.center,\n\t\t\t\tzoom: Number.isFinite(c.zoom) ? c.zoom : p.zoom,\n\t\t\t\tlayers: Array.isArray(c.layers) ? c.layers : p.layers,\n\t\t\t\tmarkers: (c.markers !== undefined && c.markers !== null) ? c.markers : p.markers,\n\t\t\t\tclustering: typeof c.clustering === 'boolean' ? c.clustering : p.clustering,\n\t\t\t\theight: (c.height !== undefined && c.height !== null && c.height !== '') ? c.height : p.height,\n\t\t\t\tautoFit: typeof c.autoFit === 'boolean' ? c.autoFit : p.autoFit,\n\t\t\t\tbasemaps: Array.isArray(c.basemaps) ? c.basemaps : p.basemaps,\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * The image layer, when the map is a picture: the first `image` layer with\n\t\t * a safe URL and a positive width and height. Null for a geographic map.\n\t\t *\n\t\t * @return {{url: string, width: number, height: number}|null}\n\t\t */\n\t\timageLayer() {\n\t\t\tconst def = (this.cfg.layers || []).find((l) => l && l.type === 'image')\n\t\t\tif (!def || safeHref(def.url) === '#' || !(def.width > 0) || !(def.height > 0)) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn { url: safeHref(def.url), width: Number(def.width), height: Number(def.height) }\n\t\t},\n\n\t\tunplottedLabel() {\n\t\t\treturn t('nextcloud-vue', '{count} places have no position', { count: this.unplottedCount })\n\t\t},\n\n\t\tresolvedHeight() {\n\t\t\tif (typeof this.cfg.height === 'number') {\n\t\t\t\treturn `${this.cfg.height}px`\n\t\t\t}\n\t\t\treturn this.cfg.height\n\t\t},\n\n\t\tclusteringEnabled() {\n\t\t\tif (this.cfg.markers && typeof this.cfg.markers.clustering === 'boolean') {\n\t\t\t\treturn this.cfg.markers.clustering\n\t\t\t}\n\t\t\treturn this.cfg.clustering\n\t\t},\n\n\t\t/**\n\t\t * Comparable snapshot of the layer config.\n\t\t *\n\t\t * The render watchers read `cfg`, which reads every prop, and a `deep`\n\t\t * watcher fires whenever its effect re-runs rather than when its value\n\t\t * changes — so a new marker set rebuilt the base tile layer and the map\n\t\t * went white until the tiles came back. A key the watcher can actually\n\t\t * compare renders only on a real change.\n\t\t */\n\t\tlayersKey() {\n\t\t\treturn configKey(this.cfg.layers)\n\t\t},\n\n\t\tmarkersKey() {\n\t\t\treturn configKey(this.cfg.markers)\n\t\t},\n\t},\n\n\twatch: {\n\t\t// Keyed, never `deep: true` — see `layersKey`.\n\t\tlayersKey() {\n\t\t\tif (this.map) {\n\t\t\t\tthis.renderLayers()\n\t\t\t}\n\t\t},\n\n\t\tmarkersKey() {\n\t\t\tif (this.map) {\n\t\t\t\tthis.renderMarkers()\n\t\t\t}\n\t\t},\n\n\t\t// Re-plot when the centre moves, but only while the centre pin is on —\n\t\t// otherwise the centre is an initial-view concern, not a marker one.\n\t\t'cfg.center': {\n\t\t\thandler() {\n\t\t\t\tif (this.map && this.cfg.markers && this.cfg.markers.centerMarker) {\n\t\t\t\t\tthis.renderMarkers()\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t},\n\n\tasync mounted() {\n\t\ttry {\n\t\t\t// Leaflet is loaded as a runtime dep. Default-export the namespace.\n\t\t\tconst mod = await import('leaflet')\n\t\t\tthis.L = mod.default || mod\n\t\t} catch (err) {\n\t\t\t// Fallback when Leaflet can't load (test envs, CSP-blocked CDNs).\n\t\t\t// Surface the fallback slot rather than blanking the page.\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.warn('[CnMapWidget] Leaflet unavailable', err)\n\t\t\tthis.leafletAvailable = false\n\t\t\treturn\n\t\t}\n\t\tthis.initMap()\n\t},\n\n\tbeforeUnmount() {\n\t\tclearTimeout(this.boundsTimer)\n\t\tclearTimeout(this.resizeTimer)\n\t\tif (this.$refs.mapEl) {\n\t\t\tfor (const type of VIEW_GESTURE_EVENTS) {\n\t\t\t\tthis.$refs.mapEl.removeEventListener(type, this.onViewGesture, { capture: true })\n\t\t\t}\n\t\t}\n\t\tif (this.resizeObserver) {\n\t\t\tthis.resizeObserver.disconnect()\n\t\t\tthis.resizeObserver = null\n\t\t}\n\t\tif (this.map) {\n\t\t\tthis.map.remove()\n\t\t\tthis.map = null\n\t\t}\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Mount the Leaflet map instance, attach event handlers, and\n\t\t * render the initial layer + marker set.\n\t\t */\n\t\tinitMap() {\n\t\t\tconst L = this.L\n\t\t\tconst image = this.imageLayer\n\t\t\t// A picture is not the earth: flat pixel coordinates, (0, 0) at the\n\t\t\t// top left, and a view that cannot be dragged far off the picture.\n\t\t\tconst mapOptions = image\n\t\t\t\t? {\n\t\t\t\t\t\tcrs: L.CRS && L.CRS.Simple,\n\t\t\t\t\t\tcenter: [-image.height / 2, image.width / 2],\n\t\t\t\t\t\tzoom: 0,\n\t\t\t\t\t\tminZoom: -5,\n\t\t\t\t\t\tmaxZoom: 2,\n\t\t\t\t\t\tzoomSnap: 0.25,\n\t\t\t\t\t\tmaxBounds: paddedImageBounds(image.width, image.height),\n\t\t\t\t\t\tmaxBoundsViscosity: 1,\n\t\t\t\t\t\tzoomControl: true,\n\t\t\t\t\t\tattributionControl: true,\n\t\t\t\t\t}\n\t\t\t\t: {\n\t\t\t\t\t\tcenter: this.cfg.center,\n\t\t\t\t\t\tzoom: this.cfg.zoom,\n\t\t\t\t\t\tzoomControl: true,\n\t\t\t\t\t\tattributionControl: true,\n\t\t\t\t\t}\n\t\t\tthis.map = L.map(this.$refs.mapEl, mapOptions)\n\n\t\t\t// Capture, because Leaflet stops propagation on its own handles.\n\t\t\tif (this.$refs.mapEl) {\n\t\t\t\tfor (const type of VIEW_GESTURE_EVENTS) {\n\t\t\t\t\tthis.$refs.mapEl.addEventListener(type, this.onViewGesture, { capture: true, passive: true })\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthis.map.on('moveend', this.onViewSettled)\n\t\t\tthis.map.on('zoomend', this.onViewSettled)\n\n\t\t\tthis.map.on('click', (e) => {\n\t\t\t\t/**\n\t\t\t\t * Map background click event. Fired when the user clicks the map outside any marker.\n\t\t\t\t *\n\t\t\t\t * @event click\n\t\t\t\t * @type {{lat: number, lng: number}|{x: number, y: number}} `{ lat, lng }` on a geographic map, `{ x, y }` in the picture's whole pixels on an image map.\n\t\t\t\t */\n\t\t\t\tthis.$emit('click', this.imageLayer ? fromImagePoint(e.latlng, this.imageLayer) : { lat: e.latlng.lat, lng: e.latlng.lng })\n\t\t\t})\n\n\t\t\tthis.map.on('moveend', () => {\n\t\t\t\tclearTimeout(this.boundsTimer)\n\t\t\t\tthis.boundsTimer = setTimeout(() => {\n\t\t\t\t\tif (!this.map) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconst b = this.map.getBounds()\n\t\t\t\t\t/**\n\t\t\t\t\t * Viewport bounds change event. Fired (debounced) after pan / zoom settles.\n\t\t\t\t\t *\n\t\t\t\t\t * @event bounds-change\n\t\t\t\t\t * @type {{north: number, south: number, east: number, west: number, zoom: number}}\n\t\t\t\t\t */\n\t\t\t\t\tthis.$emit('bounds-change', {\n\t\t\t\t\t\tnorth: b.getNorth(),\n\t\t\t\t\t\tsouth: b.getSouth(),\n\t\t\t\t\t\teast: b.getEast(),\n\t\t\t\t\t\twest: b.getWest(),\n\t\t\t\t\t\tzoom: this.map.getZoom(),\n\t\t\t\t\t})\n\t\t\t\t}, 100)\n\t\t\t})\n\n\t\t\t// Geolocation is best-effort — a denied permission or an insecure origin\n\t\t\t// must not break the map, so just warn.\n\t\t\tthis.map.on('locationerror', (e) => {\n\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\tconsole.warn('[CnMapWidget] Geolocation unavailable', e && e.message)\n\t\t\t})\n\n\t\t\tthis.renderLayers()\n\t\t\tthis.renderMarkers()\n\t\t\tthis.addControls()\n\n\t\t\t// The container may grow (fill-height layouts) or start hidden behind a\n\t\t\t// view toggle. Re-flow whenever its box actually changes — otherwise\n\t\t\t// Leaflet keeps the stale size it measured at mount and the tiles and\n\t\t\t// markers land in the wrong place.\n\t\t\tif (typeof ResizeObserver !== 'undefined' && this.$refs.mapEl) {\n\t\t\t\tthis.resizeObserver = new ResizeObserver(() => {\n\t\t\t\t\tclearTimeout(this.resizeTimer)\n\t\t\t\t\tthis.resizeTimer = setTimeout(() => {\n\t\t\t\t\t\tthis.refreshMapSize()\n\t\t\t\t\t}, 100)\n\t\t\t\t})\n\t\t\t\tthis.resizeObserver.observe(this.$refs.mapEl)\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * Map ready event. Fired once after Leaflet has loaded and the map is mounted.\n\t\t\t *\n\t\t\t * @event map-ready\n\t\t\t * @type {{map: object}}\n\t\t\t */\n\t\t\tthis.$emit('map-ready', { map: this.map })\n\n\t\t\t// Re-flow size if the container was hidden when first mounted.\n\t\t\tthis.$nextTick(() => {\n\t\t\t\tthis.refreshMapSize()\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Mount the configured `layers[]` onto the Leaflet map. Each entry\n\t\t * dispatches by `type` to a Leaflet factory; unknown types warn and\n\t\t * skip. `geojson` may supply inline `data` to skip the fetch.\n\t\t */\n\t\trenderLayers() {\n\t\t\tconst L = this.L\n\t\t\tif (!this.map || !L) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Tear down old layers\n\t\t\tfor (const layer of this.layerInstances) {\n\t\t\t\tthis.map.removeLayer(layer)\n\t\t\t}\n\t\t\tthis.layerInstances = []\n\t\t\tif (this.layersControl) {\n\t\t\t\tthis.map.removeControl(this.layersControl)\n\t\t\t\tthis.layersControl = null\n\t\t\t}\n\n\t\t\tconst image = this.imageLayer\n\t\t\tif (image) {\n\t\t\t\tthis.renderImageLayer(image)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tthis.renderBasemaps()\n\n\t\t\tfor (const def of this.cfg.layers) {\n\t\t\t\tif (!def || typeof def !== 'object') {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif (def.type === 'image') {\n\t\t\t\t\t// Unusable image layer (no safe url, or no width/height).\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn('[CnMapWidget] Image layer needs a url, width and height; skipping.')\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif (!ALLOWED_LAYER_TYPES.includes(def.type)) {\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn(`[CnMapWidget] Unknown layer type \"${def.type}\", skipping.`)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tconst opts = { ...(def.options || {}) }\n\t\t\t\tif (def.attribution && !opts.attribution) {\n\t\t\t\t\topts.attribution = def.attribution\n\t\t\t\t}\n\n\t\t\t\tlet instance = null\n\t\t\t\tif (def.type === 'tile') {\n\t\t\t\t\tif (typeof def.url !== 'string' || def.url.length === 0) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tinstance = L.tileLayer(def.url, withTileDefaults(opts))\n\t\t\t\t} else if (def.type === 'wms') {\n\t\t\t\t\tif (typeof def.url !== 'string' || def.url.length === 0) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tinstance = L.tileLayer.wms(def.url, withTileDefaults(opts))\n\t\t\t\t} else if (def.type === 'wfs') {\n\t\t\t\t\tif (typeof def.url !== 'string' || def.url.length === 0) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tthis.fetchAndAddGeoJson(def.url, opts)\n\t\t\t\t\tcontinue\n\t\t\t\t} else if (def.type === 'geojson') {\n\t\t\t\t\tif (def.data && typeof def.data === 'object') {\n\t\t\t\t\t\tinstance = L.geoJSON(def.data, opts)\n\t\t\t\t\t} else if (typeof def.url === 'string' && def.url.length > 0) {\n\t\t\t\t\t\tthis.fetchAndAddGeoJson(def.url, opts)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (instance) {\n\t\t\t\t\tinstance.addTo(this.map)\n\t\t\t\t\tthis.layerInstances.push(instance)\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Show the picture: an image overlay on the flat bounds, fitted on open.\n\t\t * Any other layer beside it is skipped with a warning (one picture per map).\n\t\t *\n\t\t * @param {{url: string, width: number, height: number}} image The image layer.\n\t\t */\n\t\trenderImageLayer(image) {\n\t\t\tconst L = this.L\n\t\t\tfor (const def of this.cfg.layers) {\n\t\t\t\tif (def && typeof def === 'object' && def.type !== 'image') {\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn(`[CnMapWidget] Layer type \"${def.type}\" is skipped on an image map.`)\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst bounds = imageBounds(image.width, image.height)\n\t\t\tconst overlay = L.imageOverlay(image.url, bounds)\n\t\t\toverlay.addTo(this.map)\n\t\t\tthis.layerInstances.push(overlay)\n\t\t\tthis.map.fitBounds(bounds)\n\t\t\t// The whole picture is the furthest out; four times native size the furthest in.\n\t\t\tif (typeof this.map.getBoundsZoom === 'function') {\n\t\t\t\tconst fit = this.map.getBoundsZoom(bounds)\n\t\t\t\tif (Number.isFinite(fit)) {\n\t\t\t\t\tif (typeof this.map.setMinZoom === 'function') {\n\t\t\t\t\t\tthis.map.setMinZoom(fit)\n\t\t\t\t\t}\n\t\t\t\t\tif (typeof this.map.setMaxZoom === 'function') {\n\t\t\t\t\t\tthis.map.setMaxZoom(Math.max(2, fit))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Fetch a GeoJSON-shaped resource and add it to the map. Used by\n\t\t * the WFS branch and the geojson-from-url branch.\n\t\t *\n\t\t * @param {string} url Endpoint that returns GeoJSON.\n\t\t * @param {object} opts Leaflet `geoJSON` options.\n\t\t */\n\t\tfetchAndAddGeoJson(url, opts) {\n\t\t\tconst L = this.L\n\t\t\tfetch(prefixUrl(url))\n\t\t\t\t.then((r) => r.json())\n\t\t\t\t.then((json) => {\n\t\t\t\t\tif (!this.map) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconst layer = L.geoJSON(json, opts)\n\t\t\t\t\tlayer.addTo(this.map)\n\t\t\t\t\tthis.layerInstances.push(layer)\n\t\t\t\t})\n\t\t\t\t.catch((err) => {\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn('[CnMapWidget] Failed to load layer', url, err)\n\t\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Mount the configured `markers` onto the Leaflet map. Reads from\n\t\t * `features[]` (inline) OR `dataSource.url` (HTTP fetch). When\n\t\t * `clusteringEnabled`, lazy-loads `leaflet.markercluster` and\n\t\t * groups markers under a single cluster layer.\n\t\t */\n\t\tasync renderMarkers() {\n\t\t\tconst L = this.L\n\t\t\tif (!this.map || !L || !this.cfg.markers) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Tear down previous marker layer\n\t\t\tif (this.markerLayer) {\n\t\t\t\tthis.map.removeLayer(this.markerLayer)\n\t\t\t\tthis.markerLayer = null\n\t\t\t}\n\t\t\tif (this.clusterGroup) {\n\t\t\t\tthis.map.removeLayer(this.clusterGroup)\n\t\t\t\tthis.clusterGroup = null\n\t\t\t}\n\n\t\t\tif (!this.imageLayer) {\n\t\t\t\tthis.unplottedCount = 0\n\t\t\t}\n\t\t\tconst features = await this.collectFeatures()\n\t\t\tif (!features || features.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst layer = L.geoJSON({ type: 'FeatureCollection', features }, {\n\t\t\t\tpointToLayer: (feature, latlng) => {\n\t\t\t\t\tconst color = (feature.properties && feature.properties.markerColor)\n\t\t\t\t\t\t|| (this.cfg.markers && this.cfg.markers.iconColor)\n\t\t\t\t\t\t|| 'var(--color-primary-element, #2196F3)'\n\t\t\t\t\t// A numbered pin, the number the list beside the map shows for the\n\t\t\t\t\t// same row (an index map with `mapConfig.list`); the active row's\n\t\t\t\t\t// pin draws darker.\n\t\t\t\t\tconst number = feature.properties && Number(feature.properties.markerNumber)\n\t\t\t\t\tif (Number.isInteger(number) && number > 0) {\n\t\t\t\t\t\tconst active = feature.properties.markerActive === true\n\t\t\t\t\t\treturn L.marker(latlng, {\n\t\t\t\t\t\t\ticon: L.divIcon({\n\t\t\t\t\t\t\t\tclassName: active ? 'cn-map-widget__pin cn-map-widget__pin--active' : 'cn-map-widget__pin',\n\t\t\t\t\t\t\t\thtml: `<span>${number}</span>`,\n\t\t\t\t\t\t\t\ticonSize: [26, 26],\n\t\t\t\t\t\t\t\ticonAnchor: [13, 13],\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\tif (this.cfg.markers && this.cfg.markers.iconUrl) {\n\t\t\t\t\t\treturn L.marker(latlng, {\n\t\t\t\t\t\t\ticon: L.icon({ iconUrl: this.cfg.markers.iconUrl, iconSize: [25, 41], iconAnchor: [12, 41] }),\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\treturn L.circleMarker(latlng, {\n\t\t\t\t\t\tradius: 8,\n\t\t\t\t\t\tfillColor: color,\n\t\t\t\t\t\tcolor: '#fff',\n\t\t\t\t\t\tweight: 2,\n\t\t\t\t\t\topacity: 1,\n\t\t\t\t\t\tfillOpacity: 0.85,\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t\tonEachFeature: (feature, lyr) => {\n\t\t\t\t\tconst popupField = this.cfg.markers && this.cfg.markers.popupField\n\t\t\t\t\tconst popupHtml = popupField && feature.properties ? feature.properties[popupField] : null\n\t\t\t\t\tif (popupHtml) {\n\t\t\t\t\t\t// Security: marker data may come from an external URL\n\t\t\t\t\t\t// (markers.dataSource.url) or arbitrary GeoJSON feature\n\t\t\t\t\t\t// properties (often an OR-backed data source). Leaflet's\n\t\t\t\t\t\t// bindPopup renders its string argument as HTML (innerHTML),\n\t\t\t\t\t\t// so sanitize with the shared strict config (C1) before\n\t\t\t\t\t\t// injecting — a `<script>` / `onerror=` payload cannot\n\t\t\t\t\t\t// execute in the Nextcloud origin.\n\t\t\t\t\t\tconst safeHtml = DOMPurify.sanitize(String(popupHtml), SAFE_MARKDOWN_DOMPURIFY_CONFIG)\n\t\t\t\t\t\tlyr.bindPopup(safeHtml)\n\t\t\t\t\t}\n\n\t\t\t\t\t// Hover tooltip: a marker with no label is a mystery dot. Name it\n\t\t\t\t\t// with the object's own display name — the backend already derives\n\t\t\t\t\t// one and publishes it as `@self.name`, so a Cow (`name`), a Barn\n\t\t\t\t\t// (`title`), and a Case (`reference`) all read correctly without the\n\t\t\t\t\t// widget knowing the schema. bindTooltip also renders HTML, so\n\t\t\t\t\t// sanitize the same way bindPopup does.\n\t\t\t\t\tconst label = objectDisplayName(feature.properties || {})\n\t\t\t\t\tif (label !== '') {\n\t\t\t\t\t\tconst safeLabel = DOMPurify.sanitize(label, SAFE_MARKDOWN_DOMPURIFY_CONFIG)\n\t\t\t\t\t\tlyr.bindTooltip(safeLabel, { direction: 'top' })\n\t\t\t\t\t}\n\t\t\t\t\tlyr.on('click', (e) => {\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Marker click event. Fired when a marker is clicked.\n\t\t\t\t\t\t *\n\t\t\t\t\t\t * @event marker-click\n\t\t\t\t\t\t * @type {{feature: object, latlng: object}}\n\t\t\t\t\t\t */\n\t\t\t\t\t\tthis.$emit('marker-click', { feature, latlng: e.latlng })\n\t\t\t\t\t})\n\t\t\t\t},\n\t\t\t})\n\n\t\t\tif (this.clusteringEnabled) {\n\t\t\t\ttry {\n\t\t\t\t\t// TWO THINGS HAVE TO BE TRUE BEFORE THIS PLUGIN CAN RUN, AND\n\t\t\t\t\t// NEITHER OF THEM WAS.\n\t\t\t\t\t//\n\t\t\t\t\t// 1. The import has to reach a file. This line used to carry a\n\t\t\t\t\t// `webpackIgnore: true` magic comment, on the stated\n\t\t\t\t\t// reasoning that it stopped a downstream bundler resolving\n\t\t\t\t\t// the specifier against\n\t\t\t\t\t// `node_modules/@conduction/nextcloud-vue/dist/` and failing\n\t\t\t\t\t// with \"Module not found\". That reasoning does not hold, and\n\t\t\t\t\t// the marker is what broke it. `webpackIgnore` does not mean\n\t\t\t\t\t// \"skip the resolution\"; it means \"leave this import for the\n\t\t\t\t\t// BROWSER\", and a browser on a Nextcloud page cannot resolve\n\t\t\t\t\t// a module specifier. Rollup had already rewritten the\n\t\t\t\t\t// specifier in the published ESM build to\n\t\t\t\t\t// `../../node_modules/leaflet.markercluster/dist/\n\t\t\t\t\t// leaflet.markercluster-src.js` and emitted that file inside\n\t\t\t\t\t// the package, so every consumer requested\n\t\t\t\t\t// `node_modules/leaflet.markercluster/...` off the page URL\n\t\t\t\t\t// and got a 404. Clustering has never run in any app.\n\t\t\t\t\t// Without the marker, webpack resolves the relative path\n\t\t\t\t\t// against the file the package actually ships and emits it\n\t\t\t\t\t// as a lazy chunk, so the import stays lazy and clustering\n\t\t\t\t\t// costs nothing to a consumer that does not ask for it.\n\t\t\t\t\t//\n\t\t\t\t\t// 2. The plugin has to find Leaflet. leaflet.markercluster is a\n\t\t\t\t\t// CLASSIC Leaflet plugin: it exports nothing and instead\n\t\t\t\t\t// writes `L.MarkerClusterGroup` onto the GLOBAL `L`. This\n\t\t\t\t\t// widget imports Leaflet as an ES module and keeps it on\n\t\t\t\t\t// `this.L`, so there was no global for the plugin to attach\n\t\t\t\t\t// to, and even a plugin that loaded would have left\n\t\t\t\t\t// `L.markerClusterGroup` undefined. Publishing our own\n\t\t\t\t\t// instance on `window.L` first is what makes the plugin\n\t\t\t\t\t// augment the same object this component then reads.\n\t\t\t\t\t//\n\t\t\t\t\t// An existing `window.L` is left alone: if the page already has\n\t\t\t\t\t// Leaflet, the plugin must extend THAT one, and overwriting it\n\t\t\t\t\t// would break whatever put it there.\n\t\t\t\t\tif (typeof window !== 'undefined' && !window.L) {\n\t\t\t\t\t\twindow.L = L\n\t\t\t\t\t}\n\t\t\t\t\tawait import('leaflet.markercluster')\n\t\t\t\t\tif (typeof L.markerClusterGroup === 'function') {\n\t\t\t\t\t\tthis.clusterGroup = L.markerClusterGroup()\n\t\t\t\t\t\tthis.clusterGroup.addLayer(layer)\n\t\t\t\t\t\tthis.clusterGroup.addTo(this.map)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlayer.addTo(this.map)\n\t\t\t\t\t\tthis.markerLayer = layer\n\t\t\t\t\t}\n\t\t\t\t} catch (err) {\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn('[CnMapWidget] Cluster plugin unavailable', err)\n\t\t\t\t\tlayer.addTo(this.map)\n\t\t\t\t\tthis.markerLayer = layer\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlayer.addTo(this.map)\n\t\t\t\tthis.markerLayer = layer\n\t\t\t}\n\n\t\t\t// Not once per render: a consumer that reloads its markers (a filter,\n\t\t\t// a poll, a save) re-renders them, and fitting again threw away\n\t\t\t// whatever the user had panned or zoomed to. Their framing wins from\n\t\t\t// the moment they touch the map; the Fit all markers control brings\n\t\t\t// this one back on request.\n\t\t\tif (this.cfg.autoFit && !this.userFramedView && !this.imageLayer) {\n\t\t\t\t// Wait a tick so the container has its final box — fill-height layouts\n\t\t\t\t// and the hidden→visible view toggle both settle after render.\n\t\t\t\t// fitToMarkers() then measures before it fits.\n\t\t\t\tthis.$nextTick(() => this.fitToMarkers())\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Mount the switchable base maps and, when more than one is configured, a\n\t\t * Leaflet layer switcher. Falls back to a single OpenStreetMap base map when\n\t\t * the consumer supplied neither `basemaps` nor a `tile` entry in `layers`, so\n\t\t * a map is never left with a blank background.\n\t\t */\n\t\trenderBasemaps() {\n\t\t\tconst L = this.L\n\t\t\tif (!this.map || !L || typeof L.tileLayer !== 'function') {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst basemaps = (this.cfg.basemaps || []).filter((b) => b && typeof b.url === 'string' && b.url.length > 0)\n\t\t\tif (basemaps.length === 0) {\n\t\t\t\t// No explicit basemaps. Unless the consumer draws its own background\n\t\t\t\t// via a `tile`/`wms` entry in `layers`, add a single OpenStreetMap\n\t\t\t\t// base map so the map is never left blank (see DEFAULT_BASEMAP).\n\t\t\t\tconst hasTileLayer = (this.cfg.layers || []).some((l) => l\n\t\t\t\t\t&& (l.type === 'tile' || l.type === 'wms')\n\t\t\t\t\t&& typeof l.url === 'string' && l.url.length > 0)\n\t\t\t\tif (!hasTileLayer) {\n\t\t\t\t\tconst fallback = L.tileLayer(DEFAULT_BASEMAP.url, withTileDefaults({ ...DEFAULT_BASEMAP.options }))\n\t\t\t\t\tfallback.addTo(this.map)\n\t\t\t\t\tthis.layerInstances.push(fallback)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst baseLayers = {}\n\t\t\tbasemaps.forEach((bm, index) => {\n\t\t\t\tconst opts = { ...(bm.options || {}) }\n\t\t\t\tif (bm.attribution && !opts.attribution) {\n\t\t\t\t\topts.attribution = bm.attribution\n\t\t\t\t}\n\t\t\t\tconst instance = L.tileLayer(bm.url, withTileDefaults(opts))\n\t\t\t\tbaseLayers[bm.name || `${index + 1}`] = instance\n\t\t\t\t// Only the first base map is live on load; the switcher swaps in the rest.\n\t\t\t\tif (index === 0) {\n\t\t\t\t\tinstance.addTo(this.map)\n\t\t\t\t}\n\t\t\t\tthis.layerInstances.push(instance)\n\t\t\t})\n\n\t\t\tif (Object.keys(baseLayers).length > 1 && L.control && typeof L.control.layers === 'function') {\n\t\t\t\tthis.layersControl = L.control.layers(baseLayers, {}, { position: 'topright' })\n\t\t\t\tthis.layersControl.addTo(this.map)\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Mount the custom control bar (fit-all / locate / fullscreen). Leaflet's own\n\t\t * zoom + attribution controls are enabled separately in `initMap`.\n\t\t */\n\t\taddControls() {\n\t\t\tconst L = this.L\n\t\t\t// L.Control is absent from lightweight Leaflet stubs (tests); the map is\n\t\t\t// still perfectly usable without the extra bar, so degrade quietly.\n\t\t\tif (!this.map || !L || !L.Control || typeof L.Control.extend !== 'function') {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst buttons = []\n\t\t\tif (this.fitControl) {\n\t\t\t\tbuttons.push({ key: 'fit', title: t('nextcloud-vue', 'Fit all markers'), icon: ICON_FIT, onClick: () => this.fitToMarkers() })\n\t\t\t}\n\t\t\tif (this.locateControl) {\n\t\t\t\tbuttons.push({ key: 'locate', title: t('nextcloud-vue', 'Show my location'), icon: ICON_LOCATE, onClick: () => this.locateMe() })\n\t\t\t}\n\t\t\tif (this.fullscreenControl) {\n\t\t\t\tbuttons.push({ key: 'fullscreen', title: t('nextcloud-vue', 'Toggle fullscreen'), icon: ICON_FULLSCREEN, onClick: () => this.toggleFullscreen() })\n\t\t\t}\n\t\t\tif (buttons.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// `onAdd()` below is invoked by Leaflet with `this` bound to the\n\t\t\t// L.Control instance, so the component has to be captured here to\n\t\t\t// stash the fullscreen button ref on it.\n\t\t\t// eslint-disable-next-line @typescript-eslint/no-this-alias\n\t\t\tconst self = this\n\t\t\tconst ControlBar = L.Control.extend({\n\t\t\t\tonAdd() {\n\t\t\t\t\tconst bar = L.DomUtil.create('div', 'leaflet-bar cn-map-widget__controls')\n\t\t\t\t\tfor (const button of buttons) {\n\t\t\t\t\t\tconst anchor = L.DomUtil.create('a', `cn-map-widget__control cn-map-widget__control--${button.key}`, bar)\n\t\t\t\t\t\tanchor.href = '#'\n\t\t\t\t\t\tanchor.title = button.title\n\t\t\t\t\t\tanchor.setAttribute('role', 'button')\n\t\t\t\t\t\tanchor.setAttribute('aria-label', button.title)\n\t\t\t\t\t\tanchor.innerHTML = controlIcon(button.icon)\n\t\t\t\t\t\tif (button.key === 'fullscreen') {\n\t\t\t\t\t\t\tself.fullscreenButton = anchor\n\t\t\t\t\t\t}\n\t\t\t\t\t\tL.DomEvent.on(anchor, 'click', L.DomEvent.stop).on(anchor, 'click', button.onClick)\n\t\t\t\t\t}\n\t\t\t\t\t// Keep clicks / wheel on the bar from panning or zooming the map.\n\t\t\t\t\tL.DomEvent.disableClickPropagation(bar)\n\t\t\t\t\tL.DomEvent.disableScrollPropagation(bar)\n\t\t\t\t\treturn bar\n\t\t\t\t},\n\t\t\t})\n\n\t\t\tthis.controlBar = new ControlBar({ position: 'topleft' })\n\t\t\tthis.controlBar.addTo(this.map)\n\t\t},\n\n\t\t/**\n\t\t * Arm the hand-over: the next view change the map reports is the user's.\n\t\t */\n\t\tonViewGesture() {\n\t\t\tthis.pendingViewGesture = true\n\t\t},\n\n\t\t/**\n\t\t * Commit an armed gesture once the view has actually moved — and only\n\t\t * then, so a gesture that framed nothing cannot disable `autoFit`.\n\t\t */\n\t\tonViewSettled() {\n\t\t\tif (this.pendingViewGesture) {\n\t\t\t\tthis.pendingViewGesture = false\n\t\t\t\tthis.userFramedView = true\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Re-measure the container. Also drops an armed gesture: a size change is\n\t\t * ours, so the `moveend` it may fire is not the user framing the view.\n\t\t */\n\t\trefreshMapSize() {\n\t\t\tif (!this.map || typeof this.map.invalidateSize !== 'function') {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.pendingViewGesture = false\n\t\t\tthis.map.invalidateSize()\n\t\t},\n\n\t\t/**\n\t\t * Re-centre and re-zoom so every marker is back in view — the same frame\n\t\t * `autoFit` lands on at load. Public: consumers MAY call it through `$refs`.\n\t\t */\n\t\tfitToMarkers() {\n\t\t\tif (!this.map) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (this.imageLayer) {\n\t\t\t\t// On a picture, \"fit\" is the whole picture.\n\t\t\t\tthis.refreshMapSize()\n\t\t\t\tthis.map.fitBounds(imageBounds(this.imageLayer.width, this.imageLayer.height))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst target = this.clusterGroup || this.markerLayer\n\t\t\tif (!target) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst bounds = target.getBounds()\n\t\t\t\tif (!bounds || !bounds.isValid()) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// Measure first — a stale container size yields a wrong fit.\n\t\t\t\tthis.refreshMapSize()\n\t\t\t\tthis.map.fitBounds(bounds, { padding: [50, 50] })\n\t\t\t} catch {\n\t\t\t\t// ignore — empty layer set means no bounds to fit\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Centre the map on the visitor's own position. Failures (denied permission,\n\t\t * insecure origin) surface via the `locationerror` handler in `initMap`.\n\t\t */\n\t\tlocateMe() {\n\t\t\tif (!this.map || typeof this.map.locate !== 'function') {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.map.locate({ setView: true, maxZoom: 16 })\n\t\t},\n\n\t\t/**\n\t\t * Toggle the CSS fullscreen overlay and re-flow Leaflet into the new box.\n\t\t */\n\t\ttoggleFullscreen() {\n\t\t\tthis.isFullscreen = !this.isFullscreen\n\t\t\tif (this.fullscreenButton) {\n\t\t\t\tthis.fullscreenButton.innerHTML = controlIcon(this.isFullscreen ? ICON_FULLSCREEN_EXIT : ICON_FULLSCREEN)\n\t\t\t}\n\t\t\tthis.$nextTick(() => {\n\t\t\t\tthis.refreshMapSize()\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Resolve the features set for the current `markers` config.\n\t\t *\n\t\t * Returns the inline `features[]` when set; otherwise fetches\n\t\t * `dataSource.url` and converts the response into GeoJSON\n\t\t * features. The response MAY be either a GeoJSON\n\t\t * FeatureCollection OR an array of flat rows (in which case\n\t\t * `latField`, `lngField`, `popupField` drive the conversion).\n\t\t *\n\t\t * `dataSource.{register, schema}` plots the objects of an OpenRegister\n\t\t * register/schema (see fetchRegisterFeatures).\n\t\t *\n\t\t * @return {Promise<Array<object>>} GeoJSON Feature array.\n\t\t */\n\t\tasync collectFeatures() {\n\t\t\tif (!this.cfg.markers) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\tif (this.imageLayer) {\n\t\t\t\treturn this.imageFeatures(await this.collectSourceRows())\n\t\t\t}\n\t\t\tlet features = await this.collectSourceFeatures()\n\t\t\t// Optional pin at the configured centre, plotted alongside the object\n\t\t\t// markers (`markers.centerMarker`). Spread into a new array so an inline\n\t\t\t// `features[]` prop is never mutated.\n\t\t\tif (this.cfg.markers.centerMarker) {\n\t\t\t\tconst centre = this.centreMarkerFeature()\n\t\t\t\tif (centre) {\n\t\t\t\t\tfeatures = [...features, centre]\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn features\n\t\t},\n\n\t\t/**\n\t\t * The rows behind an image map's markers: inline features' properties, a\n\t\t * fetched `dataSource.url` (rows, or a FeatureCollection's properties) or an\n\t\t * OpenRegister `dataSource.{register, schema}`.\n\t\t *\n\t\t * @return {Promise<Array<object>>} The rows.\n\t\t */\n\t\tasync collectSourceRows() {\n\t\t\tconst m = this.cfg.markers\n\t\t\tconst fromFeatures = (list) => (Array.isArray(list) ? list.map((f) => (f && f.properties ? f.properties : f)) : [])\n\t\t\tif (Array.isArray(m.features)) {\n\t\t\t\treturn fromFeatures(m.features)\n\t\t\t}\n\t\t\tconst ds = m.dataSource\n\t\t\tif (!ds) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tif (typeof ds.url === 'string' && ds.url.length > 0) {\n\t\t\t\t\tconst json = await (await fetch(prefixUrl(ds.url))).json()\n\t\t\t\t\treturn Array.isArray(json) ? json : fromFeatures(json && json.features)\n\t\t\t\t}\n\t\t\t\tif (ds.register && ds.schema) {\n\t\t\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([import('@nextcloud/axios'), import('@nextcloud/router')])\n\t\t\t\t\tconst url = generateUrl('/apps/openregister/api/objects/{register}/{schema}', { register: ds.register, schema: ds.schema })\n\t\t\t\t\tconst res = await axios.get(url, { params: { _limit: ds.limit || 500 } })\n\t\t\t\t\treturn (res && res.data && res.data.results) || []\n\t\t\t\t}\n\t\t\t} catch (err) {\n\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\tconsole.warn('[CnMapWidget] Failed to load markers', ds, err)\n\t\t\t}\n\t\t\treturn []\n\t\t},\n\n\t\t/**\n\t\t * Place rows on the picture by `xField` / `yField` (default `x` / `y`).\n\t\t * A row without both numbers is not plotted and is counted in the note.\n\t\t *\n\t\t * @param {Array<object>} rows The rows.\n\t\t * @return {Array<object>} GeoJSON Point features in the flat coordinates.\n\t\t */\n\t\timageFeatures(rows) {\n\t\t\tconst xField = (this.cfg.markers && this.cfg.markers.xField) || 'x'\n\t\t\tconst yField = (this.cfg.markers && this.cfg.markers.yField) || 'y'\n\t\t\tconst features = []\n\t\t\tlet skipped = 0\n\t\t\tfor (const row of rows) {\n\t\t\t\tif (!hasImagePosition(row, xField, yField)) {\n\t\t\t\t\tskipped += 1\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tconst point = toImagePoint(row[xField], row[yField])\n\t\t\t\tfeatures.push({\n\t\t\t\t\ttype: 'Feature',\n\t\t\t\t\tgeometry: { type: 'Point', coordinates: [point.lng, point.lat] },\n\t\t\t\t\tproperties: { ...row },\n\t\t\t\t})\n\t\t\t}\n\t\t\tthis.unplottedCount = skipped\n\t\t\treturn features\n\t\t},\n\n\t\t/**\n\t\t * Resolve the marker features from the configured source only —\n\t\t * inline `features[]`, a fetched `dataSource.url`, or an OpenRegister\n\t\t * `dataSource.{register, schema}`. The optional centre pin is layered on\n\t\t * by collectFeatures().\n\t\t *\n\t\t * @return {Promise<Array<object>>} GeoJSON Feature array.\n\t\t */\n\t\tasync collectSourceFeatures() {\n\t\t\tif (Array.isArray(this.cfg.markers.features)) {\n\t\t\t\treturn this.cfg.markers.features\n\t\t\t}\n\t\t\tconst ds = this.cfg.markers.dataSource\n\t\t\tif (!ds) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\tif (typeof ds.url === 'string' && ds.url.length > 0) {\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await fetch(prefixUrl(ds.url))\n\t\t\t\t\tconst json = await response.json()\n\t\t\t\t\treturn this.normaliseFeatures(json)\n\t\t\t\t} catch (err) {\n\t\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\t\tconsole.warn('[CnMapWidget] Failed to fetch markers', ds.url, err)\n\t\t\t\t\treturn []\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (ds.register && ds.schema) {\n\t\t\t\treturn await this.fetchRegisterFeatures(ds)\n\t\t\t}\n\t\t\treturn []\n\t\t},\n\n\t\t/**\n\t\t * Build a GeoJSON point Feature at the resolved centre, or null when the\n\t\t * centre is not a valid `[lat, lng]` pair. GeoJSON coordinates are\n\t\t * `[lng, lat]`, the reverse of the `center` prop's order.\n\t\t *\n\t\t * @return {object|null} the centre Feature, or null.\n\t\t */\n\t\tcentreMarkerFeature() {\n\t\t\tconst c = this.cfg.center\n\t\t\tif (!Array.isArray(c) || c.length !== 2 || !c.every((n) => Number.isFinite(n))) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn {\n\t\t\t\ttype: 'Feature',\n\t\t\t\tgeometry: { type: 'Point', coordinates: [c[1], c[0]] },\n\t\t\t\tproperties: {},\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Plot the objects of an OpenRegister register/schema.\n\t\t *\n\t\t * Each object's location comes from `@self.geo` (where CnObjectGeoWidget writes\n\t\t * it), falling back to plain `latField` / `lngField` properties so a schema that\n\t\t * models coordinates itself still plots. Objects with no usable location are\n\t\t * skipped rather than dropped at (0, 0) — an invisible wrong marker is worse\n\t\t * than an absent one.\n\t\t *\n\t\t * @param {{register: string, schema: string, limit?: number}} ds The data source.\n\t\t * @return {Promise<Array<object>>} GeoJSON Feature array.\n\t\t */\n\t\tasync fetchRegisterFeatures(ds) {\n\t\t\ttry {\n\t\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([\n\t\t\t\t\timport('@nextcloud/axios'),\n\t\t\t\t\timport('@nextcloud/router'),\n\t\t\t\t])\n\t\t\t\tconst url = generateUrl(\n\t\t\t\t\t'/apps/openregister/api/objects/{register}/{schema}',\n\t\t\t\t\t{ register: ds.register, schema: ds.schema },\n\t\t\t\t)\n\t\t\t\tconst res = await axios.get(url, { params: { _limit: ds.limit || 500 } })\n\t\t\t\tconst rows = (res && res.data && res.data.results) || []\n\n\t\t\t\tconst latField = this.cfg.markers && this.cfg.markers.latField\n\t\t\t\tconst lngField = this.cfg.markers && this.cfg.markers.lngField\n\n\t\t\t\treturn rows\n\t\t\t\t\t.map((row) => objectToGeoFeature(row, { latField, lngField }))\n\t\t\t\t\t.filter(Boolean)\n\t\t\t} catch (err) {\n\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\tconsole.warn('[CnMapWidget] Failed to load objects for the map', ds, err)\n\t\t\t\treturn []\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Normalise a fetched payload into a GeoJSON Feature array.\n\t\t * Accepts FeatureCollection, raw Feature[], or flat rows that\n\t\t * declare `latField` / `lngField` (plus optional `popupField`\n\t\t * for popup HTML).\n\t\t *\n\t\t * @param {object|Array} json The fetched payload.\n\t\t * @return {Array<object>} GeoJSON Feature array.\n\t\t */\n\t\tnormaliseFeatures(json) {\n\t\t\tif (!json) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\tif (Array.isArray(json.features)) {\n\t\t\t\treturn json.features\n\t\t\t}\n\t\t\tif (Array.isArray(json)) {\n\t\t\t\tconst latField = (this.cfg.markers && this.cfg.markers.latField) || 'lat'\n\t\t\t\tconst lngField = (this.cfg.markers && this.cfg.markers.lngField) || 'lng'\n\t\t\t\treturn json\n\t\t\t\t\t.filter((row) => row !== null && row !== undefined && Number.isFinite(row[latField]) && Number.isFinite(row[lngField]))\n\t\t\t\t\t.map((row) => ({\n\t\t\t\t\t\ttype: 'Feature',\n\t\t\t\t\t\tgeometry: { type: 'Point', coordinates: [row[lngField], row[latField]] },\n\t\t\t\t\t\tproperties: { ...row },\n\t\t\t\t\t}))\n\t\t\t}\n\t\t\treturn []\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-map-widget {\n\tposition: relative;\n\twidth: 100%;\n\tmin-height: 200px;\n}\n\n.cn-map-widget__leaflet {\n\twidth: 100%;\n\theight: 100%;\n\t/* Leaflet paints its container #ddd, which reads as a white flash before the\n\t tiles arrive — and a bright one in a dark theme. */\n\tbackground: var(--color-background-dark);\n}\n\n.cn-map-widget__note {\n\tmargin: 4px 0 0;\n\tfont-size: 13px;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-map-widget__fallback {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\theight: 100%;\n\tmin-height: 150px;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n.cn-map-widget__error {\n\tfont-size: 14px;\n\tmargin: 0;\n}\n\n.cn-map-widget__legend {\n\tposition: absolute;\n\ttop: 12px;\n\tright: 12px;\n\tz-index: 1000;\n\tbackground: var(--color-main-background);\n\tpadding: 8px 12px;\n\tborder-radius: var(--border-radius-large, 8px);\n\tbox-shadow: 0 2px 8px rgba(0, 0, 0, .15);\n}\n\n/* Fullscreen overlay. A CSS-fixed box rather than the Fullscreen API, so it needs\n no permission prompt; the z-index clears the Nextcloud header. */\n.cn-map-widget--fullscreen {\n\tposition: fixed;\n\ttop: 0;\n\tleft: 0;\n\twidth: 100vw;\n\theight: 100vh;\n\tz-index: 3000;\n}\n\n/* Custom control bar (fit / locate / fullscreen), themed to Nextcloud rather than\n Leaflet's default white-on-grey buttons.\n ::v-deep is required — Leaflet builds the control DOM via L.DomUtil.create, so\n those nodes never receive this SFC's scoped data-v attribute. */\n.cn-map-widget ::v-deep .cn-map-widget__control {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\twidth: 30px;\n\theight: 30px;\n\tcolor: var(--color-main-text);\n\tbackground: var(--color-main-background);\n}\n\n.cn-map-widget ::v-deep .cn-map-widget__control:hover,\n.cn-map-widget ::v-deep .cn-map-widget__control:focus-visible {\n\tbackground: var(--color-background-hover);\n}\n</style>\n\n<style>\n/* A numbered pin (`properties.markerNumber`): a 26px primary circle with its\n number in white, the active one darker. Unscoped, because Leaflet creates\n the marker element outside the template. */\n.cn-map-widget__pin {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tborder: 2px solid var(--color-main-background);\n\tborder-radius: 50%;\n\tbackground: var(--color-primary-element);\n\tcolor: var(--color-primary-element-text);\n\tfont-size: 13px;\n\tfont-weight: 700;\n\tbox-sizing: border-box;\n}\n\n.cn-map-widget__pin--active {\n\tbackground: var(--color-primary-element-light-text);\n}\n</style>\n"],"names":["_normalizeClass"],"mappings":";;;;;;;;;;;;;;;;;;;;IAsEE,KAAI,EAAAA,cAAA,CAAA,CAAA,eAAA,EAAA,EAAA,2BAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
|
@@ -2,7 +2,7 @@ import script from './CnMenuWidget.vue2.js';
|
|
|
2
2
|
import { render } from './CnMenuWidget.vue3.js';
|
|
3
3
|
|
|
4
4
|
script.render = render;
|
|
5
|
-
script.__scopeId = "data-v-
|
|
5
|
+
script.__scopeId = "data-v-2edac2f0";
|
|
6
6
|
const __sfc_main = script;
|
|
7
7
|
|
|
8
8
|
export { __sfc_main as default };
|
|
@@ -35,6 +35,11 @@ var script = {
|
|
|
35
35
|
CnMenuItemIcon: __sfc_main$1,
|
|
36
36
|
},
|
|
37
37
|
|
|
38
|
+
inject: {
|
|
39
|
+
/** Host translate function provided by CnAppRoot, for the item labels. */
|
|
40
|
+
cnTranslate: { default: () => (key) => key },
|
|
41
|
+
},
|
|
42
|
+
|
|
38
43
|
props: {
|
|
39
44
|
/**
|
|
40
45
|
* Persisted widget content `{items, style, orientation, showIcons,
|
|
@@ -84,7 +89,26 @@ var script = {
|
|
|
84
89
|
*/
|
|
85
90
|
items() {
|
|
86
91
|
const arr = this.content?.items;
|
|
87
|
-
|
|
92
|
+
if (!Array.isArray(arr)) {
|
|
93
|
+
return []
|
|
94
|
+
}
|
|
95
|
+
// Labels are manifest strings: they go through the app's translate,
|
|
96
|
+
// children included, so "Case types" reads "Zaaktypen" (DqCatalogus).
|
|
97
|
+
const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key;
|
|
98
|
+
const translated = (list) => list.map((item) => {
|
|
99
|
+
if (!item || typeof item !== 'object') {
|
|
100
|
+
return item
|
|
101
|
+
}
|
|
102
|
+
const out = { ...item };
|
|
103
|
+
if (typeof item.label === 'string' && item.label !== '') {
|
|
104
|
+
out.label = translate(item.label);
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(item.children)) {
|
|
107
|
+
out.children = translated(item.children);
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
});
|
|
111
|
+
return translated(arr)
|
|
88
112
|
},
|
|
89
113
|
|
|
90
114
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CnMenuWidget.vue2.js","sources":["../../../../src/components/CnMenuWidget/CnMenuWidget.vue"],"sourcesContent":["<!--\n - SPDX-FileCopyrightText: 2026 Conduction B.V.\n - SPDX-License-Identifier: EUPL-1.2\n-->\n\n<template>\n\t<div\n\t\tclass=\"cn-menu-widget\"\n\t\t:class=\"rootClasses\"\n\t\t@keydown.esc.stop=\"closeAll\">\n\t\t<!-- Empty state -->\n\t\t<div\n\t\t\tv-if=\"isEmpty\"\n\t\t\tclass=\"cn-menu-widget__empty\"\n\t\t\t:class=\"{ 'cn-menu-widget__empty--clickable': isInEditMode }\"\n\t\t\t:role=\"isInEditMode ? 'button' : null\"\n\t\t\t:tabindex=\"isInEditMode ? 0 : -1\"\n\t\t\t@click=\"onEmptyStateClick\"\n\t\t\t@keyup.enter=\"onEmptyStateClick\"\n\t\t\t@keyup.space=\"onEmptyStateClick\">\n\t\t\t<span class=\"cn-menu-widget__empty-icon\" aria-hidden=\"true\">⚙</span>\n\t\t\t<span>{{ t('nextcloud-vue', 'No menu items yet — click the gear icon to add some.') }}</span>\n\t\t</div>\n\n\t\t<!-- Tree style -->\n\t\t<ul\n\t\t\tv-else-if=\"effectiveStyle === 'tree'\"\n\t\t\tclass=\"cn-menu-widget__tree\"\n\t\t\trole=\"tree\">\n\t\t\t<CnMenuTreeNode\n\t\t\t\tv-for=\"(item, idx) in items\"\n\t\t\t\t:key=\"`tree-${idx}`\"\n\t\t\t\t:item=\"item\"\n\t\t\t\t:depth=\"1\"\n\t\t\t\t:showIcons=\"showIcons\"\n\t\t\t\t:expandedByDefault=\"expandedByDefault\"\n\t\t\t\t:activePath=\"activePath\"\n\t\t\t\t:activeLeafKey=\"activeLeafKey\"\n\t\t\t\t:currentKey=\"`${idx}`\"\n\t\t\t\t:activeHighlight=\"activeItemHighlight\"\n\t\t\t\t@navigate=\"onTreeNavigate\" />\n\t\t</ul>\n\n\t\t<!-- Megamenu style -->\n\t\t<div\n\t\t\tv-else-if=\"effectiveStyle === 'megamenu'\"\n\t\t\tclass=\"cn-menu-widget__megamenu\">\n\t\t\t<ul\n\t\t\t\tclass=\"cn-menu-widget__bar\"\n\t\t\t\trole=\"menubar\">\n\t\t\t\t<li\n\t\t\t\t\tv-for=\"(item, idx) in items\"\n\t\t\t\t\t:key=\"`mega-top-${idx}`\"\n\t\t\t\t\trole=\"none\"\n\t\t\t\t\tclass=\"cn-menu-widget__bar-item\">\n\t\t\t\t\t<component\n\t\t\t\t\t\t:is=\"isTopLink(item) ? 'a' : 'button'\"\n\t\t\t\t\t\t:ref=\"(el) => setTopRef(el, idx)\"\n\t\t\t\t\t\tv-bind=\"isTopLink(item) ? linkAttrs(item) : { type: 'button' }\"\n\t\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\t\tclass=\"cn-menu-widget__bar-button\"\n\t\t\t\t\t\t:class=\"topItemClass(`${idx}`)\"\n\t\t\t\t\t\t:aria-haspopup=\"hasChildren(item) ? 'menu' : null\"\n\t\t\t\t\t\t:aria-expanded=\"megaOpenIndex === idx ? 'true' : 'false'\"\n\t\t\t\t\t\t@click=\"isTopLink(item) ? onLinkClick($event, item) : onMegaTopClick(idx, item)\"\n\t\t\t\t\t\t@keydown=\"onMegaTopKey($event, idx, item)\">\n\t\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !item.icon }\">\n\t\t\t\t\t\t\t<CnMenuItemIcon v-if=\"item.icon\" :icon=\"item.icon\" />\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ item.label }}</span>\n\t\t\t\t\t</component>\n\t\t\t\t</li>\n\t\t\t</ul>\n\t\t\t<div\n\t\t\t\tv-if=\"megaOpenIndex !== null && hasChildren(items[megaOpenIndex])\"\n\t\t\t\tref=\"megaPanel\"\n\t\t\t\tclass=\"cn-menu-widget__mega-panel\"\n\t\t\t\trole=\"menu\">\n\t\t\t\t<div\n\t\t\t\t\tv-for=\"(child, childIdx) in items[megaOpenIndex].children\"\n\t\t\t\t\t:key=\"`mega-group-${childIdx}`\"\n\t\t\t\t\tclass=\"cn-menu-widget__mega-group\">\n\t\t\t\t\t<component\n\t\t\t\t\t\t:is=\"isLink(child) ? 'a' : 'button'\"\n\t\t\t\t\t\tv-bind=\"itemAttrs(child)\"\n\t\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\t\tclass=\"cn-menu-widget__mega-group-title\"\n\t\t\t\t\t\t:class=\"topItemClass(`${megaOpenIndex}.${childIdx}`)\"\n\t\t\t\t\t\t@click=\"onLinkClick($event, child)\">\n\t\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !child.icon }\">\n\t\t\t\t\t\t\t<CnMenuItemIcon v-if=\"child.icon\" :icon=\"child.icon\" />\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ child.label }}</span>\n\t\t\t\t\t</component>\n\t\t\t\t\t<ul\n\t\t\t\t\t\tv-if=\"hasChildren(child)\"\n\t\t\t\t\t\tclass=\"cn-menu-widget__mega-leaves\">\n\t\t\t\t\t\t<li\n\t\t\t\t\t\t\tv-for=\"(leaf, leafIdx) in child.children\"\n\t\t\t\t\t\t\t:key=\"`mega-leaf-${leafIdx}`\"\n\t\t\t\t\t\t\trole=\"none\">\n\t\t\t\t\t\t\t<component\n\t\t\t\t\t\t\t\t:is=\"isLink(leaf) ? 'a' : 'button'\"\n\t\t\t\t\t\t\t\tv-bind=\"itemAttrs(leaf)\"\n\t\t\t\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\t\t\t\tclass=\"cn-menu-widget__mega-leaf\"\n\t\t\t\t\t\t\t\t:class=\"topItemClass(`${megaOpenIndex}.${childIdx}.${leafIdx}`)\"\n\t\t\t\t\t\t\t\t@click=\"onLinkClick($event, leaf)\">\n\t\t\t\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !leaf.icon }\">\n\t\t\t\t\t\t\t\t\t<CnMenuItemIcon v-if=\"leaf.icon\" :icon=\"leaf.icon\" />\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ leaf.label }}</span>\n\t\t\t\t\t\t\t</component>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t</ul>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<!-- Dropdown style (default) -->\n\t\t<ul\n\t\t\tv-else\n\t\t\tclass=\"cn-menu-widget__bar\"\n\t\t\t:class=\"{ 'cn-menu-widget__bar--vertical': orientation === 'vertical' }\"\n\t\t\trole=\"menubar\">\n\t\t\t<li\n\t\t\t\tv-for=\"(item, idx) in items\"\n\t\t\t\t:key=\"`drop-top-${idx}`\"\n\t\t\t\t:ref=\"(el) => setBarItemRef(el, idx)\"\n\t\t\t\trole=\"none\"\n\t\t\t\tclass=\"cn-menu-widget__bar-item\">\n\t\t\t\t<component\n\t\t\t\t\t:is=\"isTopLink(item) ? 'a' : 'button'\"\n\t\t\t\t\t:ref=\"(el) => setTopRef(el, idx)\"\n\t\t\t\t\tv-bind=\"isTopLink(item) ? linkAttrs(item) : { type: 'button' }\"\n\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\tclass=\"cn-menu-widget__bar-button\"\n\t\t\t\t\t:class=\"topItemClass(`${idx}`)\"\n\t\t\t\t\t:aria-haspopup=\"hasChildren(item) ? 'menu' : null\"\n\t\t\t\t\t:aria-expanded=\"dropOpenIndex === idx ? 'true' : 'false'\"\n\t\t\t\t\t@click=\"isTopLink(item) ? onLinkClick($event, item) : onDropdownTopClick(idx, item)\"\n\t\t\t\t\t@keydown.tab=\"closeAll\"\n\t\t\t\t\t@keydown.enter=\"onDropdownTopEnter($event, idx, item)\"\n\t\t\t\t\t@keydown.space.prevent=\"onDropdownTopClick(idx, item)\"\n\t\t\t\t\t@keydown.down.prevent=\"openDropdown(idx)\">\n\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !item.icon }\">\n\t\t\t\t\t\t<CnMenuItemIcon v-if=\"item.icon\" :icon=\"item.icon\" />\n\t\t\t\t\t</span>\n\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ item.label }}</span>\n\t\t\t\t\t<span v-if=\"hasChildren(item)\" class=\"cn-menu-widget__caret\" aria-hidden=\"true\">▾</span>\n\t\t\t\t</component>\n\t\t\t\t<ul\n\t\t\t\t\tv-if=\"dropOpenIndex === idx && hasChildren(item)\"\n\t\t\t\t\tclass=\"cn-menu-widget__dropdown\"\n\t\t\t\t\trole=\"menu\">\n\t\t\t\t\t<li\n\t\t\t\t\t\tv-for=\"(child, childIdx) in item.children\"\n\t\t\t\t\t\t:key=\"`drop-child-${childIdx}`\"\n\t\t\t\t\t\trole=\"none\"\n\t\t\t\t\t\tclass=\"cn-menu-widget__dropdown-item-wrap\"\n\t\t\t\t\t\t@mouseenter=\"hasChildren(child) && (flyoutOpenKey = `${idx}.${childIdx}`)\">\n\t\t\t\t\t\t<component\n\t\t\t\t\t\t\t:is=\"isLink(child) ? 'a' : 'button'\"\n\t\t\t\t\t\t\tv-bind=\"itemAttrs(child)\"\n\t\t\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\t\t\tclass=\"cn-menu-widget__dropdown-item\"\n\t\t\t\t\t\t\t:class=\"topItemClass(`${idx}.${childIdx}`)\"\n\t\t\t\t\t\t\t@click=\"onLinkClick($event, child)\">\n\t\t\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !child.icon }\">\n\t\t\t\t\t\t\t\t<CnMenuItemIcon v-if=\"child.icon\" :icon=\"child.icon\" />\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ child.label }}</span>\n\t\t\t\t\t\t\t<span v-if=\"hasChildren(child)\" class=\"cn-menu-widget__caret cn-menu-widget__caret--right\" aria-hidden=\"true\">▸</span>\n\t\t\t\t\t\t</component>\n\t\t\t\t\t\t<ul\n\t\t\t\t\t\t\tv-if=\"flyoutOpenKey === `${idx}.${childIdx}` && hasChildren(child)\"\n\t\t\t\t\t\t\tclass=\"cn-menu-widget__flyout\"\n\t\t\t\t\t\t\trole=\"menu\">\n\t\t\t\t\t\t\t<li\n\t\t\t\t\t\t\t\tv-for=\"(leaf, leafIdx) in child.children\"\n\t\t\t\t\t\t\t\t:key=\"`drop-leaf-${leafIdx}`\"\n\t\t\t\t\t\t\t\trole=\"none\">\n\t\t\t\t\t\t\t\t<component\n\t\t\t\t\t\t\t\t\t:is=\"isLink(leaf) ? 'a' : 'button'\"\n\t\t\t\t\t\t\t\t\tv-bind=\"itemAttrs(leaf)\"\n\t\t\t\t\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\t\t\t\t\tclass=\"cn-menu-widget__dropdown-item\"\n\t\t\t\t\t\t\t\t\t:class=\"topItemClass(`${idx}.${childIdx}.${leafIdx}`)\"\n\t\t\t\t\t\t\t\t\t@click=\"onLinkClick($event, leaf)\">\n\t\t\t\t\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !leaf.icon }\">\n\t\t\t\t\t\t\t\t\t\t<CnMenuItemIcon v-if=\"leaf.icon\" :icon=\"leaf.icon\" />\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ leaf.label }}</span>\n\t\t\t\t\t\t\t\t</component>\n\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t</ul>\n\t\t\t\t\t</li>\n\t\t\t\t</ul>\n\t\t\t</li>\n\t\t</ul>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport CnMenuItemIcon from './CnMenuItemIcon.vue'\nimport CnMenuTreeNode from './CnMenuTreeNode.vue'\nimport { followLinkClick } from '../../utils/linkNavigation.js'\nimport { computeActivePath, isActiveItem, isRouterPath, menuLinkAttrs } from './menuActive.js'\n\nconst VALID_STYLES = ['dropdown', 'megamenu', 'tree']\nconst VALID_ORIENTATIONS = ['horizontal', 'vertical']\nconst VALID_HIGHLIGHTS = ['background', 'underline', 'left-bar', 'none']\n\n/**\n * CnMenuWidget — renders a hierarchical navigation widget with three visual\n * styles (dropdown, megamenu, tree) and active-item highlighting based on\n * `window.location.pathname`.\n *\n * Active-item detection runs on mount and whenever `items` or the URL change;\n * ancestors of the active leaf are flagged \"in-path\" so the configured\n * highlight (`underline`, `background`, `left-bar`, `none`) renders\n * consistently. Keyboard navigation follows the WAI-ARIA Menu/Menubar pattern.\n *\n * Open dropdown/megamenu panels close on: re-clicking the toggle, Escape,\n * Tab, navigating a leaf, and clicking ANYWHERE else — including empty space\n * elsewhere in the widget itself, not just outside it entirely (a\n * document-level listener that checks containment against the open toggle\n * button + its own panel specifically, since the panels are plain in-DOM\n * markup, not a teleported popover with its own outside-click handling).\n *\n * @spec openspec/changes/cn-widget-library/specs/cn-widget-library/spec.md\n */\nexport default {\n\tname: 'CnMenuWidget',\n\n\tcomponents: {\n\t\tCnMenuTreeNode,\n\t\tCnMenuItemIcon,\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * Persisted widget content `{items, style, orientation, showIcons,\n\t\t * expandedByDefault, activeItemHighlight}`.\n\t\t *\n\t\t * @type {object}\n\t\t */\n\t\tcontent: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/** Whether the current user is an admin (pairs with `canEdit`). */\n\t\tisAdmin: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Whether the surrounding shell is in edit mode. */\n\t\tcanEdit: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\t},\n\n\temits: [\n\t\t/** Fired when the empty-state gear is clicked in edit mode. */\n\t\t'edit-request',\n\t],\n\n\tdata() {\n\t\treturn {\n\t\t\tdropOpenIndex: null,\n\t\t\tflyoutOpenKey: null,\n\t\t\tmegaOpenIndex: null,\n\t\t\tcurrentLocation: this.readLocation(),\n\t\t\ttopRefs: [],\n\t\t\tbarItemRefs: [],\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The configured menu items.\n\t\t *\n\t\t * @return {object[]} the items array.\n\t\t */\n\t\titems() {\n\t\t\tconst arr = this.content?.items\n\t\t\treturn Array.isArray(arr) ? arr : []\n\t\t},\n\n\t\t/**\n\t\t * Whether the menu has no items.\n\t\t *\n\t\t * @return {boolean} true when empty.\n\t\t */\n\t\tisEmpty() {\n\t\t\treturn this.items.length === 0\n\t\t},\n\n\t\t/**\n\t\t * The validated visual style (default `dropdown`).\n\t\t *\n\t\t * @return {string} one of dropdown/megamenu/tree.\n\t\t */\n\t\teffectiveStyle() {\n\t\t\tconst s = this.content?.style\n\t\t\treturn VALID_STYLES.includes(s) ? s : 'dropdown'\n\t\t},\n\n\t\t/**\n\t\t * The validated orientation (tree is always vertical).\n\t\t *\n\t\t * @return {string} `horizontal` or `vertical`.\n\t\t */\n\t\torientation() {\n\t\t\tconst o = this.content?.orientation\n\t\t\tif (this.effectiveStyle === 'tree') {\n\t\t\t\treturn 'vertical'\n\t\t\t}\n\t\t\treturn VALID_ORIENTATIONS.includes(o) ? o : 'horizontal'\n\t\t},\n\n\t\t/**\n\t\t * Whether item icons render (default true).\n\t\t *\n\t\t * @return {boolean} the showIcons flag.\n\t\t */\n\t\tshowIcons() {\n\t\t\treturn this.content?.showIcons !== false\n\t\t},\n\n\t\t/**\n\t\t * Whether tree nodes start expanded.\n\t\t *\n\t\t * @return {boolean} the expandedByDefault flag.\n\t\t */\n\t\texpandedByDefault() {\n\t\t\treturn this.content?.expandedByDefault === true\n\t\t},\n\n\t\t/**\n\t\t * The validated active-item highlight style (default `underline`).\n\t\t *\n\t\t * @return {string} one of background/underline/left-bar/none.\n\t\t */\n\t\tactiveItemHighlight() {\n\t\t\tconst h = this.content?.activeItemHighlight\n\t\t\treturn VALID_HIGHLIGHTS.includes(h) ? h : 'underline'\n\t\t},\n\n\t\t/**\n\t\t * Whether the widget is in edit mode (empty-state gear clickable).\n\t\t *\n\t\t * @return {boolean} true when admin + canEdit.\n\t\t */\n\t\tisInEditMode() {\n\t\t\treturn this.isAdmin === true && this.canEdit === true\n\t\t},\n\n\t\t/**\n\t\t * The root style/highlight/orientation CSS classes.\n\t\t *\n\t\t * @return {object} the class map.\n\t\t */\n\t\trootClasses() {\n\t\t\treturn {\n\t\t\t\t[`cn-menu-widget--style-${this.effectiveStyle}`]: true,\n\t\t\t\t[`cn-menu-widget--highlight-${this.activeItemHighlight}`]: true,\n\t\t\t\t[`cn-menu-widget--orientation-${this.orientation}`]: true,\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * The dotted-key path map for the current location.\n\t\t *\n\t\t * @return {{path: object, leafKey: string|null}} the active-path map.\n\t\t */\n\t\tactivePathMap() {\n\t\t\treturn computeActivePath({\n\t\t\t\titems: this.items,\n\t\t\t\tcurrentLocation: this.currentLocation,\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The dotted-key → state map.\n\t\t *\n\t\t * @return {Record<string, string>} the path map.\n\t\t */\n\t\tactivePath() {\n\t\t\treturn this.activePathMap.path\n\t\t},\n\n\t\t/**\n\t\t * The key of the deepest active leaf.\n\t\t *\n\t\t * @return {string|null} the leaf key.\n\t\t */\n\t\tactiveLeafKey() {\n\t\t\treturn this.activePathMap.leafKey\n\t\t},\n\t},\n\n\tmounted() {\n\t\tthis.boundLocationListener = () => {\n\t\t\tthis.currentLocation = this.readLocation()\n\t\t}\n\t\tif (typeof window !== 'undefined') {\n\t\t\twindow.addEventListener('popstate', this.boundLocationListener)\n\t\t\twindow.addEventListener('hashchange', this.boundLocationListener)\n\t\t}\n\t\tthis.boundOutsideClickListener = this.onOutsideClick\n\t\tif (typeof document !== 'undefined') {\n\t\t\tdocument.addEventListener('click', this.boundOutsideClickListener)\n\t\t}\n\t},\n\n\tbeforeUnmount() {\n\t\tif (typeof window !== 'undefined' && this.boundLocationListener) {\n\t\t\twindow.removeEventListener('popstate', this.boundLocationListener)\n\t\t\twindow.removeEventListener('hashchange', this.boundLocationListener)\n\t\t}\n\t\tif (typeof document !== 'undefined' && this.boundOutsideClickListener) {\n\t\t\tdocument.removeEventListener('click', this.boundOutsideClickListener)\n\t\t}\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Read the current browser location into a small bag.\n\t\t *\n\t\t * @return {{pathname: string, host: string}} the location bag.\n\t\t */\n\t\treadLocation() {\n\t\t\tif (typeof window === 'undefined' || !window.location) {\n\t\t\t\treturn { pathname: '/', host: '' }\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tpathname: window.location.pathname || '/',\n\t\t\t\thost: window.location.host || '',\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Stash a top-level button ref by index.\n\t\t *\n\t\t * @param {HTMLElement} el the button element.\n\t\t * @param {number} idx the item index.\n\t\t * @return {void}\n\t\t */\n\t\tsetTopRef(el, idx) {\n\t\t\tif (el) {\n\t\t\t\tthis.topRefs[idx] = el\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Stash a dropdown-style top-level `<li>` ref by index. This `<li>`\n\t\t * contains both the toggle button and its dropdown/flyout panel, so a\n\t\t * single containment check against it covers the whole open structure.\n\t\t *\n\t\t * @param {HTMLElement} el the `<li>` element.\n\t\t * @param {number} idx the item index.\n\t\t * @return {void}\n\t\t */\n\t\tsetBarItemRef(el, idx) {\n\t\t\tif (el) {\n\t\t\t\tthis.barItemRefs[idx] = el\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Whether an item has children.\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {boolean} true when it has a non-empty children array.\n\t\t */\n\t\thasChildren(item) {\n\t\t\treturn item && Array.isArray(item.children) && item.children.length > 0\n\t\t},\n\n\t\t/**\n\t\t * The active-state class for a dotted key.\n\t\t *\n\t\t * @param {string} key the dotted item key.\n\t\t * @return {string} the class name (or `''`).\n\t\t */\n\t\ttopItemClass(key) {\n\t\t\tconst state = this.activePath[key]\n\t\t\tif (state === 'active' || key === this.activeLeafKey) {\n\t\t\t\treturn 'cn-menu-widget__item--active'\n\t\t\t}\n\t\t\tif (state === 'in-path') {\n\t\t\t\treturn 'cn-menu-widget__item--in-path'\n\t\t\t}\n\t\t\treturn ''\n\t\t},\n\n\t\t/**\n\t\t * Top-level dropdown click — navigate a leaf or toggle the dropdown.\n\t\t *\n\t\t * @param {number} idx the item index.\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonDropdownTopClick(idx, item) {\n\t\t\tif (!this.hasChildren(item)) {\n\t\t\t\tthis.onNavigate(item)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (this.dropOpenIndex === idx) {\n\t\t\t\tthis.dropOpenIndex = null\n\t\t\t\tthis.flyoutOpenKey = null\n\t\t\t} else {\n\t\t\t\tthis.dropOpenIndex = idx\n\t\t\t\tthis.flyoutOpenKey = null\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Open the dropdown at an index when it has children.\n\t\t *\n\t\t * @param {number} idx the item index.\n\t\t * @return {void}\n\t\t */\n\t\topenDropdown(idx) {\n\t\t\tif (this.hasChildren(this.items[idx])) {\n\t\t\t\tthis.dropOpenIndex = idx\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Top-level megamenu click — navigate a leaf or toggle the panel.\n\t\t *\n\t\t * @param {number} idx the item index.\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonMegaTopClick(idx, item) {\n\t\t\tif (!this.hasChildren(item)) {\n\t\t\t\tthis.onNavigate(item)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.megaOpenIndex = this.megaOpenIndex === idx ? null : idx\n\t\t},\n\n\t\t/**\n\t\t * Keyboard handler for megamenu top items.\n\t\t *\n\t\t * @param {KeyboardEvent} event the key event.\n\t\t * @param {number} idx the item index.\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonMegaTopKey(event, idx, item) {\n\t\t\t// Enter on a link clicks it natively.\n\t\t\tif (event.key === 'Enter' && this.isTopLink(item)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {\n\t\t\t\tevent.preventDefault()\n\t\t\t\tthis.onMegaTopClick(idx, item)\n\t\t\t} else if (event.key === 'Tab') {\n\t\t\t\tthis.megaOpenIndex = null\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Close every open dropdown/flyout/panel.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\tcloseAll() {\n\t\t\tthis.dropOpenIndex = null\n\t\t\tthis.flyoutOpenKey = null\n\t\t\tthis.megaOpenIndex = null\n\t\t},\n\n\t\t/**\n\t\t * Document-level click handler — closes any open dropdown/flyout/panel\n\t\t * when the click lands outside its own toggle button + panel, INCLUDING\n\t\t * empty space elsewhere in the widget (e.g. bar padding, gaps between\n\t\t * items) — not just outside the whole widget. The toggle button's own\n\t\t * `@click` handler manages opening/closing for its own clicks, so a\n\t\t * click still inside the open button/panel is left alone — otherwise\n\t\t * every open-toggle click would immediately re-close itself via this\n\t\t * same listener.\n\t\t *\n\t\t * @param {MouseEvent} event the document click event.\n\t\t * @return {void}\n\t\t */\n\t\tonOutsideClick(event) {\n\t\t\tconst target = event.target\n\t\t\tif (this.effectiveStyle === 'megamenu') {\n\t\t\t\tif (this.megaOpenIndex === null) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tconst button = this.topRefs[this.megaOpenIndex]\n\t\t\t\tconst panel = this.$refs.megaPanel\n\t\t\t\tconst insideButton = Boolean(button && button.contains(target))\n\t\t\t\tconst insidePanel = Boolean(panel && panel.contains(target))\n\t\t\t\tif (!insideButton && !insidePanel) {\n\t\t\t\t\tthis.closeAll()\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Dropdown style — the button and its dropdown/flyout panel share\n\t\t\t// the same top-level `<li>`, so one containment check covers both.\n\t\t\tif (this.dropOpenIndex === null) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst barItem = this.barItemRefs[this.dropOpenIndex]\n\t\t\tif (barItem && !barItem.contains(target)) {\n\t\t\t\tthis.closeAll()\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Whether an item renders as a link (it has a URL).\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {boolean} true when the item carries a non-empty URL.\n\t\t */\n\t\tisLink(item) {\n\t\t\treturn Boolean(item) && typeof item.url === 'string' && item.url !== ''\n\t\t},\n\n\t\t/**\n\t\t * Whether a top-level item renders as a link. One with children\n\t\t * stays a button, because its click toggles the panel.\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {boolean} true for a top-level leaf with a URL.\n\t\t */\n\t\tisTopLink(item) {\n\t\t\treturn this.isLink(item) && !this.hasChildren(item)\n\t\t},\n\n\t\t/**\n\t\t * Anchor attributes for an item's URL: external URLs open in a new tab.\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {object} the `href` (plus `target` / `rel` for external URLs).\n\t\t */\n\t\tlinkAttrs(item) {\n\t\t\treturn menuLinkAttrs(item.url, this.$router)\n\t\t},\n\n\t\t/**\n\t\t * Attributes for a panel item: link attributes, or `type=\"button\"`.\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {object} the element attributes.\n\t\t */\n\t\titemAttrs(item) {\n\t\t\treturn this.isLink(item) ? this.linkAttrs(item) : { type: 'button' }\n\t\t},\n\n\t\t/**\n\t\t * Click on an item link: close the menus, then let the browser open\n\t\t * an external URL or a modified click, and route a plain click on an\n\t\t * internal one. A no-op for an item without a URL.\n\t\t *\n\t\t * @param {MouseEvent} event the click event.\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonLinkClick(event, item) {\n\t\t\tif (!this.isLink(item)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.closeAll()\n\t\t\tif (isRouterPath(item.url)) {\n\t\t\t\tfollowLinkClick(event, item.url, this.$router)\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Enter on a dropdown top item: a link follows natively (Enter\n\t\t * clicks it); a button toggles or navigates.\n\t\t *\n\t\t * @param {KeyboardEvent} event the key event.\n\t\t * @param {number} idx the item index.\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonDropdownTopEnter(event, idx, item) {\n\t\t\tif (this.isTopLink(item)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tevent.preventDefault()\n\t\t\tthis.onDropdownTopClick(idx, item)\n\t\t},\n\n\t\t/**\n\t\t * Navigation request from a tree node: a link click goes through the\n\t\t * link handler, a request without an event navigates directly.\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @param {MouseEvent} [event] the click event, when the node's link was clicked.\n\t\t * @return {void}\n\t\t */\n\t\tonTreeNavigate(item, event) {\n\t\t\tif (event) {\n\t\t\t\tthis.onLinkClick(event, item)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.onNavigate(item)\n\t\t},\n\n\t\t/**\n\t\t * Navigate to an item's URL (router push for internal, new tab for\n\t\t * external), closing any open menus first. Used where no link click\n\t\t * is involved (keyboard activation of a menu item).\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonNavigate(item) {\n\t\t\tif (!item || typeof item.url !== 'string' || item.url === '') {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.closeAll()\n\t\t\tconst url = item.url\n\t\t\tconst { href } = menuLinkAttrs(url, this.$router)\n\t\t\tif (this.isExternal(url)) {\n\t\t\t\tif (typeof window !== 'undefined' && typeof window.open === 'function') {\n\t\t\t\t\twindow.open(href, '_blank', 'noopener,noreferrer')\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (isRouterPath(url) && this.$router && typeof this.$router.push === 'function') {\n\t\t\t\ttry {\n\t\t\t\t\tthis.$router.push(url)\n\t\t\t\t\treturn\n\t\t\t\t} catch {\n\t\t\t\t\t// Fall through to location assignment.\n\t\t\t\t}\n\t\t\t}\n\t\t\t// href is safeHref-checked, so a javascript: URL never reaches location.\n\t\t\tif (href !== '#' && typeof window !== 'undefined' && window.location) {\n\t\t\t\twindow.location.href = href\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Whether a URL is external (http/https).\n\t\t *\n\t\t * @param {string} url the URL.\n\t\t * @return {boolean} true for absolute http(s) URLs.\n\t\t */\n\t\tisExternal(url) {\n\t\t\treturn typeof url === 'string' && /^https?:\\/\\//i.test(url)\n\t\t},\n\n\t\t/**\n\t\t * Empty-state click — request edit when in edit mode.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\tonEmptyStateClick() {\n\t\t\tif (this.isInEditMode) {\n\t\t\t\tthis.$emit('edit-request')\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Test helper — whether a URL is active for the current location.\n\t\t *\n\t\t * @param {string} itemUrl the URL to test.\n\t\t * @return {boolean} true when active.\n\t\t */\n\t\t__isActive(itemUrl) {\n\t\t\treturn isActiveItem(itemUrl, this.currentLocation)\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-menu-widget {\n\twidth: 100%;\n\theight: 100%;\n\tpadding: 4px;\n\tcolor: var(--color-main-text);\n\tfont-size: 14px;\n}\n\n.cn-menu-widget__empty {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 8px;\n\tpadding: 16px;\n\tcolor: var(--color-text-maxcontrast);\n\tfont-style: italic;\n\tborder: 1px dashed var(--color-border);\n\tborder-radius: var(--border-radius);\n}\n\n.cn-menu-widget__empty--clickable {\n\tcursor: pointer;\n}\n\n.cn-menu-widget__empty-icon {\n\tfont-size: 18px;\n}\n\n.cn-menu-widget__bar {\n\tdisplay: flex;\n\tflex-direction: row;\n\tgap: 4px;\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.cn-menu-widget__bar--vertical {\n\tflex-direction: column;\n}\n\n.cn-menu-widget__bar-item {\n\tposition: relative;\n\tlist-style: none;\n}\n\n.cn-menu-widget__bar-button,\n.cn-menu-widget__dropdown-item,\n.cn-menu-widget__mega-group-title,\n.cn-menu-widget__mega-leaf {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 6px 10px;\n\tbackground: transparent;\n\tborder: none;\n\tborder-radius: var(--border-radius);\n\tcolor: inherit;\n\tfont-size: 14px;\n\tcursor: pointer;\n\ttext-align: left;\n\ttext-decoration: none;\n\tbox-sizing: border-box;\n\twidth: 100%;\n}\n\n.cn-menu-widget__mega-group-title {\n\tfont-weight: 600;\n}\n\n.cn-menu-widget__bar-button:hover,\n.cn-menu-widget__dropdown-item:hover,\n.cn-menu-widget__mega-group-title:hover,\n.cn-menu-widget__mega-leaf:hover {\n\tbackground: var(--color-background-hover);\n}\n\n.cn-menu-widget__bar-button:focus-visible,\n.cn-menu-widget__dropdown-item:focus-visible {\n\toutline: 2px solid var(--color-primary);\n\toutline-offset: 2px;\n}\n\n.cn-menu-widget__icon {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\twidth: 20px;\n\theight: 20px;\n}\n\n.cn-menu-widget__icon--hidden {\n\tvisibility: hidden;\n}\n\n.cn-menu-widget__caret {\n\tmargin-left: auto;\n\tfont-size: 10px;\n}\n\n.cn-menu-widget__dropdown {\n\tposition: absolute;\n\ttop: 100%;\n\tleft: 0;\n\tz-index: 50;\n\tmargin: 4px 0 0;\n\tpadding: 4px;\n\tmin-width: 180px;\n\tbackground: var(--color-main-background);\n\tborder: 1px solid var(--color-border);\n\tborder-radius: var(--border-radius);\n\tbox-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);\n\tlist-style: none;\n}\n\n.cn-menu-widget__dropdown-item-wrap {\n\tposition: relative;\n}\n\n.cn-menu-widget__flyout {\n\tposition: absolute;\n\ttop: 0;\n\tleft: 100%;\n\tmargin-left: 2px;\n\tpadding: 4px;\n\tmin-width: 160px;\n\tbackground: var(--color-main-background);\n\tborder: 1px solid var(--color-border);\n\tborder-radius: var(--border-radius);\n\tbox-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);\n\tlist-style: none;\n}\n\n.cn-menu-widget__mega-panel {\n\tmargin-top: 4px;\n\tpadding: 12px;\n\tdisplay: grid;\n\tgrid-template-columns: repeat(auto-fit, minmax(160px, 1fr));\n\tgap: 12px;\n\tbackground: var(--color-main-background);\n\tborder: 1px solid var(--color-border);\n\tborder-radius: var(--border-radius);\n}\n\n.cn-menu-widget__mega-leaves {\n\tmargin: 4px 0 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.cn-menu-widget__tree {\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n/* Active-item highlight variants. */\n.cn-menu-widget--highlight-underline .cn-menu-widget__item--active {\n\tborder-bottom: 3px solid var(--color-primary);\n}\n\n.cn-menu-widget--highlight-underline .cn-menu-widget__item--in-path {\n\tborder-bottom: 2px solid var(--color-primary);\n\topacity: 0.85;\n}\n\n.cn-menu-widget--highlight-background .cn-menu-widget__item--active,\n.cn-menu-widget--highlight-background .cn-menu-widget__item--in-path {\n\tbackground: var(--color-primary-element-light, rgba(0, 112, 192, 0.1));\n\tcolor: var(--color-primary);\n}\n\n.cn-menu-widget--highlight-left-bar .cn-menu-widget__item--active {\n\tborder-left: 5px solid var(--color-primary);\n\tpadding-left: 6px;\n}\n\n.cn-menu-widget--highlight-left-bar .cn-menu-widget__item--in-path {\n\tborder-left: 4px solid var(--color-primary);\n\tpadding-left: 7px;\n\topacity: 0.85;\n}\n</style>\n"],"names":["CnMenuTreeNode","CnMenuItemIcon"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"CnMenuWidget.vue2.js","sources":["../../../../src/components/CnMenuWidget/CnMenuWidget.vue"],"sourcesContent":["<!--\n - SPDX-FileCopyrightText: 2026 Conduction B.V.\n - SPDX-License-Identifier: EUPL-1.2\n-->\n\n<template>\n\t<div\n\t\tclass=\"cn-menu-widget\"\n\t\t:class=\"rootClasses\"\n\t\t@keydown.esc.stop=\"closeAll\">\n\t\t<!-- Empty state -->\n\t\t<div\n\t\t\tv-if=\"isEmpty\"\n\t\t\tclass=\"cn-menu-widget__empty\"\n\t\t\t:class=\"{ 'cn-menu-widget__empty--clickable': isInEditMode }\"\n\t\t\t:role=\"isInEditMode ? 'button' : null\"\n\t\t\t:tabindex=\"isInEditMode ? 0 : -1\"\n\t\t\t@click=\"onEmptyStateClick\"\n\t\t\t@keyup.enter=\"onEmptyStateClick\"\n\t\t\t@keyup.space=\"onEmptyStateClick\">\n\t\t\t<span class=\"cn-menu-widget__empty-icon\" aria-hidden=\"true\">⚙</span>\n\t\t\t<span>{{ t('nextcloud-vue', 'No menu items yet — click the gear icon to add some.') }}</span>\n\t\t</div>\n\n\t\t<!-- Tree style -->\n\t\t<ul\n\t\t\tv-else-if=\"effectiveStyle === 'tree'\"\n\t\t\tclass=\"cn-menu-widget__tree\"\n\t\t\trole=\"tree\">\n\t\t\t<CnMenuTreeNode\n\t\t\t\tv-for=\"(item, idx) in items\"\n\t\t\t\t:key=\"`tree-${idx}`\"\n\t\t\t\t:item=\"item\"\n\t\t\t\t:depth=\"1\"\n\t\t\t\t:showIcons=\"showIcons\"\n\t\t\t\t:expandedByDefault=\"expandedByDefault\"\n\t\t\t\t:activePath=\"activePath\"\n\t\t\t\t:activeLeafKey=\"activeLeafKey\"\n\t\t\t\t:currentKey=\"`${idx}`\"\n\t\t\t\t:activeHighlight=\"activeItemHighlight\"\n\t\t\t\t@navigate=\"onTreeNavigate\" />\n\t\t</ul>\n\n\t\t<!-- Megamenu style -->\n\t\t<div\n\t\t\tv-else-if=\"effectiveStyle === 'megamenu'\"\n\t\t\tclass=\"cn-menu-widget__megamenu\">\n\t\t\t<ul\n\t\t\t\tclass=\"cn-menu-widget__bar\"\n\t\t\t\trole=\"menubar\">\n\t\t\t\t<li\n\t\t\t\t\tv-for=\"(item, idx) in items\"\n\t\t\t\t\t:key=\"`mega-top-${idx}`\"\n\t\t\t\t\trole=\"none\"\n\t\t\t\t\tclass=\"cn-menu-widget__bar-item\">\n\t\t\t\t\t<component\n\t\t\t\t\t\t:is=\"isTopLink(item) ? 'a' : 'button'\"\n\t\t\t\t\t\t:ref=\"(el) => setTopRef(el, idx)\"\n\t\t\t\t\t\tv-bind=\"isTopLink(item) ? linkAttrs(item) : { type: 'button' }\"\n\t\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\t\tclass=\"cn-menu-widget__bar-button\"\n\t\t\t\t\t\t:class=\"topItemClass(`${idx}`)\"\n\t\t\t\t\t\t:aria-haspopup=\"hasChildren(item) ? 'menu' : null\"\n\t\t\t\t\t\t:aria-expanded=\"megaOpenIndex === idx ? 'true' : 'false'\"\n\t\t\t\t\t\t@click=\"isTopLink(item) ? onLinkClick($event, item) : onMegaTopClick(idx, item)\"\n\t\t\t\t\t\t@keydown=\"onMegaTopKey($event, idx, item)\">\n\t\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !item.icon }\">\n\t\t\t\t\t\t\t<CnMenuItemIcon v-if=\"item.icon\" :icon=\"item.icon\" />\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ item.label }}</span>\n\t\t\t\t\t</component>\n\t\t\t\t</li>\n\t\t\t</ul>\n\t\t\t<div\n\t\t\t\tv-if=\"megaOpenIndex !== null && hasChildren(items[megaOpenIndex])\"\n\t\t\t\tref=\"megaPanel\"\n\t\t\t\tclass=\"cn-menu-widget__mega-panel\"\n\t\t\t\trole=\"menu\">\n\t\t\t\t<div\n\t\t\t\t\tv-for=\"(child, childIdx) in items[megaOpenIndex].children\"\n\t\t\t\t\t:key=\"`mega-group-${childIdx}`\"\n\t\t\t\t\tclass=\"cn-menu-widget__mega-group\">\n\t\t\t\t\t<component\n\t\t\t\t\t\t:is=\"isLink(child) ? 'a' : 'button'\"\n\t\t\t\t\t\tv-bind=\"itemAttrs(child)\"\n\t\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\t\tclass=\"cn-menu-widget__mega-group-title\"\n\t\t\t\t\t\t:class=\"topItemClass(`${megaOpenIndex}.${childIdx}`)\"\n\t\t\t\t\t\t@click=\"onLinkClick($event, child)\">\n\t\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !child.icon }\">\n\t\t\t\t\t\t\t<CnMenuItemIcon v-if=\"child.icon\" :icon=\"child.icon\" />\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ child.label }}</span>\n\t\t\t\t\t</component>\n\t\t\t\t\t<ul\n\t\t\t\t\t\tv-if=\"hasChildren(child)\"\n\t\t\t\t\t\tclass=\"cn-menu-widget__mega-leaves\">\n\t\t\t\t\t\t<li\n\t\t\t\t\t\t\tv-for=\"(leaf, leafIdx) in child.children\"\n\t\t\t\t\t\t\t:key=\"`mega-leaf-${leafIdx}`\"\n\t\t\t\t\t\t\trole=\"none\">\n\t\t\t\t\t\t\t<component\n\t\t\t\t\t\t\t\t:is=\"isLink(leaf) ? 'a' : 'button'\"\n\t\t\t\t\t\t\t\tv-bind=\"itemAttrs(leaf)\"\n\t\t\t\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\t\t\t\tclass=\"cn-menu-widget__mega-leaf\"\n\t\t\t\t\t\t\t\t:class=\"topItemClass(`${megaOpenIndex}.${childIdx}.${leafIdx}`)\"\n\t\t\t\t\t\t\t\t@click=\"onLinkClick($event, leaf)\">\n\t\t\t\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !leaf.icon }\">\n\t\t\t\t\t\t\t\t\t<CnMenuItemIcon v-if=\"leaf.icon\" :icon=\"leaf.icon\" />\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ leaf.label }}</span>\n\t\t\t\t\t\t\t</component>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t</ul>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<!-- Dropdown style (default) -->\n\t\t<ul\n\t\t\tv-else\n\t\t\tclass=\"cn-menu-widget__bar\"\n\t\t\t:class=\"{ 'cn-menu-widget__bar--vertical': orientation === 'vertical' }\"\n\t\t\trole=\"menubar\">\n\t\t\t<li\n\t\t\t\tv-for=\"(item, idx) in items\"\n\t\t\t\t:key=\"`drop-top-${idx}`\"\n\t\t\t\t:ref=\"(el) => setBarItemRef(el, idx)\"\n\t\t\t\trole=\"none\"\n\t\t\t\tclass=\"cn-menu-widget__bar-item\">\n\t\t\t\t<component\n\t\t\t\t\t:is=\"isTopLink(item) ? 'a' : 'button'\"\n\t\t\t\t\t:ref=\"(el) => setTopRef(el, idx)\"\n\t\t\t\t\tv-bind=\"isTopLink(item) ? linkAttrs(item) : { type: 'button' }\"\n\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\tclass=\"cn-menu-widget__bar-button\"\n\t\t\t\t\t:class=\"topItemClass(`${idx}`)\"\n\t\t\t\t\t:aria-haspopup=\"hasChildren(item) ? 'menu' : null\"\n\t\t\t\t\t:aria-expanded=\"dropOpenIndex === idx ? 'true' : 'false'\"\n\t\t\t\t\t@click=\"isTopLink(item) ? onLinkClick($event, item) : onDropdownTopClick(idx, item)\"\n\t\t\t\t\t@keydown.tab=\"closeAll\"\n\t\t\t\t\t@keydown.enter=\"onDropdownTopEnter($event, idx, item)\"\n\t\t\t\t\t@keydown.space.prevent=\"onDropdownTopClick(idx, item)\"\n\t\t\t\t\t@keydown.down.prevent=\"openDropdown(idx)\">\n\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !item.icon }\">\n\t\t\t\t\t\t<CnMenuItemIcon v-if=\"item.icon\" :icon=\"item.icon\" />\n\t\t\t\t\t</span>\n\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ item.label }}</span>\n\t\t\t\t\t<span v-if=\"hasChildren(item)\" class=\"cn-menu-widget__caret\" aria-hidden=\"true\">▾</span>\n\t\t\t\t</component>\n\t\t\t\t<ul\n\t\t\t\t\tv-if=\"dropOpenIndex === idx && hasChildren(item)\"\n\t\t\t\t\tclass=\"cn-menu-widget__dropdown\"\n\t\t\t\t\trole=\"menu\">\n\t\t\t\t\t<li\n\t\t\t\t\t\tv-for=\"(child, childIdx) in item.children\"\n\t\t\t\t\t\t:key=\"`drop-child-${childIdx}`\"\n\t\t\t\t\t\trole=\"none\"\n\t\t\t\t\t\tclass=\"cn-menu-widget__dropdown-item-wrap\"\n\t\t\t\t\t\t@mouseenter=\"hasChildren(child) && (flyoutOpenKey = `${idx}.${childIdx}`)\">\n\t\t\t\t\t\t<component\n\t\t\t\t\t\t\t:is=\"isLink(child) ? 'a' : 'button'\"\n\t\t\t\t\t\t\tv-bind=\"itemAttrs(child)\"\n\t\t\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\t\t\tclass=\"cn-menu-widget__dropdown-item\"\n\t\t\t\t\t\t\t:class=\"topItemClass(`${idx}.${childIdx}`)\"\n\t\t\t\t\t\t\t@click=\"onLinkClick($event, child)\">\n\t\t\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !child.icon }\">\n\t\t\t\t\t\t\t\t<CnMenuItemIcon v-if=\"child.icon\" :icon=\"child.icon\" />\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ child.label }}</span>\n\t\t\t\t\t\t\t<span v-if=\"hasChildren(child)\" class=\"cn-menu-widget__caret cn-menu-widget__caret--right\" aria-hidden=\"true\">▸</span>\n\t\t\t\t\t\t</component>\n\t\t\t\t\t\t<ul\n\t\t\t\t\t\t\tv-if=\"flyoutOpenKey === `${idx}.${childIdx}` && hasChildren(child)\"\n\t\t\t\t\t\t\tclass=\"cn-menu-widget__flyout\"\n\t\t\t\t\t\t\trole=\"menu\">\n\t\t\t\t\t\t\t<li\n\t\t\t\t\t\t\t\tv-for=\"(leaf, leafIdx) in child.children\"\n\t\t\t\t\t\t\t\t:key=\"`drop-leaf-${leafIdx}`\"\n\t\t\t\t\t\t\t\trole=\"none\">\n\t\t\t\t\t\t\t\t<component\n\t\t\t\t\t\t\t\t\t:is=\"isLink(leaf) ? 'a' : 'button'\"\n\t\t\t\t\t\t\t\t\tv-bind=\"itemAttrs(leaf)\"\n\t\t\t\t\t\t\t\t\trole=\"menuitem\"\n\t\t\t\t\t\t\t\t\tclass=\"cn-menu-widget__dropdown-item\"\n\t\t\t\t\t\t\t\t\t:class=\"topItemClass(`${idx}.${childIdx}.${leafIdx}`)\"\n\t\t\t\t\t\t\t\t\t@click=\"onLinkClick($event, leaf)\">\n\t\t\t\t\t\t\t\t\t<span v-if=\"showIcons\" class=\"cn-menu-widget__icon\" :class=\"{ 'cn-menu-widget__icon--hidden': !leaf.icon }\">\n\t\t\t\t\t\t\t\t\t\t<CnMenuItemIcon v-if=\"leaf.icon\" :icon=\"leaf.icon\" />\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span class=\"cn-menu-widget__label\">{{ leaf.label }}</span>\n\t\t\t\t\t\t\t\t</component>\n\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t</ul>\n\t\t\t\t\t</li>\n\t\t\t\t</ul>\n\t\t\t</li>\n\t\t</ul>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport CnMenuItemIcon from './CnMenuItemIcon.vue'\nimport CnMenuTreeNode from './CnMenuTreeNode.vue'\nimport { followLinkClick } from '../../utils/linkNavigation.js'\nimport { computeActivePath, isActiveItem, isRouterPath, menuLinkAttrs } from './menuActive.js'\n\nconst VALID_STYLES = ['dropdown', 'megamenu', 'tree']\nconst VALID_ORIENTATIONS = ['horizontal', 'vertical']\nconst VALID_HIGHLIGHTS = ['background', 'underline', 'left-bar', 'none']\n\n/**\n * CnMenuWidget — renders a hierarchical navigation widget with three visual\n * styles (dropdown, megamenu, tree) and active-item highlighting based on\n * `window.location.pathname`.\n *\n * Active-item detection runs on mount and whenever `items` or the URL change;\n * ancestors of the active leaf are flagged \"in-path\" so the configured\n * highlight (`underline`, `background`, `left-bar`, `none`) renders\n * consistently. Keyboard navigation follows the WAI-ARIA Menu/Menubar pattern.\n *\n * Open dropdown/megamenu panels close on: re-clicking the toggle, Escape,\n * Tab, navigating a leaf, and clicking ANYWHERE else — including empty space\n * elsewhere in the widget itself, not just outside it entirely (a\n * document-level listener that checks containment against the open toggle\n * button + its own panel specifically, since the panels are plain in-DOM\n * markup, not a teleported popover with its own outside-click handling).\n *\n * @spec openspec/changes/cn-widget-library/specs/cn-widget-library/spec.md\n */\nexport default {\n\tname: 'CnMenuWidget',\n\n\tcomponents: {\n\t\tCnMenuTreeNode,\n\t\tCnMenuItemIcon,\n\t},\n\n\tinject: {\n\t\t/** Host translate function provided by CnAppRoot, for the item labels. */\n\t\tcnTranslate: { default: () => (key) => key },\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * Persisted widget content `{items, style, orientation, showIcons,\n\t\t * expandedByDefault, activeItemHighlight}`.\n\t\t *\n\t\t * @type {object}\n\t\t */\n\t\tcontent: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/** Whether the current user is an admin (pairs with `canEdit`). */\n\t\tisAdmin: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Whether the surrounding shell is in edit mode. */\n\t\tcanEdit: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\t},\n\n\temits: [\n\t\t/** Fired when the empty-state gear is clicked in edit mode. */\n\t\t'edit-request',\n\t],\n\n\tdata() {\n\t\treturn {\n\t\t\tdropOpenIndex: null,\n\t\t\tflyoutOpenKey: null,\n\t\t\tmegaOpenIndex: null,\n\t\t\tcurrentLocation: this.readLocation(),\n\t\t\ttopRefs: [],\n\t\t\tbarItemRefs: [],\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The configured menu items.\n\t\t *\n\t\t * @return {object[]} the items array.\n\t\t */\n\t\titems() {\n\t\t\tconst arr = this.content?.items\n\t\t\tif (!Array.isArray(arr)) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\t// Labels are manifest strings: they go through the app's translate,\n\t\t\t// children included, so \"Case types\" reads \"Zaaktypen\" (DqCatalogus).\n\t\t\tconst translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key\n\t\t\tconst translated = (list) => list.map((item) => {\n\t\t\t\tif (!item || typeof item !== 'object') {\n\t\t\t\t\treturn item\n\t\t\t\t}\n\t\t\t\tconst out = { ...item }\n\t\t\t\tif (typeof item.label === 'string' && item.label !== '') {\n\t\t\t\t\tout.label = translate(item.label)\n\t\t\t\t}\n\t\t\t\tif (Array.isArray(item.children)) {\n\t\t\t\t\tout.children = translated(item.children)\n\t\t\t\t}\n\t\t\t\treturn out\n\t\t\t})\n\t\t\treturn translated(arr)\n\t\t},\n\n\t\t/**\n\t\t * Whether the menu has no items.\n\t\t *\n\t\t * @return {boolean} true when empty.\n\t\t */\n\t\tisEmpty() {\n\t\t\treturn this.items.length === 0\n\t\t},\n\n\t\t/**\n\t\t * The validated visual style (default `dropdown`).\n\t\t *\n\t\t * @return {string} one of dropdown/megamenu/tree.\n\t\t */\n\t\teffectiveStyle() {\n\t\t\tconst s = this.content?.style\n\t\t\treturn VALID_STYLES.includes(s) ? s : 'dropdown'\n\t\t},\n\n\t\t/**\n\t\t * The validated orientation (tree is always vertical).\n\t\t *\n\t\t * @return {string} `horizontal` or `vertical`.\n\t\t */\n\t\torientation() {\n\t\t\tconst o = this.content?.orientation\n\t\t\tif (this.effectiveStyle === 'tree') {\n\t\t\t\treturn 'vertical'\n\t\t\t}\n\t\t\treturn VALID_ORIENTATIONS.includes(o) ? o : 'horizontal'\n\t\t},\n\n\t\t/**\n\t\t * Whether item icons render (default true).\n\t\t *\n\t\t * @return {boolean} the showIcons flag.\n\t\t */\n\t\tshowIcons() {\n\t\t\treturn this.content?.showIcons !== false\n\t\t},\n\n\t\t/**\n\t\t * Whether tree nodes start expanded.\n\t\t *\n\t\t * @return {boolean} the expandedByDefault flag.\n\t\t */\n\t\texpandedByDefault() {\n\t\t\treturn this.content?.expandedByDefault === true\n\t\t},\n\n\t\t/**\n\t\t * The validated active-item highlight style (default `underline`).\n\t\t *\n\t\t * @return {string} one of background/underline/left-bar/none.\n\t\t */\n\t\tactiveItemHighlight() {\n\t\t\tconst h = this.content?.activeItemHighlight\n\t\t\treturn VALID_HIGHLIGHTS.includes(h) ? h : 'underline'\n\t\t},\n\n\t\t/**\n\t\t * Whether the widget is in edit mode (empty-state gear clickable).\n\t\t *\n\t\t * @return {boolean} true when admin + canEdit.\n\t\t */\n\t\tisInEditMode() {\n\t\t\treturn this.isAdmin === true && this.canEdit === true\n\t\t},\n\n\t\t/**\n\t\t * The root style/highlight/orientation CSS classes.\n\t\t *\n\t\t * @return {object} the class map.\n\t\t */\n\t\trootClasses() {\n\t\t\treturn {\n\t\t\t\t[`cn-menu-widget--style-${this.effectiveStyle}`]: true,\n\t\t\t\t[`cn-menu-widget--highlight-${this.activeItemHighlight}`]: true,\n\t\t\t\t[`cn-menu-widget--orientation-${this.orientation}`]: true,\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * The dotted-key path map for the current location.\n\t\t *\n\t\t * @return {{path: object, leafKey: string|null}} the active-path map.\n\t\t */\n\t\tactivePathMap() {\n\t\t\treturn computeActivePath({\n\t\t\t\titems: this.items,\n\t\t\t\tcurrentLocation: this.currentLocation,\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The dotted-key → state map.\n\t\t *\n\t\t * @return {Record<string, string>} the path map.\n\t\t */\n\t\tactivePath() {\n\t\t\treturn this.activePathMap.path\n\t\t},\n\n\t\t/**\n\t\t * The key of the deepest active leaf.\n\t\t *\n\t\t * @return {string|null} the leaf key.\n\t\t */\n\t\tactiveLeafKey() {\n\t\t\treturn this.activePathMap.leafKey\n\t\t},\n\t},\n\n\tmounted() {\n\t\tthis.boundLocationListener = () => {\n\t\t\tthis.currentLocation = this.readLocation()\n\t\t}\n\t\tif (typeof window !== 'undefined') {\n\t\t\twindow.addEventListener('popstate', this.boundLocationListener)\n\t\t\twindow.addEventListener('hashchange', this.boundLocationListener)\n\t\t}\n\t\tthis.boundOutsideClickListener = this.onOutsideClick\n\t\tif (typeof document !== 'undefined') {\n\t\t\tdocument.addEventListener('click', this.boundOutsideClickListener)\n\t\t}\n\t},\n\n\tbeforeUnmount() {\n\t\tif (typeof window !== 'undefined' && this.boundLocationListener) {\n\t\t\twindow.removeEventListener('popstate', this.boundLocationListener)\n\t\t\twindow.removeEventListener('hashchange', this.boundLocationListener)\n\t\t}\n\t\tif (typeof document !== 'undefined' && this.boundOutsideClickListener) {\n\t\t\tdocument.removeEventListener('click', this.boundOutsideClickListener)\n\t\t}\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Read the current browser location into a small bag.\n\t\t *\n\t\t * @return {{pathname: string, host: string}} the location bag.\n\t\t */\n\t\treadLocation() {\n\t\t\tif (typeof window === 'undefined' || !window.location) {\n\t\t\t\treturn { pathname: '/', host: '' }\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tpathname: window.location.pathname || '/',\n\t\t\t\thost: window.location.host || '',\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Stash a top-level button ref by index.\n\t\t *\n\t\t * @param {HTMLElement} el the button element.\n\t\t * @param {number} idx the item index.\n\t\t * @return {void}\n\t\t */\n\t\tsetTopRef(el, idx) {\n\t\t\tif (el) {\n\t\t\t\tthis.topRefs[idx] = el\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Stash a dropdown-style top-level `<li>` ref by index. This `<li>`\n\t\t * contains both the toggle button and its dropdown/flyout panel, so a\n\t\t * single containment check against it covers the whole open structure.\n\t\t *\n\t\t * @param {HTMLElement} el the `<li>` element.\n\t\t * @param {number} idx the item index.\n\t\t * @return {void}\n\t\t */\n\t\tsetBarItemRef(el, idx) {\n\t\t\tif (el) {\n\t\t\t\tthis.barItemRefs[idx] = el\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Whether an item has children.\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {boolean} true when it has a non-empty children array.\n\t\t */\n\t\thasChildren(item) {\n\t\t\treturn item && Array.isArray(item.children) && item.children.length > 0\n\t\t},\n\n\t\t/**\n\t\t * The active-state class for a dotted key.\n\t\t *\n\t\t * @param {string} key the dotted item key.\n\t\t * @return {string} the class name (or `''`).\n\t\t */\n\t\ttopItemClass(key) {\n\t\t\tconst state = this.activePath[key]\n\t\t\tif (state === 'active' || key === this.activeLeafKey) {\n\t\t\t\treturn 'cn-menu-widget__item--active'\n\t\t\t}\n\t\t\tif (state === 'in-path') {\n\t\t\t\treturn 'cn-menu-widget__item--in-path'\n\t\t\t}\n\t\t\treturn ''\n\t\t},\n\n\t\t/**\n\t\t * Top-level dropdown click — navigate a leaf or toggle the dropdown.\n\t\t *\n\t\t * @param {number} idx the item index.\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonDropdownTopClick(idx, item) {\n\t\t\tif (!this.hasChildren(item)) {\n\t\t\t\tthis.onNavigate(item)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (this.dropOpenIndex === idx) {\n\t\t\t\tthis.dropOpenIndex = null\n\t\t\t\tthis.flyoutOpenKey = null\n\t\t\t} else {\n\t\t\t\tthis.dropOpenIndex = idx\n\t\t\t\tthis.flyoutOpenKey = null\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Open the dropdown at an index when it has children.\n\t\t *\n\t\t * @param {number} idx the item index.\n\t\t * @return {void}\n\t\t */\n\t\topenDropdown(idx) {\n\t\t\tif (this.hasChildren(this.items[idx])) {\n\t\t\t\tthis.dropOpenIndex = idx\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Top-level megamenu click — navigate a leaf or toggle the panel.\n\t\t *\n\t\t * @param {number} idx the item index.\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonMegaTopClick(idx, item) {\n\t\t\tif (!this.hasChildren(item)) {\n\t\t\t\tthis.onNavigate(item)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.megaOpenIndex = this.megaOpenIndex === idx ? null : idx\n\t\t},\n\n\t\t/**\n\t\t * Keyboard handler for megamenu top items.\n\t\t *\n\t\t * @param {KeyboardEvent} event the key event.\n\t\t * @param {number} idx the item index.\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonMegaTopKey(event, idx, item) {\n\t\t\t// Enter on a link clicks it natively.\n\t\t\tif (event.key === 'Enter' && this.isTopLink(item)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {\n\t\t\t\tevent.preventDefault()\n\t\t\t\tthis.onMegaTopClick(idx, item)\n\t\t\t} else if (event.key === 'Tab') {\n\t\t\t\tthis.megaOpenIndex = null\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Close every open dropdown/flyout/panel.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\tcloseAll() {\n\t\t\tthis.dropOpenIndex = null\n\t\t\tthis.flyoutOpenKey = null\n\t\t\tthis.megaOpenIndex = null\n\t\t},\n\n\t\t/**\n\t\t * Document-level click handler — closes any open dropdown/flyout/panel\n\t\t * when the click lands outside its own toggle button + panel, INCLUDING\n\t\t * empty space elsewhere in the widget (e.g. bar padding, gaps between\n\t\t * items) — not just outside the whole widget. The toggle button's own\n\t\t * `@click` handler manages opening/closing for its own clicks, so a\n\t\t * click still inside the open button/panel is left alone — otherwise\n\t\t * every open-toggle click would immediately re-close itself via this\n\t\t * same listener.\n\t\t *\n\t\t * @param {MouseEvent} event the document click event.\n\t\t * @return {void}\n\t\t */\n\t\tonOutsideClick(event) {\n\t\t\tconst target = event.target\n\t\t\tif (this.effectiveStyle === 'megamenu') {\n\t\t\t\tif (this.megaOpenIndex === null) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tconst button = this.topRefs[this.megaOpenIndex]\n\t\t\t\tconst panel = this.$refs.megaPanel\n\t\t\t\tconst insideButton = Boolean(button && button.contains(target))\n\t\t\t\tconst insidePanel = Boolean(panel && panel.contains(target))\n\t\t\t\tif (!insideButton && !insidePanel) {\n\t\t\t\t\tthis.closeAll()\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Dropdown style — the button and its dropdown/flyout panel share\n\t\t\t// the same top-level `<li>`, so one containment check covers both.\n\t\t\tif (this.dropOpenIndex === null) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst barItem = this.barItemRefs[this.dropOpenIndex]\n\t\t\tif (barItem && !barItem.contains(target)) {\n\t\t\t\tthis.closeAll()\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Whether an item renders as a link (it has a URL).\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {boolean} true when the item carries a non-empty URL.\n\t\t */\n\t\tisLink(item) {\n\t\t\treturn Boolean(item) && typeof item.url === 'string' && item.url !== ''\n\t\t},\n\n\t\t/**\n\t\t * Whether a top-level item renders as a link. One with children\n\t\t * stays a button, because its click toggles the panel.\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {boolean} true for a top-level leaf with a URL.\n\t\t */\n\t\tisTopLink(item) {\n\t\t\treturn this.isLink(item) && !this.hasChildren(item)\n\t\t},\n\n\t\t/**\n\t\t * Anchor attributes for an item's URL: external URLs open in a new tab.\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {object} the `href` (plus `target` / `rel` for external URLs).\n\t\t */\n\t\tlinkAttrs(item) {\n\t\t\treturn menuLinkAttrs(item.url, this.$router)\n\t\t},\n\n\t\t/**\n\t\t * Attributes for a panel item: link attributes, or `type=\"button\"`.\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {object} the element attributes.\n\t\t */\n\t\titemAttrs(item) {\n\t\t\treturn this.isLink(item) ? this.linkAttrs(item) : { type: 'button' }\n\t\t},\n\n\t\t/**\n\t\t * Click on an item link: close the menus, then let the browser open\n\t\t * an external URL or a modified click, and route a plain click on an\n\t\t * internal one. A no-op for an item without a URL.\n\t\t *\n\t\t * @param {MouseEvent} event the click event.\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonLinkClick(event, item) {\n\t\t\tif (!this.isLink(item)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.closeAll()\n\t\t\tif (isRouterPath(item.url)) {\n\t\t\t\tfollowLinkClick(event, item.url, this.$router)\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Enter on a dropdown top item: a link follows natively (Enter\n\t\t * clicks it); a button toggles or navigates.\n\t\t *\n\t\t * @param {KeyboardEvent} event the key event.\n\t\t * @param {number} idx the item index.\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonDropdownTopEnter(event, idx, item) {\n\t\t\tif (this.isTopLink(item)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tevent.preventDefault()\n\t\t\tthis.onDropdownTopClick(idx, item)\n\t\t},\n\n\t\t/**\n\t\t * Navigation request from a tree node: a link click goes through the\n\t\t * link handler, a request without an event navigates directly.\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @param {MouseEvent} [event] the click event, when the node's link was clicked.\n\t\t * @return {void}\n\t\t */\n\t\tonTreeNavigate(item, event) {\n\t\t\tif (event) {\n\t\t\t\tthis.onLinkClick(event, item)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.onNavigate(item)\n\t\t},\n\n\t\t/**\n\t\t * Navigate to an item's URL (router push for internal, new tab for\n\t\t * external), closing any open menus first. Used where no link click\n\t\t * is involved (keyboard activation of a menu item).\n\t\t *\n\t\t * @param {object} item the menu item.\n\t\t * @return {void}\n\t\t */\n\t\tonNavigate(item) {\n\t\t\tif (!item || typeof item.url !== 'string' || item.url === '') {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.closeAll()\n\t\t\tconst url = item.url\n\t\t\tconst { href } = menuLinkAttrs(url, this.$router)\n\t\t\tif (this.isExternal(url)) {\n\t\t\t\tif (typeof window !== 'undefined' && typeof window.open === 'function') {\n\t\t\t\t\twindow.open(href, '_blank', 'noopener,noreferrer')\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (isRouterPath(url) && this.$router && typeof this.$router.push === 'function') {\n\t\t\t\ttry {\n\t\t\t\t\tthis.$router.push(url)\n\t\t\t\t\treturn\n\t\t\t\t} catch {\n\t\t\t\t\t// Fall through to location assignment.\n\t\t\t\t}\n\t\t\t}\n\t\t\t// href is safeHref-checked, so a javascript: URL never reaches location.\n\t\t\tif (href !== '#' && typeof window !== 'undefined' && window.location) {\n\t\t\t\twindow.location.href = href\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Whether a URL is external (http/https).\n\t\t *\n\t\t * @param {string} url the URL.\n\t\t * @return {boolean} true for absolute http(s) URLs.\n\t\t */\n\t\tisExternal(url) {\n\t\t\treturn typeof url === 'string' && /^https?:\\/\\//i.test(url)\n\t\t},\n\n\t\t/**\n\t\t * Empty-state click — request edit when in edit mode.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\tonEmptyStateClick() {\n\t\t\tif (this.isInEditMode) {\n\t\t\t\tthis.$emit('edit-request')\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Test helper — whether a URL is active for the current location.\n\t\t *\n\t\t * @param {string} itemUrl the URL to test.\n\t\t * @return {boolean} true when active.\n\t\t */\n\t\t__isActive(itemUrl) {\n\t\t\treturn isActiveItem(itemUrl, this.currentLocation)\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-menu-widget {\n\twidth: 100%;\n\theight: 100%;\n\tpadding: 4px;\n\tcolor: var(--color-main-text);\n\tfont-size: 14px;\n}\n\n.cn-menu-widget__empty {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 8px;\n\tpadding: 16px;\n\tcolor: var(--color-text-maxcontrast);\n\tfont-style: italic;\n\tborder: 1px dashed var(--color-border);\n\tborder-radius: var(--border-radius);\n}\n\n.cn-menu-widget__empty--clickable {\n\tcursor: pointer;\n}\n\n.cn-menu-widget__empty-icon {\n\tfont-size: 18px;\n}\n\n.cn-menu-widget__bar {\n\tdisplay: flex;\n\tflex-direction: row;\n\tgap: 4px;\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.cn-menu-widget__bar--vertical {\n\tflex-direction: column;\n}\n\n.cn-menu-widget__bar-item {\n\tposition: relative;\n\tlist-style: none;\n}\n\n.cn-menu-widget__bar-button,\n.cn-menu-widget__dropdown-item,\n.cn-menu-widget__mega-group-title,\n.cn-menu-widget__mega-leaf {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 6px 10px;\n\tbackground: transparent;\n\tborder: none;\n\tborder-radius: var(--border-radius);\n\tcolor: inherit;\n\tfont-size: 14px;\n\tcursor: pointer;\n\ttext-align: left;\n\ttext-decoration: none;\n\tbox-sizing: border-box;\n\twidth: 100%;\n}\n\n.cn-menu-widget__mega-group-title {\n\tfont-weight: 600;\n}\n\n.cn-menu-widget__bar-button:hover,\n.cn-menu-widget__dropdown-item:hover,\n.cn-menu-widget__mega-group-title:hover,\n.cn-menu-widget__mega-leaf:hover {\n\tbackground: var(--color-background-hover);\n}\n\n.cn-menu-widget__bar-button:focus-visible,\n.cn-menu-widget__dropdown-item:focus-visible {\n\toutline: 2px solid var(--color-primary);\n\toutline-offset: 2px;\n}\n\n.cn-menu-widget__icon {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\twidth: 20px;\n\theight: 20px;\n}\n\n.cn-menu-widget__icon--hidden {\n\tvisibility: hidden;\n}\n\n.cn-menu-widget__caret {\n\tmargin-left: auto;\n\tfont-size: 10px;\n}\n\n.cn-menu-widget__dropdown {\n\tposition: absolute;\n\ttop: 100%;\n\tleft: 0;\n\tz-index: 50;\n\tmargin: 4px 0 0;\n\tpadding: 4px;\n\tmin-width: 180px;\n\tbackground: var(--color-main-background);\n\tborder: 1px solid var(--color-border);\n\tborder-radius: var(--border-radius);\n\tbox-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);\n\tlist-style: none;\n}\n\n.cn-menu-widget__dropdown-item-wrap {\n\tposition: relative;\n}\n\n.cn-menu-widget__flyout {\n\tposition: absolute;\n\ttop: 0;\n\tleft: 100%;\n\tmargin-left: 2px;\n\tpadding: 4px;\n\tmin-width: 160px;\n\tbackground: var(--color-main-background);\n\tborder: 1px solid var(--color-border);\n\tborder-radius: var(--border-radius);\n\tbox-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);\n\tlist-style: none;\n}\n\n.cn-menu-widget__mega-panel {\n\tmargin-top: 4px;\n\tpadding: 12px;\n\tdisplay: grid;\n\tgrid-template-columns: repeat(auto-fit, minmax(160px, 1fr));\n\tgap: 12px;\n\tbackground: var(--color-main-background);\n\tborder: 1px solid var(--color-border);\n\tborder-radius: var(--border-radius);\n}\n\n.cn-menu-widget__mega-leaves {\n\tmargin: 4px 0 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.cn-menu-widget__tree {\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n/* Active-item highlight variants. */\n.cn-menu-widget--highlight-underline .cn-menu-widget__item--active {\n\tborder-bottom: 3px solid var(--color-primary);\n}\n\n.cn-menu-widget--highlight-underline .cn-menu-widget__item--in-path {\n\tborder-bottom: 2px solid var(--color-primary);\n\topacity: 0.85;\n}\n\n.cn-menu-widget--highlight-background .cn-menu-widget__item--active,\n.cn-menu-widget--highlight-background .cn-menu-widget__item--in-path {\n\tbackground: var(--color-primary-element-light, rgba(0, 112, 192, 0.1));\n\tcolor: var(--color-primary);\n}\n\n.cn-menu-widget--highlight-left-bar .cn-menu-widget__item--active {\n\tborder-left: 5px solid var(--color-primary);\n\tpadding-left: 6px;\n}\n\n.cn-menu-widget--highlight-left-bar .cn-menu-widget__item--in-path {\n\tborder-left: 4px solid var(--color-primary);\n\tpadding-left: 7px;\n\topacity: 0.85;\n}\n</style>\n"],"names":["CnMenuTreeNode","CnMenuItemIcon"],"mappings":"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"}
|