@djangocfg/ui-tools 2.1.431 → 2.1.433

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/package.json +28 -14
  2. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  3. package/src/common/CodePanelHeader/index.ts +1 -0
  4. package/src/common/blocks/BlockError.tsx +46 -0
  5. package/src/common/blocks/index.ts +34 -0
  6. package/src/common/blocks/schemas.ts +143 -0
  7. package/src/common/blocks/types.ts +76 -0
  8. package/src/common/chips/README.md +67 -0
  9. package/src/common/chips/UrlChip.tsx +105 -0
  10. package/src/common/chips/index.ts +20 -0
  11. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  12. package/src/common/link-preview/index.ts +12 -0
  13. package/src/common/link-preview/types.ts +44 -0
  14. package/src/common/tiptap/index.ts +10 -0
  15. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  16. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  17. package/src/tools/chat/README.md +88 -2
  18. package/src/tools/chat/composer/Composer.tsx +45 -12
  19. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  20. package/src/tools/chat/constants.ts +9 -2
  21. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  22. package/src/tools/chat/core/reducer.ts +32 -8
  23. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  24. package/src/tools/chat/core/transport/mock.ts +4 -1
  25. package/src/tools/chat/hooks/useChat.ts +20 -20
  26. package/src/tools/chat/lazy.tsx +10 -0
  27. package/src/tools/chat/messages/Attachments.tsx +267 -42
  28. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  29. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  30. package/src/tools/chat/messages/MessageList.tsx +107 -21
  31. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  32. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  33. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  34. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  35. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  36. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  37. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  38. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  39. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  40. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  41. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  42. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  43. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  44. package/src/tools/chat/messages/index.ts +13 -0
  45. package/src/tools/chat/public.ts +5 -1
  46. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  47. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  48. package/src/tools/chat/styles/index.ts +1 -0
  49. package/src/tools/chat/types/attachment.ts +26 -2
  50. package/src/tools/chat/types/block.ts +56 -9
  51. package/src/tools/chat/types/blockSchemas.ts +197 -0
  52. package/src/tools/chat/types/config.ts +33 -0
  53. package/src/tools/chat/types/events.ts +14 -7
  54. package/src/tools/chat/types/index.ts +4 -2
  55. package/src/tools/chat/types/message.ts +7 -0
  56. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  57. package/src/tools/dev/Map/README.md +30 -345
  58. package/src/tools/dev/Map/cards/index.ts +6 -0
  59. package/src/tools/dev/Map/cards/types.ts +67 -0
  60. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  61. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  62. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  63. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  64. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  65. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  66. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  67. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  68. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  69. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  70. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  71. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  72. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  73. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  74. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  75. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  76. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  77. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  78. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  79. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  80. package/src/tools/dev/Map/components/index.ts +35 -1
  81. package/src/tools/dev/Map/geocode/index.ts +2 -0
  82. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  83. package/src/tools/dev/Map/hooks/index.ts +37 -0
  84. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  85. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  86. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  87. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  88. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  89. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  90. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  91. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  92. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  93. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  94. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  95. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  96. package/src/tools/dev/Map/index.ts +59 -2
  97. package/src/tools/dev/Map/layers/index.ts +1 -0
  98. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  99. package/src/tools/dev/Map/lazy.tsx +120 -0
  100. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  101. package/src/tools/dev/Map/styles/index.ts +62 -0
  102. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  103. package/src/tools/dev/Map/types.ts +31 -3
  104. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  105. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  106. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  107. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  108. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  109. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  110. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  111. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  112. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  113. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  114. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  115. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  116. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  117. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  118. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  119. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  120. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  121. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  122. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  123. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  124. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  125. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  126. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  127. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  128. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  129. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  130. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  131. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  132. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  133. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  134. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  135. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  136. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  137. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  138. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  139. package/src/tools/forms/NotionEditor/README.md +109 -188
  140. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  141. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  142. package/src/tools/forms/NotionEditor/styles.css +25 -0
  143. package/src/tools/forms/NotionEditor/types.ts +9 -0
  144. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  145. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  146. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  147. package/src/tools/media/ImageViewer/README.md +9 -0
  148. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  149. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  150. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  151. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  152. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  153. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  154. package/src/tools/media/ImageViewer/types.ts +10 -0
  155. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  156. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  157. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  158. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  159. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  160. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  161. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  162. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  163. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  164. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  165. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  166. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  167. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  168. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  169. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  170. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  171. package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
@@ -0,0 +1,326 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState, type ComponentType } from 'react'
4
+ import Map, {
5
+ type ViewStateChangeEvent,
6
+ type GeolocateControlInstance,
7
+ } from 'react-map-gl/maplibre'
8
+ import 'maplibre-gl/dist/maplibre-gl.css'
9
+
10
+ import { useMapContext } from '../../context'
11
+ import { useCompass, useMapScrollProtection, useMapTouchMode, useTerrain } from '../../hooks'
12
+ import { MapToolbar } from './MapToolbar'
13
+ import { ScrollLockOverlay } from './ScrollLockOverlay'
14
+ import { RotateHint } from './RotateHint'
15
+ import { FullscreenDialog } from './FullscreenDialog'
16
+ import { GeolocateHandle, type GeolocateState } from './GeolocateHandle'
17
+ import { GeocoderControl } from '../GeocoderControl'
18
+ import { useBasemapState } from './hooks/useBasemapState'
19
+ import { useExternalMapsUrl } from './hooks/useExternalMapsUrl'
20
+ import { useAutoReset } from './hooks/useAutoReset'
21
+ import type { MapContainerProps, MapInnerProps } from './types'
22
+
23
+ export function MapInner({
24
+ children,
25
+ mapStyle = 'light',
26
+ interactiveLayerIds,
27
+ style,
28
+ cursor,
29
+ attributionControl = false,
30
+ // `reuseMaps` pools maplibre instances across mounts. It saves init cost
31
+ // but in practice (Storybook HMR, switching stories, chat lists mounting/
32
+ // unmounting map blocks) a reused instance comes back with no style —
33
+ // a blank white map that never requests its style.json. Default OFF for
34
+ // correctness; opt back in for a single long-lived full-page map.
35
+ reuseMaps = false,
36
+ openInMapsUrl,
37
+ openInMapsLabel = 'Open in Maps',
38
+ googleMapsButton = true,
39
+ autoResetDelay = 0,
40
+ showResetButton = false,
41
+ scrollProtection = true,
42
+ scrollProtectionHint = 'Click to interact',
43
+ fullscreenButton = true,
44
+ fullscreenLabel = 'Expand map',
45
+ fullscreenTitle = 'Map',
46
+ basemapSwitcher = true,
47
+ basemaps,
48
+ basemapLabel = 'Map style',
49
+ onBasemapChange,
50
+ terrain = false,
51
+ terrainButton = true,
52
+ terrainLabel = '3D terrain',
53
+ onTerrainChange,
54
+ compassButton = true,
55
+ compassLabel = 'Reset bearing to north',
56
+ geolocate = false,
57
+ geolocateLabel = 'Show your location',
58
+ geolocateOptions,
59
+ onGeolocate,
60
+ onGeolocateError,
61
+ geocoder = false,
62
+ geocoderResolve,
63
+ geocoderPlaceholder,
64
+ onGeocoderResult,
65
+ onClose,
66
+ ariaLabel = 'Interactive map',
67
+ MapContainerComponent,
68
+ }: MapInnerProps & { MapContainerComponent: ComponentType<MapContainerProps> }) {
69
+ const { mapRef, viewport, setViewport, setIsLoaded, resetToInitial, initialViewport } = useMapContext()
70
+
71
+ // Active basemap — seeded from `mapStyle`, then driven by the switcher.
72
+ const { activeKey, effectiveStyle, resolvedStyle, basemapOptions, handleBasemapPick } =
73
+ useBasemapState({ mapStyle, basemaps, onBasemapChange })
74
+
75
+ // 3D terrain — seeded from the `terrain` prop, then driven by the chip.
76
+ const [terrainOn, setTerrainOn] = useState(terrain)
77
+ useEffect(() => setTerrainOn(terrain), [terrain])
78
+ useTerrain({ enabled: terrainOn })
79
+
80
+ // Brief gesture hint when 3D turns on — rotation/tilt isn't discoverable
81
+ // (it's right-drag / Ctrl+drag), so surface it, then auto-hide.
82
+ const [showRotateHint, setShowRotateHint] = useState(false)
83
+ useEffect(() => {
84
+ if (!terrainOn) {
85
+ setShowRotateHint(false)
86
+ return
87
+ }
88
+ setShowRotateHint(true)
89
+ const t = setTimeout(() => setShowRotateHint(false), 4500)
90
+ return () => clearTimeout(t)
91
+ }, [terrainOn])
92
+ const handleTerrainToggle = useCallback(() => {
93
+ setTerrainOn((prev) => {
94
+ const next = !prev
95
+ onTerrainChange?.(next)
96
+ return next
97
+ })
98
+ }, [onTerrainChange])
99
+
100
+ // Compass — reset the camera to a north-up, top-down view. Google-style:
101
+ // the chip only shows when the view is rotated or tilted, and its needle
102
+ // rotates to reflect the current bearing. One click eases back to north.
103
+ // Math/reset live in `useCompass`; the toolbar gates the chip on it.
104
+ const { bearing, isOriented, resetNorth } = useCompass()
105
+
106
+ // Locate-me (GPS). Drives maplibre's built-in GeolocateControl via its
107
+ // instance ref; the chip calls `trigger()` and reflects active/error from
108
+ // the control's events. Privacy: the control is only mounted when
109
+ // `geolocate` is true, and nothing prompts permission until the chip is
110
+ // pressed (`trigger()`) — never on mount.
111
+ const geolocateRef = useRef<GeolocateControlInstance | null>(null)
112
+ const [geolocateState, setGeolocateState] = useState<GeolocateState>('idle')
113
+ const handleGeolocateTrigger = useCallback(() => {
114
+ // A new trigger after an error is a retry — clear the disabled look so
115
+ // the events can re-drive state.
116
+ setGeolocateState((s) => (s === 'error' ? 'idle' : s))
117
+ geolocateRef.current?.trigger()
118
+ }, [])
119
+
120
+ // Geocoder search — a toolbar magnifier chip toggles an on-map autocomplete
121
+ // (Google-Maps style). Collapsed by default; the chip flips `geocoderOpen`,
122
+ // which mounts the controlled `GeocoderControl` (autofocused). Picking a
123
+ // result / Esc / re-clicking the chip closes it.
124
+ const [geocoderOpen, setGeocoderOpen] = useState(false)
125
+ const handleGeocoderToggle = useCallback(() => setGeocoderOpen((o) => !o), [])
126
+ const closeGeocoder = useCallback(() => setGeocoderOpen(false), [])
127
+
128
+ // External-maps link (host URL wins, else Google Maps at current center).
129
+ const { externalMapsUrl, externalMapsLabel } = useExternalMapsUrl({
130
+ openInMapsUrl,
131
+ openInMapsLabel,
132
+ googleMapsButton,
133
+ viewport,
134
+ })
135
+
136
+ // Fullscreen — opens a fresh map in a large dialog, seeded with the
137
+ // viewport the user is currently looking at.
138
+ const [fullscreenOpen, setFullscreenOpen] = useState(false)
139
+
140
+ // `onClose` is only ever passed to the dialog's own (fullscreen) map, so
141
+ // it doubles as "this instance is the expanded one" — there gestures are
142
+ // always live.
143
+ const isFullscreenInstance = !!onClose
144
+
145
+ // Gesture mode. Touch / narrow inline maps are made fully static (a
146
+ // finger drags the PAGE, never the map); the user taps to expand into
147
+ // the fullscreen dialog where the map is fully interactive. Desktop
148
+ // keeps the Google-Maps scroll-isolation. The expanded map is always
149
+ // live. Detection is `matchMedia`-based so it reacts to DevTools device
150
+ // emulation, real touchscreens and window resizes (see useMapTouchMode).
151
+ const isTouch = useMapTouchMode()
152
+ const protectionMode = !scrollProtection || isFullscreenInstance
153
+ ? 'off'
154
+ : isTouch
155
+ ? 'mobile'
156
+ : 'desktop'
157
+
158
+ const { locked, unlock, isMobileLock } = useMapScrollProtection(protectionMode)
159
+
160
+ // Overlay tap: desktop unlocks in place; mobile opens fullscreen.
161
+ const handleOverlayActivate = useCallback(() => {
162
+ if (isMobileLock) {
163
+ setFullscreenOpen(true)
164
+ } else {
165
+ unlock()
166
+ }
167
+ }, [isMobileLock, unlock])
168
+
169
+ // Hint adapts to the gesture model.
170
+ const resolvedHint = isMobileLock ? 'Tap to expand' : scrollProtectionHint
171
+
172
+ // Check if viewport has changed from initial
173
+ const hasViewportChanged =
174
+ Math.abs(viewport.longitude - initialViewport.longitude) > 0.0001 ||
175
+ Math.abs(viewport.latitude - initialViewport.latitude) > 0.0001 ||
176
+ Math.abs(viewport.zoom - initialViewport.zoom) > 0.1
177
+
178
+ const handleMove = useCallback(
179
+ (evt: ViewStateChangeEvent) => {
180
+ setViewport({
181
+ longitude: evt.viewState.longitude,
182
+ latitude: evt.viewState.latitude,
183
+ zoom: evt.viewState.zoom,
184
+ bearing: evt.viewState.bearing,
185
+ pitch: evt.viewState.pitch,
186
+ })
187
+ },
188
+ [setViewport]
189
+ )
190
+
191
+ const { handleMoveStart, handleMoveEnd } = useAutoReset({ autoResetDelay, resetToInitial })
192
+
193
+ const handleLoad = useCallback(() => {
194
+ setIsLoaded(true)
195
+ }, [setIsLoaded])
196
+
197
+ return (
198
+ <>
199
+ <Map
200
+ ref={mapRef}
201
+ {...viewport}
202
+ onMove={handleMove}
203
+ onMoveStart={handleMoveStart}
204
+ onMoveEnd={handleMoveEnd}
205
+ onLoad={handleLoad}
206
+ mapStyle={resolvedStyle}
207
+ interactiveLayerIds={interactiveLayerIds}
208
+ attributionControl={attributionControl ? {} : false}
209
+ reuseMaps={reuseMaps}
210
+ style={{
211
+ width: '100%',
212
+ height: '100%',
213
+ ...style,
214
+ }}
215
+ cursor={cursor}
216
+ aria-label={ariaLabel}
217
+ >
218
+ {geolocate && (
219
+ <GeolocateHandle
220
+ ref={geolocateRef}
221
+ options={geolocateOptions ?? {}}
222
+ onGeolocate={onGeolocate}
223
+ onGeolocateError={onGeolocateError}
224
+ onStateChange={setGeolocateState}
225
+ />
226
+ )}
227
+ {/* Toolbar-toggled geocoder. Controlled (open driven by the chip),
228
+ autofocused on reveal. Rendered inside <Map> so its dropped pin
229
+ (a react-map-gl Marker) has the map context; the input overlay is
230
+ absolute-positioned top-left, clear of the top-right chip column. */}
231
+ {geocoder && (
232
+ <GeocoderControl
233
+ open={geocoderOpen}
234
+ onClose={closeGeocoder}
235
+ autoFocus
236
+ resolve={geocoderResolve}
237
+ placeholder={geocoderPlaceholder}
238
+ onResult={onGeocoderResult}
239
+ />
240
+ )}
241
+ {children}
242
+ </Map>
243
+
244
+ {locked && (
245
+ <ScrollLockOverlay
246
+ isMobileLock={isMobileLock}
247
+ resolvedHint={resolvedHint}
248
+ onActivate={handleOverlayActivate}
249
+ />
250
+ )}
251
+
252
+ <MapToolbar
253
+ onClose={onClose}
254
+ fullscreenButton={fullscreenButton}
255
+ fullscreenLabel={fullscreenLabel}
256
+ onFullscreenOpen={() => setFullscreenOpen(true)}
257
+ basemapSwitcher={basemapSwitcher}
258
+ basemapOptions={basemapOptions}
259
+ basemapLabel={basemapLabel}
260
+ activeKey={activeKey}
261
+ onBasemapPick={handleBasemapPick}
262
+ terrainButton={terrainButton}
263
+ terrainLabel={terrainLabel}
264
+ terrainOn={terrainOn}
265
+ onTerrainToggle={handleTerrainToggle}
266
+ compassButton={compassButton}
267
+ isOriented={isOriented}
268
+ compassLabel={compassLabel}
269
+ bearing={bearing}
270
+ onResetNorth={resetNorth}
271
+ geolocate={geolocate}
272
+ geolocateLabel={geolocateLabel}
273
+ geolocateState={geolocateState}
274
+ onGeolocateTrigger={handleGeolocateTrigger}
275
+ geocoder={geocoder}
276
+ geocoderOpen={geocoderOpen}
277
+ onGeocoderToggle={handleGeocoderToggle}
278
+ externalMapsUrl={externalMapsUrl}
279
+ externalMapsLabel={externalMapsLabel}
280
+ showResetButton={showResetButton}
281
+ hasViewportChanged={hasViewportChanged}
282
+ onResetToInitial={resetToInitial}
283
+ />
284
+
285
+ <RotateHint show={showRotateHint} />
286
+
287
+ {fullscreenButton && (
288
+ <FullscreenDialog
289
+ MapContainer={MapContainerComponent}
290
+ open={fullscreenOpen}
291
+ onOpenChange={setFullscreenOpen}
292
+ fullscreenTitle={fullscreenTitle}
293
+ viewport={viewport}
294
+ effectiveStyle={effectiveStyle}
295
+ interactiveLayerIds={interactiveLayerIds}
296
+ openInMapsUrl={openInMapsUrl}
297
+ openInMapsLabel={openInMapsLabel}
298
+ googleMapsButton={googleMapsButton}
299
+ basemapSwitcher={basemapSwitcher}
300
+ basemaps={basemaps}
301
+ onBasemapChange={onBasemapChange}
302
+ terrainOn={terrainOn}
303
+ terrainButton={terrainButton}
304
+ terrainLabel={terrainLabel}
305
+ onTerrainChange={onTerrainChange}
306
+ compassButton={compassButton}
307
+ compassLabel={compassLabel}
308
+ geolocate={geolocate}
309
+ geolocateLabel={geolocateLabel}
310
+ geolocateOptions={geolocateOptions}
311
+ onGeolocate={onGeolocate}
312
+ onGeolocateError={onGeolocateError}
313
+ geocoder={geocoder}
314
+ geocoderResolve={geocoderResolve}
315
+ geocoderPlaceholder={geocoderPlaceholder}
316
+ onGeocoderResult={onGeocoderResult}
317
+ showResetButton={showResetButton}
318
+ ariaLabel={ariaLabel}
319
+ onClose={() => setFullscreenOpen(false)}
320
+ >
321
+ {children}
322
+ </FullscreenDialog>
323
+ )}
324
+ </>
325
+ )
326
+ }
@@ -0,0 +1,286 @@
1
+ 'use client'
2
+
3
+ import { Layers, LocateFixed, MapPin, Maximize2, Mountain, RotateCcw, Search, X } from 'lucide-react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuRadioGroup,
9
+ DropdownMenuRadioItem,
10
+ DropdownMenuTrigger,
11
+ Tooltip,
12
+ TooltipContent,
13
+ TooltipTrigger,
14
+ } from '@djangocfg/ui-core/components'
15
+
16
+ import { MAP_STYLE_LABELS } from '../../styles'
17
+ import type { MapStyleKey } from '../../types'
18
+ import { CompassChip } from '../CompassChip'
19
+ import { MAP_CONTROL_CLASS } from './constants'
20
+ import type { GeolocateState } from './GeolocateHandle'
21
+
22
+ /**
23
+ * Map controls — one icon-only toolbar, top-right (Google-Maps style). All
24
+ * chips are a uniform 36×36 square; labels live in tooltips (shown on the LEFT
25
+ * so they never spill off the map's right edge). Sits above the scroll-lock
26
+ * overlay so it stays clickable.
27
+ *
28
+ * The `data-map-control` attribute is load-bearing: the scroll-protection
29
+ * re-lock skips clicks inside it.
30
+ */
31
+ export function MapToolbar({
32
+ onClose,
33
+ fullscreenButton,
34
+ fullscreenLabel,
35
+ onFullscreenOpen,
36
+ basemapSwitcher,
37
+ basemapOptions,
38
+ basemapLabel,
39
+ activeKey,
40
+ onBasemapPick,
41
+ terrainButton,
42
+ terrainLabel,
43
+ terrainOn,
44
+ onTerrainToggle,
45
+ compassButton,
46
+ isOriented,
47
+ compassLabel,
48
+ bearing,
49
+ onResetNorth,
50
+ geolocate,
51
+ geolocateLabel,
52
+ geolocateState,
53
+ onGeolocateTrigger,
54
+ geocoder,
55
+ geocoderOpen,
56
+ onGeocoderToggle,
57
+ externalMapsUrl,
58
+ externalMapsLabel,
59
+ showResetButton,
60
+ hasViewportChanged,
61
+ onResetToInitial,
62
+ }: {
63
+ onClose: (() => void) | undefined
64
+ fullscreenButton: boolean
65
+ fullscreenLabel: string
66
+ onFullscreenOpen: () => void
67
+ basemapSwitcher: boolean
68
+ basemapOptions: MapStyleKey[]
69
+ basemapLabel: string
70
+ activeKey: MapStyleKey | undefined
71
+ onBasemapPick: (key: string) => void
72
+ terrainButton: boolean
73
+ terrainLabel: string
74
+ terrainOn: boolean
75
+ onTerrainToggle: () => void
76
+ compassButton: boolean
77
+ isOriented: boolean
78
+ compassLabel: string
79
+ bearing: number
80
+ onResetNorth: () => void
81
+ geolocate: boolean
82
+ geolocateLabel: string
83
+ geolocateState: GeolocateState
84
+ onGeolocateTrigger: () => void
85
+ geocoder: boolean
86
+ geocoderOpen: boolean
87
+ onGeocoderToggle: () => void
88
+ externalMapsUrl: string | undefined
89
+ externalMapsLabel: string
90
+ showResetButton: boolean
91
+ hasViewportChanged: boolean
92
+ onResetToInitial: () => void
93
+ }) {
94
+ return (
95
+ <div
96
+ data-map-control
97
+ className="absolute top-3 right-3 z-20 flex flex-col items-end gap-2"
98
+ >
99
+ {onClose && (
100
+ <Tooltip>
101
+ <TooltipTrigger asChild>
102
+ <button
103
+ type="button"
104
+ onClick={onClose}
105
+ aria-label="Close map"
106
+ className={cn(MAP_CONTROL_CLASS, 'w-9 h-9')}
107
+ >
108
+ <X className="w-4 h-4" aria-hidden="true" />
109
+ </button>
110
+ </TooltipTrigger>
111
+ <TooltipContent side="left">Close</TooltipContent>
112
+ </Tooltip>
113
+ )}
114
+
115
+ {fullscreenButton && (
116
+ <Tooltip>
117
+ <TooltipTrigger asChild>
118
+ <button
119
+ type="button"
120
+ onClick={onFullscreenOpen}
121
+ aria-label={fullscreenLabel}
122
+ className={cn(MAP_CONTROL_CLASS, 'w-9 h-9')}
123
+ >
124
+ <Maximize2 className="w-4 h-4" aria-hidden="true" />
125
+ </button>
126
+ </TooltipTrigger>
127
+ <TooltipContent side="left">{fullscreenLabel}</TooltipContent>
128
+ </Tooltip>
129
+ )}
130
+
131
+ {/* Search (geocoder) — Google-Maps-style magnifier that toggles an
132
+ on-map autocomplete (rendered top-left by MapInner). Tinted
133
+ text-primary while open; aria-pressed mirrors the open state. */}
134
+ {geocoder && (
135
+ <Tooltip>
136
+ <TooltipTrigger asChild>
137
+ <button
138
+ type="button"
139
+ onClick={onGeocoderToggle}
140
+ aria-label="Search the map"
141
+ aria-pressed={geocoderOpen}
142
+ className={cn(
143
+ MAP_CONTROL_CLASS,
144
+ 'w-9 h-9',
145
+ geocoderOpen && 'bg-popover text-primary',
146
+ )}
147
+ >
148
+ <Search className="w-4 h-4" aria-hidden="true" />
149
+ </button>
150
+ </TooltipTrigger>
151
+ <TooltipContent side="left">
152
+ {geocoderOpen ? 'Close search' : 'Search'}
153
+ </TooltipContent>
154
+ </Tooltip>
155
+ )}
156
+
157
+ {/* Basemap switcher (Google-Maps "Layers") — pick the map style at
158
+ runtime. Hidden if disabled or there's nothing to switch to. */}
159
+ {basemapSwitcher && basemapOptions.length > 1 && (
160
+ <DropdownMenu>
161
+ <Tooltip>
162
+ <TooltipTrigger asChild>
163
+ <DropdownMenuTrigger
164
+ aria-label={basemapLabel}
165
+ className={cn(MAP_CONTROL_CLASS, 'w-9 h-9')}
166
+ >
167
+ <Layers className="w-4 h-4" aria-hidden="true" />
168
+ </DropdownMenuTrigger>
169
+ </TooltipTrigger>
170
+ <TooltipContent side="left">{basemapLabel}</TooltipContent>
171
+ </Tooltip>
172
+ <DropdownMenuContent align="end" side="left" className="min-w-36">
173
+ <DropdownMenuRadioGroup
174
+ value={activeKey ?? ''}
175
+ onValueChange={onBasemapPick}
176
+ >
177
+ {basemapOptions.map((key) => (
178
+ <DropdownMenuRadioItem key={key} value={key}>
179
+ {MAP_STYLE_LABELS[key]}
180
+ </DropdownMenuRadioItem>
181
+ ))}
182
+ </DropdownMenuRadioGroup>
183
+ </DropdownMenuContent>
184
+ </DropdownMenu>
185
+ )}
186
+
187
+ {/* 3D terrain toggle (Mountain) — adds the free DEM terrain +
188
+ hillshade and pitches the camera in so it reads as 3D. */}
189
+ {terrainButton && (
190
+ <Tooltip>
191
+ <TooltipTrigger asChild>
192
+ <button
193
+ type="button"
194
+ onClick={onTerrainToggle}
195
+ aria-label={terrainLabel}
196
+ aria-pressed={terrainOn}
197
+ className={cn(
198
+ MAP_CONTROL_CLASS,
199
+ 'w-9 h-9',
200
+ terrainOn && 'bg-popover text-primary',
201
+ )}
202
+ >
203
+ <Mountain className="w-4 h-4" aria-hidden="true" />
204
+ </button>
205
+ </TooltipTrigger>
206
+ <TooltipContent side="left">{terrainLabel}</TooltipContent>
207
+ </Tooltip>
208
+ )}
209
+
210
+ {/* Compass — shows only when rotated/tilted (gated here); the chip's
211
+ needle reflects the bearing and tints accent as an off-north cue,
212
+ click eases back to north-up + top-down (Google style). */}
213
+ {compassButton && isOriented && (
214
+ <CompassChip
215
+ bearing={bearing}
216
+ oriented={isOriented}
217
+ onReset={onResetNorth}
218
+ label={compassLabel}
219
+ />
220
+ )}
221
+
222
+ {/* Locate me (GPS) — drives maplibre's hidden GeolocateControl via
223
+ trigger(). Tinted text-primary while actively following (like the
224
+ terrain chip); muted/disabled look on permission-denied or
225
+ unavailable, with a tooltip explaining it. Opt-in only. */}
226
+ {geolocate && (
227
+ <Tooltip>
228
+ <TooltipTrigger asChild>
229
+ <button
230
+ type="button"
231
+ onClick={onGeolocateTrigger}
232
+ aria-label={geolocateLabel}
233
+ aria-pressed={geolocateState === 'active'}
234
+ className={cn(
235
+ MAP_CONTROL_CLASS,
236
+ 'w-9 h-9',
237
+ geolocateState === 'active' && 'bg-popover text-primary',
238
+ geolocateState === 'error' && 'text-muted-foreground',
239
+ )}
240
+ >
241
+ <LocateFixed className="w-4 h-4" aria-hidden="true" />
242
+ </button>
243
+ </TooltipTrigger>
244
+ <TooltipContent side="left">
245
+ {geolocateState === 'error'
246
+ ? 'Location unavailable — check permission'
247
+ : geolocateLabel}
248
+ </TooltipContent>
249
+ </Tooltip>
250
+ )}
251
+
252
+ {externalMapsUrl && (
253
+ <Tooltip>
254
+ <TooltipTrigger asChild>
255
+ <a
256
+ href={externalMapsUrl}
257
+ target="_blank"
258
+ rel="noopener noreferrer"
259
+ aria-label={externalMapsLabel}
260
+ className={cn(MAP_CONTROL_CLASS, 'w-9 h-9')}
261
+ >
262
+ <MapPin className="w-4 h-4" aria-hidden="true" />
263
+ </a>
264
+ </TooltipTrigger>
265
+ <TooltipContent side="left">{externalMapsLabel}</TooltipContent>
266
+ </Tooltip>
267
+ )}
268
+
269
+ {showResetButton && hasViewportChanged && (
270
+ <Tooltip>
271
+ <TooltipTrigger asChild>
272
+ <button
273
+ type="button"
274
+ onClick={onResetToInitial}
275
+ aria-label="Reset map view"
276
+ className={cn(MAP_CONTROL_CLASS, 'w-9 h-9')}
277
+ >
278
+ <RotateCcw className="w-4 h-4" aria-hidden="true" />
279
+ </button>
280
+ </TooltipTrigger>
281
+ <TooltipContent side="left">Reset to the initial view</TooltipContent>
282
+ </Tooltip>
283
+ )}
284
+ </div>
285
+ )
286
+ }
@@ -0,0 +1,25 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib'
4
+
5
+ /**
6
+ * Gesture hint — appears briefly when 3D turns on, since rotate/tilt
7
+ * (right-drag / Ctrl+drag / two fingers) isn't otherwise discoverable. The
8
+ * compass chip then offers a one-click reset back to north.
9
+ */
10
+ export function RotateHint({ show }: { show: boolean }) {
11
+ return (
12
+ <div
13
+ aria-hidden={!show}
14
+ className={cn(
15
+ 'pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2',
16
+ 'rounded-full px-3 py-1.5 text-xs font-medium',
17
+ 'bg-popover/95 text-popover-foreground border border-border shadow-sm',
18
+ 'transition-opacity duration-300',
19
+ show ? 'opacity-100' : 'opacity-0',
20
+ )}
21
+ >
22
+ Right-click or Ctrl+drag to rotate &amp; tilt
23
+ </div>
24
+ )
25
+ }
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { cn } from '@djangocfg/ui-core/lib'
5
+
6
+ /**
7
+ * Gesture overlay. Desktop: tap unlocks scroll-zoom in place. Mobile: the map
8
+ * is fully static, tap expands to fullscreen. `touch-action: pan-y` lets a
9
+ * vertical swipe scroll the PAGE straight through the overlay so the static
10
+ * map never traps the scroll.
11
+ */
12
+ export function ScrollLockOverlay({
13
+ isMobileLock,
14
+ resolvedHint,
15
+ onActivate,
16
+ }: {
17
+ isMobileLock: boolean
18
+ resolvedHint: string
19
+ onActivate: () => void
20
+ }) {
21
+ const [overlayHovered, setOverlayHovered] = useState(false)
22
+
23
+ return (
24
+ <button
25
+ type="button"
26
+ onClick={onActivate}
27
+ onMouseEnter={() => setOverlayHovered(true)}
28
+ onMouseLeave={() => setOverlayHovered(false)}
29
+ aria-label={`${resolvedHint} (map interaction is locked)`}
30
+ style={{ touchAction: 'pan-y' }}
31
+ className={cn(
32
+ 'absolute inset-0 z-10 flex items-center justify-center',
33
+ 'cursor-pointer border-0 bg-transparent p-0 transition-colors',
34
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
35
+ overlayHovered && 'bg-foreground/5'
36
+ )}
37
+ >
38
+ <span
39
+ className={cn(
40
+ // Same popover surface as the control chips — floating hint.
41
+ // Mobile has no hover, so the "Tap to expand" hint is shown
42
+ // persistently; desktop reveals it on hover.
43
+ 'pointer-events-none select-none rounded-full px-3 py-1.5 text-xs font-medium',
44
+ 'bg-popover/95 text-popover-foreground border border-border shadow-sm transition-opacity',
45
+ isMobileLock || overlayHovered ? 'opacity-100' : 'opacity-0'
46
+ )}
47
+ >
48
+ {resolvedHint}
49
+ </span>
50
+ </button>
51
+ )
52
+ }