Skip to content
Merged
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
5 changes: 3 additions & 2 deletions ABLS/config.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"views": []
}
"views": [],
"expandEnabled": false
}
2 changes: 1 addition & 1 deletion ABLS/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"author": "Lucius Creamer MOSEMA",
"description": "This widget allows for users to toggle between pre-configured layer visibility views, allowing for quick and easy layer toggling without reloading the entire page.",
"copyright": "",
"license": "http://www.apache.org/licenses/LICENSE-2.0",
"license": "MIT",
"properties": {
"hasSettingPage": true,
"hasStylePage": false,
Expand Down
2 changes: 1 addition & 1 deletion ABLS/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "abls",
"version": "1.0.2",
"version": "1.1.0",
"description": "A Better Layer Switcher - A custom Experience Widget, to swith between sets of layers using a views navigation like interface.",
"keywords": [
"exb-widget",
Expand Down
2 changes: 2 additions & 0 deletions ABLS/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ export interface ABLSView {
startOffset?: number;
endOffset?: number;
tod?: number;
expandLayerIds?: string[]; //Array of layer IDs that should be shown when expanded
}

// Defines the overall widget configuration
export interface Config {
expandEnabled?: boolean;
views: ABLSView[];
}

Expand Down
22 changes: 22 additions & 0 deletions ABLS/src/runtime/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,26 @@
flex: 0 0 auto;
/* Ensure the label doesn't wrap inside the button and stays readable */
white-space: nowrap;
}

.widget-abls .expand-handle-icon {
margin-left: 4px;
vertical-align: middle;
opacity: 0.75;
}

/* Wrapper that anchors the measurement ref */
.view-buttons-wrapper {
position: relative;
}

/* Floating layer list rendered via portal into document.body */
.expanded-layers-list {
position: fixed;
min-width: 220px;
max-width: 360px;
width: max-content;
max-height: 300px;
overflow-y: auto;
z-index: 9999;
}
261 changes: 236 additions & 25 deletions ABLS/src/runtime/widget.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { React, jsx } from "jimu-core"
import ReactDOM from "react-dom"
//import { lazy } from 'react'
import type { AllWidgetProps } from "jimu-core"
import { JimuMapViewComponent, type JimuMapView } from "jimu-arcgis"
Expand All @@ -9,21 +10,60 @@ import defaultMessages from "./translations/default"
import "./style.css"
import TimeExtent from "@arcgis/core/time/TimeExtent"
import { useCallback } from "react"
import { CalciteButton } from "@esri/calcite-components-react"
import "calcite-components"
import type Layer from "esri/layers/Layer"
import chevronDownSvg from "jimu-icons/svg/outlined/directional/down-small.svg"
import chevronUpSvg from "jimu-icons/svg/outlined/directional/up-small.svg"

export default function Widget (props: AllWidgetProps<Config>) {
const { config, useMapWidgetIds } = props
const [jimuMapView, setJimuMapView] = React.useState<JimuMapView>(null)
const [activeViewId, setActiveViewId] = React.useState<string>(null)

const [expand, setExpand] = React.useState(false)
const [expandedLayers, setExpandedLayers] = React.useState<Layer[]>([])
const [visibleLayers, setVisibleLayers] = React.useState<Layer[]>([])
const [listStyle, setListStyle] = React.useState<React.CSSProperties>({})
const buttonsWrapperRef = React.useRef<HTMLDivElement>(null)
const listRef = React.useRef<any>(null)
const focusButtonOnCollapseRef = React.useRef(false)

// This is the way that the widget prevents itself from running itself, and from crashing. It checks to see if any maps have been selected, and if any views have been configured.
const isConfigured = useMapWidgetIds?.length > 0 && config.views?.length > 0

const handleViewExpand = useCallback(
(view: ABLSView) => {
if (expand && activeViewId === view.id) {
setExpand(false)
setExpandedLayers([])
setVisibleLayers([])
return
}
if (jimuMapView?.view?.map?.allLayers) {
const allLayers = jimuMapView.view.map.allLayers.toArray().reverse()
allLayers.forEach((layer) => {
if (view.expandLayerIds.includes(layer.id)) {
setExpandedLayers((prev) => [...prev, layer])
if (layer.visible && !visibleLayers.includes(layer)) {
setVisibleLayers((prev) => [...prev, layer])
}
else if (!layer.visible && visibleLayers.includes(layer)) {
setVisibleLayers((prev) => prev.filter((l) => l !== layer))
}
}
})
setExpand(true)
}
},
[expand, activeViewId, jimuMapView, visibleLayers]
)


// The contained elements are performed every time a view button is clicked.
const handleViewChange = useCallback(
(view: ABLSView) => {
if (!jimuMapView || !jimuMapView.view) return

setExpand(false)
setActiveViewId(view.id)

// 1. Handle Layer Visibility
Expand Down Expand Up @@ -81,6 +121,113 @@ export default function Widget (props: AllWidgetProps<Config>) {
[jimuMapView]
)

const focusActiveButton = useCallback(() => {
if (!activeViewId || !buttonsWrapperRef.current) return
const btn = buttonsWrapperRef.current.querySelector(`[data-view-id="${activeViewId}"]`)
if (!btn) return
const setFocusFn = Reflect.get(btn, 'setFocus')
if (typeof setFocusFn === 'function') {
setFocusFn.call(btn)
} else if (btn instanceof HTMLElement) {
btn.focus()
}
}, [activeViewId])

React.useEffect(() => {
if (expand && buttonsWrapperRef.current) {
const rect = buttonsWrapperRef.current.getBoundingClientRect()
const spaceAbove = rect.top
const spaceBelow = window.innerHeight - rect.bottom
const openUpward = spaceAbove >= 150 || spaceAbove >= spaceBelow
setListStyle(
openUpward
? { bottom: window.innerHeight - rect.top, left: rect.left }
: { top: rect.bottom, left: rect.left }
)
}
}, [expand])

// Focus the first list item shortly after the list opens
React.useEffect(() => {
if (!expand) return
const timer = setTimeout(() => {
const firstItem = listRef.current?.querySelector('calcite-list-item')
firstItem?.setFocus?.()
}, 50)
return () => { clearTimeout(timer) }
}, [expand])

// Keyboard navigation: Escape closes the list; ArrowDown on the last item returns focus to the active button
React.useEffect(() => {
const el = listRef.current
if (!expand || !el) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
focusButtonOnCollapseRef.current = true
setExpand(false)
setExpandedLayers([])
setVisibleLayers([])
return
}
if (e.key === 'ArrowDown') {
const allItems = Array.from(el.querySelectorAll('calcite-list-item'))
const lastItem = allItems[allItems.length - 1]
if (lastItem && document.activeElement === lastItem) {
e.preventDefault()
focusActiveButton()
}
}
}
el.addEventListener('keydown', handleKeyDown, true)
return () => { el.removeEventListener('keydown', handleKeyDown, true) }
}, [expand, focusActiveButton])

// ArrowUp on the active button navigates to the last item in the open list
React.useEffect(() => {
if (!expand || !buttonsWrapperRef.current) return
const wrapper = buttonsWrapperRef.current
const handleButtonKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'ArrowUp') return
const target = e.target as HTMLElement
if (!target.closest(`[data-view-id="${activeViewId}"]`)) return
const allItems = Array.from(listRef.current?.querySelectorAll('calcite-list-item') ?? [])
const lastItem = allItems[allItems.length - 1] as any
if (lastItem) {
e.preventDefault()
lastItem.setFocus?.()
}
}
wrapper.addEventListener('keydown', handleButtonKeyDown, true)
return () => { wrapper.removeEventListener('keydown', handleButtonKeyDown, true) }
}, [expand, activeViewId])

// Click outside both the list and the buttons wrapper collapses the list
React.useEffect(() => {
if (!expand) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node
if (
!(listRef.current?.contains(target)) &&
!(buttonsWrapperRef.current?.contains(target))
) {
setExpand(false)
setExpandedLayers([])
setVisibleLayers([])
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => { document.removeEventListener('mousedown', handleClickOutside) }
}, [expand])

// After a collapse-with-focus was requested, focus the active button once the list unmounts
React.useEffect(() => {
if (!expand && focusButtonOnCollapseRef.current) {
focusButtonOnCollapseRef.current = false
focusActiveButton()
}
}, [expand, focusActiveButton])

React.useEffect(() => {
// Check if the map is loaded, if views are configured, and if no view is active yet.
if (jimuMapView && !activeViewId && config.views?.length > 0) {
Expand All @@ -104,6 +251,44 @@ export default function Widget (props: AllWidgetProps<Config>) {
)
}

// Build helper structures for nested list rendering
const expandedLayerIds = new Set(expandedLayers.map((l) => l.id))

// Collect IDs of layers that are nested children of a group layer also in expandedLayers.
// These will be skipped at the top level and rendered inside their parent instead.
const nestedChildIds = new Set<string>()
expandedLayers.forEach((layer) => {
if (layer.type === "group") {
const children: Layer[] = (layer as any).layers?.toArray?.() ?? []
children.forEach((child) => {
if (expandedLayerIds.has(child.id)) {
nestedChildIds.add(child.id)
}
})
}
})

const renderLayerItem = (layer: Layer): React.ReactElement => {
if (layer.type === "group") {
const children: Layer[] = (layer as any).layers?.toArray?.() ?? []
const expandedChildren = children.filter((child) => expandedLayerIds.has(child.id))
if (expandedChildren.length > 0) {
return (
<calcite-list-item expanded key={layer.id} label={layer.title} selected={layer.visible} oncalciteListItemSelect={(e: Event) => { e.stopPropagation(); layer.visible = !layer.visible }}>
<calcite-list displayMode="nested" selectionMode="multiple" label="Group Layers">
{expandedChildren.map((child) => renderLayerItem(child))}
</calcite-list>
</calcite-list-item>
)
}
}
return (
<calcite-list-item key={layer.id} label={layer.title || layer.id} selected={layer.visible} oncalciteListItemSelect={(e: Event) => { e.stopPropagation(); layer.visible = !layer.visible }} />
)
}

const topLevelLayers = expandedLayers.filter((layer) => !nestedChildIds.has(layer.id))

// This return statement will not be run unless the widget has been set up at least to some degree. This is what actually creates the UI for the widget that is visible to the end user
return (
// Outer Div for the entire widget
Expand All @@ -117,30 +302,56 @@ export default function Widget (props: AllWidgetProps<Config>) {
}}
/>
)}
<div className="view-buttons-container">
{config.views.map(
(
view //The .map function creates the contained items multiple times, one button for each view in this case.
) => (
<CalciteButton
key={view.id}
className={`view-button ${
activeViewId === view.id ? "active" : ""
}`}
title={view.name}
onClick={() => {
handleViewChange(view)
}}
appearance={activeViewId === view.id ? "solid" : "transparent"}
kind={activeViewId === view.id ? "brand" : "neutral"}
>
{view.icon && (
<Icon icon={view.icon.svg} size="16" className="mr-2" />
)}
{view.name}
</CalciteButton>
)
<div className="view-buttons-wrapper" ref={buttonsWrapperRef}>
{expand && expandedLayers.length > 0 && ReactDOM.createPortal(
<calcite-list
ref={listRef}
label="Expanded Layers"
className="expanded-layers-list"
selectionMode="multiple"
displayMode="nested"
style={listStyle}
>
{topLevelLayers.map((layer) => renderLayerItem(layer))}
</calcite-list>,
document.body
)}
<div className="view-buttons-container">
{config.views.map(
(
view //The .map function creates the contained items multiple times, one button for each view in this case.
) => (
<calcite-button
key={view.id}
data-view-id={view.id}
className={`view-button ${activeViewId === view.id ? "active" : ""
}`}
title={view.name}
onClick={() => {
if (activeViewId === view.id) {
handleViewExpand(view)
return
}
handleViewChange(view)
}}
appearance={activeViewId === view.id ? "solid" : "transparent"}
kind={activeViewId === view.id ? "brand" : "neutral"}
>
{view.icon && (
<Icon icon={view.icon.svg} size="16" className="mr-2" />
)}
{view.name}
{view.expandLayerIds?.length > 0 && (
<Icon
icon={expand && activeViewId === view.id ? chevronUpSvg : chevronDownSvg}
size="12"
className="expand-handle-icon"
/>
)}
</calcite-button>
)
)}
</div>
</div>
</div>
)
Expand Down
Loading
Loading