shadcn-map 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/components/ClusterLayer.svelte +511 -0
  2. package/dist/components/ClusterLayer.svelte.d.ts +29 -0
  3. package/dist/components/DetailsPanel.svelte +180 -0
  4. package/dist/components/DetailsPanel.svelte.d.ts +18 -0
  5. package/dist/components/Map.svelte +321 -0
  6. package/dist/components/Map.svelte.d.ts +63 -0
  7. package/dist/components/Marker.svelte +594 -0
  8. package/dist/components/Marker.svelte.d.ts +34 -0
  9. package/dist/components/Popup.svelte +228 -0
  10. package/dist/components/Popup.svelte.d.ts +18 -0
  11. package/dist/components/controls/GeolocateControl.svelte +141 -0
  12. package/dist/components/controls/GeolocateControl.svelte.d.ts +22 -0
  13. package/dist/components/controls/NavigationControl.svelte +50 -0
  14. package/dist/components/controls/NavigationControl.svelte.d.ts +12 -0
  15. package/dist/components/controls/ScaleControl.svelte +49 -0
  16. package/dist/components/controls/ScaleControl.svelte.d.ts +12 -0
  17. package/dist/components/controls/index.d.ts +6 -0
  18. package/dist/components/controls/index.js +3 -0
  19. package/dist/components/index.d.ts +13 -0
  20. package/dist/components/index.js +7 -0
  21. package/dist/context.svelte.d.ts +37 -0
  22. package/dist/context.svelte.js +96 -0
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.js +4 -0
  25. package/dist/styles/colors.d.ts +54 -0
  26. package/dist/styles/colors.js +53 -0
  27. package/dist/styles/dark.d.ts +7 -0
  28. package/dist/styles/dark.js +168 -0
  29. package/dist/styles/index.d.ts +3 -0
  30. package/dist/styles/index.js +3 -0
  31. package/dist/styles/light.d.ts +7 -0
  32. package/dist/styles/light.js +168 -0
  33. package/dist/theme.d.ts +2 -0
  34. package/dist/theme.js +34 -0
  35. package/dist/types.d.ts +56 -0
  36. package/dist/types.js +1 -0
  37. package/package.json +72 -0
@@ -0,0 +1,511 @@
1
+ <script lang='ts' module>
2
+ export interface ClusterPoint {
3
+ /** Unique identifier */
4
+ id: string | number
5
+ /** Position [lng, lat] */
6
+ lngLat: [number, number]
7
+ /** Additional properties */
8
+ properties?: Record<string, unknown>
9
+ }
10
+
11
+ export interface ClusterLayerProps {
12
+ /** Array of points to cluster */
13
+ points: ClusterPoint[]
14
+ /** Click callback for individual points */
15
+ onclick?: (point: ClusterPoint) => void
16
+ /** Click callback for clusters */
17
+ onclusterclick?: (clusterId: number, zoom: number) => void
18
+ /** Cluster radius in pixels */
19
+ clusterRadius?: number
20
+ /** Max zoom to cluster at */
21
+ clusterMaxZoom?: number
22
+ /** Whether to render unclustered points */
23
+ showUnclustered?: boolean
24
+ /** Callback with unclustered point ids */
25
+ onunclusteredchange?: (ids: Set<string>) => void
26
+ /** Callback when cluster source is loading */
27
+ onclusterloadingchange?: (loading: boolean) => void
28
+ }
29
+ </script>
30
+
31
+ <script lang='ts'>
32
+ import type { GeoJSONSource, MapLayerMouseEvent, MapLibreMap } from '../types'
33
+ import Supercluster from 'supercluster'
34
+ import { onMount } from 'svelte'
35
+ import { getMapContext } from '../context.svelte'
36
+
37
+ const {
38
+ points,
39
+ onclick,
40
+ onclusterclick,
41
+ clusterRadius = 50,
42
+ clusterMaxZoom = 14,
43
+ showUnclustered = true,
44
+ onunclusteredchange,
45
+ onclusterloadingchange,
46
+ }: ClusterLayerProps = $props()
47
+
48
+ const ctx = getMapContext()
49
+
50
+ const instanceId = `shadcn-cluster-${Math.random().toString(36).slice(2)}`
51
+ const sourceId = `${instanceId}-source`
52
+ const clusterLayerId = `${instanceId}-cluster`
53
+ const clusterCountId = `${instanceId}-count`
54
+ const unclusteredLayerId = `${instanceId}-point`
55
+
56
+ let map: MapLibreMap | null = null
57
+ let pointsById = new Map<string, ClusterPoint>()
58
+ let detachEvents: (() => void) | null = null
59
+ let lastConfigKey = ''
60
+ let unclusteredUpdateScheduled = false
61
+ let lastUnclusteredKey = '__initial__'
62
+ let pointsKey = ''
63
+ let detachClusterStateEvents: (() => void) | null = null
64
+ let clusterLoading = false
65
+
66
+ let supercluster: Supercluster<{ id: string | number }, Supercluster.AnyProps> | null = null
67
+ let lastSuperclusterPointsKey = ''
68
+
69
+ function getIsDarkMode(): boolean {
70
+ if (typeof document === 'undefined') {
71
+ return true
72
+ }
73
+ return document.documentElement.classList.contains('dark')
74
+ }
75
+
76
+ function getThemeColors() {
77
+ const isDark = getIsDarkMode()
78
+ return isDark
79
+ ? {
80
+ clusterLow: '#3f3f46',
81
+ clusterMid: '#52525b',
82
+ clusterHigh: '#71717a',
83
+ clusterStroke: '#09090b',
84
+ clusterText: '#f4f4f5',
85
+ point: '#38bdf8',
86
+ pointStroke: '#0f172a',
87
+ }
88
+ : {
89
+ clusterLow: '#e4e4e7',
90
+ clusterMid: '#d4d4d8',
91
+ clusterHigh: '#a1a1aa',
92
+ clusterStroke: '#ffffff',
93
+ clusterText: '#18181b',
94
+ point: '#0284c7',
95
+ pointStroke: '#ffffff',
96
+ }
97
+ }
98
+
99
+ type ClusterFeatureCollection = {
100
+ type: 'FeatureCollection'
101
+ features: Array<{
102
+ type: 'Feature'
103
+ geometry: {
104
+ type: 'Point'
105
+ coordinates: [number, number]
106
+ }
107
+ properties: Record<string, unknown> & { id: string | number }
108
+ }>
109
+ }
110
+
111
+ function buildGeoJSON(): ClusterFeatureCollection {
112
+ return {
113
+ type: 'FeatureCollection',
114
+ features: points.map(point => ({
115
+ type: 'Feature',
116
+ geometry: {
117
+ type: 'Point',
118
+ coordinates: point.lngLat,
119
+ },
120
+ properties: {
121
+ id: point.id,
122
+ ...point.properties,
123
+ },
124
+ })),
125
+ }
126
+ }
127
+
128
+ function updatePointsLookup() {
129
+ pointsById = new Map(points.map(point => [String(point.id), point]))
130
+ const nextPointsKey = points.map(point => String(point.id)).sort().join('|')
131
+ if (nextPointsKey !== pointsKey) {
132
+ pointsKey = nextPointsKey
133
+ lastUnclusteredKey = '__initial__'
134
+ }
135
+ }
136
+
137
+ function updateSourceData() {
138
+ const source = map?.getSource(sourceId) as GeoJSONSource | undefined
139
+ if (!source) {
140
+ return
141
+ }
142
+ source.setData(buildGeoJSON())
143
+ }
144
+
145
+ function buildUnclusteredKey(ids: Set<string>) {
146
+ if (ids.size === 0) {
147
+ return ''
148
+ }
149
+ return Array.from(ids).sort().join('|')
150
+ }
151
+
152
+ function setClusterLoading(next: boolean) {
153
+ if (clusterLoading === next) {
154
+ return
155
+ }
156
+ clusterLoading = next
157
+ onclusterloadingchange?.(next)
158
+ }
159
+
160
+ function ensureSupercluster() {
161
+ const currentPointsKey = points.map(p => `${p.id}:${p.lngLat[0]},${p.lngLat[1]}`).join('|')
162
+ if (supercluster && currentPointsKey === lastSuperclusterPointsKey) {
163
+ return
164
+ }
165
+
166
+ supercluster = new Supercluster({
167
+ radius: clusterRadius,
168
+ maxZoom: clusterMaxZoom,
169
+ extent: 512,
170
+ map: props => ({ id: props.id }),
171
+ reduce: (acc, props) => { acc.id = props.id },
172
+ })
173
+
174
+ const features = points.map(point => ({
175
+ type: 'Feature' as const,
176
+ geometry: {
177
+ type: 'Point' as const,
178
+ coordinates: point.lngLat as [number, number],
179
+ },
180
+ properties: { id: point.id },
181
+ }))
182
+
183
+ supercluster.load(features)
184
+ lastSuperclusterPointsKey = currentPointsKey
185
+ }
186
+
187
+ function emitUnclusteredIds() {
188
+ if (!map || !onunclusteredchange) {
189
+ return
190
+ }
191
+
192
+ ensureSupercluster()
193
+ if (!supercluster) {
194
+ return
195
+ }
196
+
197
+ const currentZoom = Math.floor(map.getZoom())
198
+
199
+ const clusters = supercluster.getClusters([-180, -90, 180, 90], currentZoom)
200
+
201
+ const ids = new Set<string>()
202
+ for (const feature of clusters) {
203
+ const props = feature.properties
204
+ if (!('cluster' in props) || !props.cluster) {
205
+ const id = (props as { id?: string | number }).id
206
+ if (id !== undefined && id !== null) {
207
+ ids.add(String(id))
208
+ }
209
+ }
210
+ }
211
+
212
+ const key = buildUnclusteredKey(ids)
213
+ if (key === lastUnclusteredKey) {
214
+ return
215
+ }
216
+ lastUnclusteredKey = key
217
+ setClusterLoading(false)
218
+ onunclusteredchange(ids)
219
+ }
220
+
221
+ function scheduleUnclusteredUpdate() {
222
+ if (!onunclusteredchange || unclusteredUpdateScheduled) {
223
+ return
224
+ }
225
+ unclusteredUpdateScheduled = true
226
+
227
+ const run = () => {
228
+ unclusteredUpdateScheduled = false
229
+ emitUnclusteredIds()
230
+ }
231
+
232
+ if (typeof requestAnimationFrame === 'function') {
233
+ requestAnimationFrame(run)
234
+ }
235
+ else {
236
+ setTimeout(run, 0)
237
+ }
238
+ }
239
+
240
+ function attachClusterStateEvents(currentMap: MapLibreMap) {
241
+ if (!onunclusteredchange) {
242
+ return
243
+ }
244
+ detachClusterStateEvents?.()
245
+
246
+ const handleZoomChange = () => {
247
+ emitUnclusteredIds()
248
+ }
249
+
250
+ const handleViewChange = () => {
251
+ scheduleUnclusteredUpdate()
252
+ }
253
+
254
+ currentMap.on('zoom', handleZoomChange)
255
+ currentMap.on('zoomend', handleZoomChange)
256
+ currentMap.on('move', handleViewChange)
257
+ currentMap.on('moveend', handleViewChange)
258
+
259
+ detachClusterStateEvents = () => {
260
+ currentMap.off('zoom', handleZoomChange)
261
+ currentMap.off('zoomend', handleZoomChange)
262
+ currentMap.off('move', handleViewChange)
263
+ currentMap.off('moveend', handleViewChange)
264
+ }
265
+ }
266
+
267
+ function attachLayerEvents(currentMap: MapLibreMap) {
268
+ detachEvents?.()
269
+
270
+ const handleClusterClick = (event: MapLayerMouseEvent) => {
271
+ const feature = event.features?.[0]
272
+ const clusterId = Number(feature?.properties?.cluster_id)
273
+ if (!Number.isFinite(clusterId)) {
274
+ return
275
+ }
276
+
277
+ const source = currentMap.getSource(sourceId) as GeoJSONSource | undefined
278
+ const coordinates = (feature?.geometry as { coordinates?: [number, number] } | undefined)?.coordinates
279
+ if (!source || !coordinates) {
280
+ return
281
+ }
282
+
283
+ Promise.resolve(source.getClusterExpansionZoom(clusterId))
284
+ .then((zoom) => {
285
+ const center = coordinates as [number, number]
286
+ currentMap.easeTo({ center, zoom })
287
+ onclusterclick?.(clusterId, zoom)
288
+ })
289
+ .catch(() => {})
290
+ }
291
+
292
+ const handlePointClick = (event: MapLayerMouseEvent) => {
293
+ const feature = event.features?.[0]
294
+ const id = feature?.properties?.id
295
+ const point = pointsById.get(String(id))
296
+ if (point) {
297
+ onclick?.(point)
298
+ return
299
+ }
300
+
301
+ const coordinates = (feature?.geometry as { coordinates?: [number, number] } | undefined)?.coordinates
302
+ if (!coordinates) {
303
+ return
304
+ }
305
+
306
+ onclick?.({
307
+ id: id ?? `${coordinates[0]}-${coordinates[1]}`,
308
+ lngLat: coordinates as [number, number],
309
+ properties: { ...feature?.properties },
310
+ })
311
+ }
312
+
313
+ const handleMouseEnter = () => {
314
+ currentMap.getCanvas().style.cursor = 'pointer'
315
+ }
316
+
317
+ const handleMouseLeave = () => {
318
+ currentMap.getCanvas().style.cursor = ''
319
+ }
320
+
321
+ currentMap.on('click', clusterLayerId, handleClusterClick)
322
+ if (showUnclustered) {
323
+ currentMap.on('click', unclusteredLayerId, handlePointClick)
324
+ }
325
+ currentMap.on('mouseenter', clusterLayerId, handleMouseEnter)
326
+ currentMap.on('mouseleave', clusterLayerId, handleMouseLeave)
327
+ if (showUnclustered) {
328
+ currentMap.on('mouseenter', unclusteredLayerId, handleMouseEnter)
329
+ currentMap.on('mouseleave', unclusteredLayerId, handleMouseLeave)
330
+ }
331
+
332
+ detachEvents = () => {
333
+ currentMap.off('click', clusterLayerId, handleClusterClick)
334
+ if (showUnclustered) {
335
+ currentMap.off('click', unclusteredLayerId, handlePointClick)
336
+ }
337
+ currentMap.off('mouseenter', clusterLayerId, handleMouseEnter)
338
+ currentMap.off('mouseleave', clusterLayerId, handleMouseLeave)
339
+ if (showUnclustered) {
340
+ currentMap.off('mouseenter', unclusteredLayerId, handleMouseEnter)
341
+ currentMap.off('mouseleave', unclusteredLayerId, handleMouseLeave)
342
+ }
343
+ }
344
+ }
345
+
346
+ function addLayers(currentMap: MapLibreMap) {
347
+ if (currentMap.getSource(sourceId)) {
348
+ return
349
+ }
350
+
351
+ currentMap.addSource(sourceId, {
352
+ type: 'geojson',
353
+ data: buildGeoJSON(),
354
+ cluster: true,
355
+ clusterRadius,
356
+ clusterMaxZoom,
357
+ })
358
+
359
+ const colors = getThemeColors()
360
+
361
+ currentMap.addLayer({
362
+ id: clusterLayerId,
363
+ type: 'circle',
364
+ source: sourceId,
365
+ filter: ['has', 'point_count'],
366
+ paint: {
367
+ 'circle-color': [
368
+ 'step',
369
+ ['get', 'point_count'],
370
+ colors.clusterLow,
371
+ 10,
372
+ colors.clusterMid,
373
+ 50,
374
+ colors.clusterHigh,
375
+ ],
376
+ 'circle-radius': ['step', ['get', 'point_count'], 16, 10, 20, 50, 24],
377
+ 'circle-stroke-width': 1,
378
+ 'circle-stroke-color': colors.clusterStroke,
379
+ },
380
+ })
381
+
382
+ currentMap.addLayer({
383
+ id: clusterCountId,
384
+ type: 'symbol',
385
+ source: sourceId,
386
+ filter: ['has', 'point_count'],
387
+ layout: {
388
+ 'text-field': '{point_count_abbreviated}',
389
+ 'text-font': ['Noto Sans Regular'],
390
+ 'text-size': 12,
391
+ },
392
+ paint: {
393
+ 'text-color': colors.clusterText,
394
+ 'text-opacity': 1,
395
+ },
396
+ })
397
+ currentMap.setPaintProperty(clusterCountId, 'text-opacity-transition', { duration: 0, delay: 0 })
398
+ currentMap.setPaintProperty(clusterCountId, 'text-color-transition', { duration: 0, delay: 0 })
399
+
400
+ if (showUnclustered) {
401
+ currentMap.addLayer({
402
+ id: unclusteredLayerId,
403
+ type: 'circle',
404
+ source: sourceId,
405
+ filter: ['!', ['has', 'point_count']],
406
+ paint: {
407
+ 'circle-color': colors.point,
408
+ 'circle-radius': 6,
409
+ 'circle-stroke-width': 2,
410
+ 'circle-stroke-color': colors.pointStroke,
411
+ },
412
+ })
413
+ }
414
+
415
+ attachLayerEvents(currentMap)
416
+ }
417
+
418
+ function removeLayers(currentMap: MapLibreMap) {
419
+ detachEvents?.()
420
+ detachEvents = null
421
+
422
+ if (currentMap.getLayer(clusterCountId)) {
423
+ currentMap.removeLayer(clusterCountId)
424
+ }
425
+ if (currentMap.getLayer(clusterLayerId)) {
426
+ currentMap.removeLayer(clusterLayerId)
427
+ }
428
+ if (currentMap.getLayer(unclusteredLayerId)) {
429
+ currentMap.removeLayer(unclusteredLayerId)
430
+ }
431
+ if (currentMap.getSource(sourceId)) {
432
+ currentMap.removeSource(sourceId)
433
+ }
434
+ }
435
+
436
+ function ensureLayers() {
437
+ if (!map || !map.isStyleLoaded()) {
438
+ return
439
+ }
440
+
441
+ const configKey = `${clusterRadius}-${clusterMaxZoom}-${showUnclustered}`
442
+ if (configKey !== lastConfigKey) {
443
+ removeLayers(map)
444
+ lastConfigKey = configKey
445
+ }
446
+
447
+ addLayers(map)
448
+ }
449
+
450
+ onMount(() => {
451
+ map = ctx.map
452
+ if (!map) {
453
+ return
454
+ }
455
+
456
+ updatePointsLookup()
457
+ attachClusterStateEvents(map)
458
+
459
+ const handleStyleLoad = () => {
460
+ lastConfigKey = ''
461
+ lastUnclusteredKey = '__initial__'
462
+ setClusterLoading(true)
463
+ ensureLayers()
464
+ updateSourceData()
465
+ scheduleUnclusteredUpdate()
466
+ }
467
+
468
+ const handleStyleData = () => {
469
+ if (!map || !map.isStyleLoaded()) {
470
+ return
471
+ }
472
+ if (!map.getSource(sourceId)) {
473
+ handleStyleLoad()
474
+ }
475
+ }
476
+
477
+ map.on('style.load', handleStyleLoad)
478
+ map.on('styledata', handleStyleData)
479
+
480
+ if (map.isStyleLoaded()) {
481
+ handleStyleLoad()
482
+ }
483
+
484
+ return () => {
485
+ map?.off('style.load', handleStyleLoad)
486
+ map?.off('styledata', handleStyleData)
487
+ detachClusterStateEvents?.()
488
+ detachClusterStateEvents = null
489
+ if (map) {
490
+ removeLayers(map)
491
+ }
492
+ map = null
493
+ }
494
+ })
495
+
496
+ // Explicitly read points to track as dependency
497
+ const pointsArray = $derived(points)
498
+
499
+ $effect(() => {
500
+ // Access pointsArray to ensure dependency tracking
501
+ void pointsArray
502
+ if (!map || !ctx.loaded) {
503
+ return
504
+ }
505
+
506
+ updatePointsLookup()
507
+ ensureLayers()
508
+ updateSourceData()
509
+ scheduleUnclusteredUpdate()
510
+ })
511
+ </script>
@@ -0,0 +1,29 @@
1
+ export interface ClusterPoint {
2
+ /** Unique identifier */
3
+ id: string | number;
4
+ /** Position [lng, lat] */
5
+ lngLat: [number, number];
6
+ /** Additional properties */
7
+ properties?: Record<string, unknown>;
8
+ }
9
+ export interface ClusterLayerProps {
10
+ /** Array of points to cluster */
11
+ points: ClusterPoint[];
12
+ /** Click callback for individual points */
13
+ onclick?: (point: ClusterPoint) => void;
14
+ /** Click callback for clusters */
15
+ onclusterclick?: (clusterId: number, zoom: number) => void;
16
+ /** Cluster radius in pixels */
17
+ clusterRadius?: number;
18
+ /** Max zoom to cluster at */
19
+ clusterMaxZoom?: number;
20
+ /** Whether to render unclustered points */
21
+ showUnclustered?: boolean;
22
+ /** Callback with unclustered point ids */
23
+ onunclusteredchange?: (ids: Set<string>) => void;
24
+ /** Callback when cluster source is loading */
25
+ onclusterloadingchange?: (loading: boolean) => void;
26
+ }
27
+ declare const ClusterLayer: import("svelte").Component<ClusterLayerProps, {}, "">;
28
+ type ClusterLayer = ReturnType<typeof ClusterLayer>;
29
+ export default ClusterLayer;
@@ -0,0 +1,180 @@
1
+ <script lang='ts' module>
2
+ import type { Snippet } from 'svelte'
3
+
4
+ export interface DetailsPanelProps {
5
+ /** Whether panel is visible */
6
+ open?: boolean
7
+ /** Close callback */
8
+ onclose?: () => void
9
+ /** Additional CSS classes */
10
+ class?: string
11
+ /** Accessible label */
12
+ ariaLabel?: string
13
+ /** Height mode - 'fit' for content height, 'full' for nearly full height */
14
+ height?: 'fit' | 'full'
15
+ /** Children */
16
+ children?: Snippet
17
+ }
18
+ </script>
19
+
20
+ <script lang='ts'>
21
+ import { onMount } from 'svelte'
22
+
23
+ const {
24
+ open = false,
25
+ onclose,
26
+ class: className = '',
27
+ ariaLabel = 'Details panel',
28
+ height = 'full',
29
+ children,
30
+ }: DetailsPanelProps = $props()
31
+
32
+ let isMobile = $state(false)
33
+
34
+ onMount(() => {
35
+ if (typeof window === 'undefined') {
36
+ return
37
+ }
38
+
39
+ const media = window.matchMedia('(max-width: 640px)')
40
+ const update = () => {
41
+ isMobile = media.matches
42
+ }
43
+
44
+ update()
45
+ media.addEventListener('change', update)
46
+
47
+ return () => {
48
+ media.removeEventListener('change', update)
49
+ }
50
+ })
51
+
52
+ // Keyboard escape to close
53
+ $effect(() => {
54
+ if (typeof window === 'undefined' || !open) {
55
+ return
56
+ }
57
+
58
+ const handleKeydown = (event: KeyboardEvent) => {
59
+ if (event.key === 'Escape') {
60
+ onclose?.()
61
+ }
62
+ }
63
+
64
+ window.addEventListener('keydown', handleKeydown)
65
+
66
+ return () => {
67
+ window.removeEventListener('keydown', handleKeydown)
68
+ }
69
+ })
70
+ </script>
71
+
72
+ {#if open}
73
+ <div
74
+ class='shadcn-details-panel {className}'
75
+ data-device={isMobile ? 'mobile' : 'desktop'}
76
+ data-height={height}
77
+ role='complementary'
78
+ aria-label={ariaLabel}
79
+ >
80
+ <button
81
+ type='button'
82
+ class='shadcn-details-close'
83
+ aria-label='Close panel'
84
+ onclick={() => onclose?.()}
85
+ >
86
+ <svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'>
87
+ <path d='M18 6 6 18' /><path d='m6 6 12 12' />
88
+ </svg>
89
+ </button>
90
+
91
+ <div class='shadcn-details-content'>
92
+ {#if children}
93
+ {@render children()}
94
+ {/if}
95
+ </div>
96
+ </div>
97
+ {/if}
98
+
99
+ <style>
100
+ .shadcn-details-panel {
101
+ position: absolute;
102
+ background: oklch(var(--card));
103
+ color: oklch(var(--card-foreground));
104
+ border: 1px solid oklch(var(--border));
105
+ border-radius: 12px;
106
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2), 0 2px 8px rgba(0, 0, 0, 0.1);
107
+ display: flex;
108
+ flex-direction: column;
109
+ z-index: 10;
110
+ pointer-events: auto;
111
+ }
112
+
113
+ /* Desktop: Left side floating panel */
114
+ .shadcn-details-panel[data-device='desktop'] {
115
+ top: 12px;
116
+ left: 12px;
117
+ width: min(90vw, 360px);
118
+ }
119
+
120
+ .shadcn-details-panel[data-device='desktop'][data-height='full'] {
121
+ bottom: 52px;
122
+ max-height: calc(100% - 64px);
123
+ }
124
+
125
+ .shadcn-details-panel[data-device='desktop'][data-height='fit'] {
126
+ bottom: auto;
127
+ max-height: calc(100% - 64px);
128
+ }
129
+
130
+ /* Mobile: Bottom floating card */
131
+ .shadcn-details-panel[data-device='mobile'] {
132
+ left: 12px;
133
+ right: 12px;
134
+ bottom: 52px;
135
+ }
136
+
137
+ .shadcn-details-panel[data-device='mobile'][data-height='full'] {
138
+ max-height: 45vh;
139
+ }
140
+
141
+ .shadcn-details-panel[data-device='mobile'][data-height='fit'] {
142
+ max-height: 60vh;
143
+ height: auto;
144
+ }
145
+
146
+ .shadcn-details-close {
147
+ position: absolute;
148
+ top: 12px;
149
+ right: 12px;
150
+ width: 32px;
151
+ height: 32px;
152
+ display: flex;
153
+ align-items: center;
154
+ justify-content: center;
155
+ border: none;
156
+ background: oklch(var(--muted));
157
+ color: oklch(var(--muted-foreground));
158
+ border-radius: 8px;
159
+ cursor: pointer;
160
+ transition: background-color 0.15s, color 0.15s;
161
+ z-index: 1;
162
+ }
163
+
164
+ .shadcn-details-close:hover {
165
+ background: oklch(var(--accent));
166
+ color: oklch(var(--accent-foreground));
167
+ }
168
+
169
+ .shadcn-details-close:focus-visible {
170
+ outline: 2px solid oklch(var(--ring));
171
+ outline-offset: 2px;
172
+ }
173
+
174
+ .shadcn-details-content {
175
+ padding: 20px;
176
+ padding-top: 16px;
177
+ overflow: auto;
178
+ height: 100%;
179
+ }
180
+ </style>