Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/essence/Ancillary/FloatingPopover/FloatingPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import React, { useLayoutEffect, useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'

export interface FloatingPopoverProps {
anchorRef: React.RefObject<HTMLElement | null>
isOpen: boolean
onClose?: () => void
placement?: 'top' | 'bottom' | 'left' | 'right'
offset?: number
className?: string
children: React.ReactNode
}

export const FloatingPopover: React.FC<FloatingPopoverProps> = ({
anchorRef,
isOpen,
onClose,
placement = 'bottom',
offset = 8,
className = '',
children
}) => {
const popupRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ top: 0, left: 0 })

// Close on outside click
useEffect(() => {
if (!isOpen || !onClose) return

const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
// If the anchor exists and the click is inside it, ignore (the button toggle will handle it)
const clickedInsideAnchor = anchorRef.current && anchorRef.current.contains(target)
// If the click is inside the popup itself, ignore
const clickedInsidePopup = popupRef.current && popupRef.current.contains(target)

if (!clickedInsideAnchor && !clickedInsidePopup) {
onClose()
}
}

document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, onClose, anchorRef])

// Update position
useLayoutEffect(() => {
if (!isOpen) return

const updatePosition = () => {
if (!anchorRef.current || !popupRef.current) return

const anchorRect = anchorRef.current.getBoundingClientRect()
const popupRect = popupRef.current.getBoundingClientRect()

let top = 0
let left = 0

switch (placement) {
case 'top':
top = anchorRect.top - popupRect.height - offset
left = anchorRect.left + (anchorRect.width / 2) - (popupRect.width / 2)
break
case 'bottom':
top = anchorRect.bottom + offset
left = anchorRect.left + (anchorRect.width / 2) - (popupRect.width / 2)
break
case 'left':
top = anchorRect.top + (anchorRect.height / 2) - (popupRect.height / 2)
left = anchorRect.left - popupRect.width - offset
break
case 'right':
top = anchorRect.top + (anchorRect.height / 2) - (popupRect.height / 2)
left = anchorRect.right + offset
break
}

// Simple viewport bounds checking
if (left < 8) left = 8
if (top < 8) {
if (placement === 'top') {
top = anchorRect.bottom + offset
} else {
top = 8
}
}
if (left + popupRect.width > window.innerWidth - 8) {
left = window.innerWidth - popupRect.width - 8
}
if (top + popupRect.height > window.innerHeight - 8) {
if (placement === 'bottom') {
top = anchorRect.top - popupRect.height - offset
} else {
top = window.innerHeight - popupRect.height - 8
}
}

// Prevent React state updates if the position hasn't changed to avoid infinite loops
setPos(prev => {
if (Math.abs(prev.top - top) < 1 && Math.abs(prev.left - left) < 1) {
return prev
}
return { top, left }
})
}

updatePosition()
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)

// Wait a tick and update again in case children render changed dimensions
const timeout = setTimeout(updatePosition, 0)

return () => {
clearTimeout(timeout)
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
}
}, [isOpen, placement, offset, anchorRef])

if (!isOpen) return null

return createPortal(
<div
ref={popupRef}
className={`floating-popover-portal ${className}`}
style={{
position: 'fixed',
zIndex: 999999,
top: `${pos.top}px`,
left: `${pos.left}px`,
// visibility hidden on first render if pos is 0,0 to prevent flicker in top left
visibility: pos.top === 0 && pos.left === 0 ? 'hidden' : 'visible'
}}
>
{children}
</div>,
document.body
)
}
1 change: 1 addition & 0 deletions src/essence/Ancillary/FloatingPopover/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './FloatingPopover'
1 change: 0 additions & 1 deletion src/essence/Basics/TimeControl_/TimeUI.js
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,6 @@ const TimeUI = {
promptTimeOnDateChangeTransitionDelay: 200,
}

// startElm is already defined at the top
TimeUI.startTempus = new TempusDominus(startElm, options)
TimeUI.startTempus.dates.formatInput = function (date) {
return moment(date).format(FORMAT)
Expand Down
1 change: 1 addition & 0 deletions src/essence/Basics/UserInterface_/UserInterfaceModern_.css
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
/* Tool Cards - Base Styles (shared by stacked and tabbed) */
.ui-tool-card {
background-color: var(--theme-color-white, #ffffff);
width: 100%;
}

/* Stacked Tool Cards (default clickable cards in panel body) */
Expand Down
24 changes: 10 additions & 14 deletions src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {
} from 'react'
import { GradientGraphic } from '../GradientGraphic/GradientGraphic'
import { CategoricalGraphic } from '../CategoricalGraphic/CategoricalGraphic'
import { useClickOutside } from '../../hooks/useClickOutside'
import type { Layer } from '../../types'
import { FloatingPopover } from '../../../../../Ancillary/FloatingPopover'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The components should be self-sufficient and shouldn't be importing anything that's not within its own folder


export type LayerLegendProps = {
layer: Layer
Expand Down Expand Up @@ -49,7 +49,6 @@ export function LayerLegend({
const [isOpacityExpanded, setIsOpacityExpanded] = useState(false)
const [localOpacity, setLocalOpacity] = useState(opacity ?? 1)
const opacityBtnRef = useRef<HTMLButtonElement | null>(null)
const opacityPopoverRef = useRef<HTMLDivElement | null>(null)

useEffect(() => {
setIsVisible(visible)
Expand All @@ -59,12 +58,6 @@ export function LayerLegend({
setLocalOpacity(opacity ?? 1)
}, [opacity])

useClickOutside(
[opacityPopoverRef, opacityBtnRef],
useCallback(() => setIsOpacityExpanded(false), []),
isOpacityExpanded,
)

const handleVisibilityToggle = () => {
const newState = !isVisible
setIsVisible(newState)
Expand Down Expand Up @@ -146,11 +139,14 @@ export function LayerLegend({
>
<i className="mdi mdi-circle-half-full mdi-18px" />
</button>
{isOpacityExpanded && (
<div
ref={opacityPopoverRef}
className="blocks-layer-legend__opacity-popover"
>
<FloatingPopover
anchorRef={opacityBtnRef}
isOpen={isOpacityExpanded}
onClose={() => setIsOpacityExpanded(false)}
placement="bottom"
offset={8}
>
<div className="blocks-layer-legend__opacity-popover" style={{ position: 'relative', top: 0, right: 0 }}>
<input
type="range"
className="blocks-layer-legend__opacity-slider"
Expand All @@ -164,7 +160,7 @@ export function LayerLegend({
{Math.round(localOpacity * 100)}%
</span>
</div>
)}
</FloatingPopover>
</div>
<button
className={`blocks-layer-legend__action-btn ${isInfoExpanded ? 'blocks-layer-legend__action-btn--active' : ''}`}
Expand Down
Loading