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
7 changes: 7 additions & 0 deletions demo/js/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ const interactiveMap = new InteractiveMap('map', {

interactiveMap.on('app:ready', function (e) {
// console.log('app:ready')
interactiveMap.addPanel('banner', {
label: 'Hello',
html: 'Alert',
mobile: { slot: 'banner' },
tablet: { slot: 'banner' },
desktop: { slot: 'banner' }
})
})


Expand Down
1 change: 1 addition & 0 deletions demo/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ const interactiveMap = new InteractiveMap('map', {

interactiveMap.on('app:ready', function (e) {
// console.log('app:ready')
interactiveMap.showHint('My hint', { duration: 0 })
})

interactiveMap.on('map:ready', function (e) {
Expand Down
32 changes: 32 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,38 @@ See [ControlDefinition](./api/control-definition.md) for configuration options.

---

### `showHint(text, options?)`

Show a toast hint, announced to screen readers. Replaces any hint currently showing and restarts its dismiss timer — there is only ever one hint visible at a time.

| Parameter | Type | Description |
|-----------|------|-------------|
| `text` | `string` | Hint text. May contain simple HTML (e.g. `<kbd>`) |
| `options.duration` | `number` | Auto-dismiss delay in milliseconds. Pass `0` to persist until `dismissHint()` is called. Default: `4000` |
| `options.announce` | `string` | Optional plain-text override for the screen-reader announcement. Defaults to `text` with any HTML tags stripped |

```js
interactiveMap.showHint('Press <kbd>Enter</kbd> to select')

// Persist until explicitly dismissed
interactiveMap.showHint('Draw mode active', { duration: 0 })
interactiveMap.dismissHint()
```

---

### `dismissHint()`

Dismiss the active toast hint, if any. No-op if no hint is showing. Mainly useful for hints shown with `{ duration: 0 }`, which otherwise persist indefinitely.

The active hint is also dismissed automatically when the user presses <kbd>Escape</kbd> anywhere within this map instance. With multiple map instances on a page, Escape only dismisses the hint belonging to the instance the keypress originated in.

```js
interactiveMap.dismissHint()
```

---

### `setContinueEnabled(enabled)`

Enable or disable the Continue button added by [`backAndContinue`](#backandcontinue). Use this for imperative control — for example, enabling Continue after an async operation or in response to an external event. For reactive state-derived conditions, prefer the `continueEnabledWhen` function in `backAndContinue` instead.
Expand Down
9 changes: 5 additions & 4 deletions src/App/components/Hints/Hints.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
.im-o-hints {
position: absolute;
bottom: var(--hint-bottom, var(--primary-gap));
left: 50%;
transform: translateX(-50%);
left: 0;
right: 0;
margin-inline: auto;
z-index: 1001;
width: fit-content;
max-width: min(var(--hint-max-width), calc(100% - (2 * var(--primary-gap))));
}

// ===================================================
Expand All @@ -22,8 +25,6 @@
}

.im-c-hints__hint {
text-wrap: nowrap;

color: var(--tooltip-foreground-color);
background-color: var(--tooltip-background-color);
border-radius: var(--tooltip-border-radius);
Expand Down
67 changes: 67 additions & 0 deletions src/App/hooks/useHintsAPI.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react'
import { EVENTS as events } from '../../config/events.js'
import { useService } from '../store/serviceContext.js'
import { useApp } from '../store/appContext.js'

/**
* Wires the public showHint()/dismissHint() API onto the hints service.
* The hints service already owns its own subscriber list (see Hints.jsx),
* so this just forwards eventBus commands to it — no React state involved.
*/
export const useHintsAPI = () => {
const { eventBus, hints } = useService()
const { layoutRefs } = useApp()
const isHintActiveRef = useRef(false)

useEffect(() => {
const handleShowHint = ({ text, options } = {}) => {
if (!text) {
return
}
hints.show(text, options)
}
const handleDismissHint = () => hints.dismiss()

eventBus.on(events.APP_SHOW_HINT, handleShowHint)
eventBus.on(events.APP_DISMISS_HINT, handleDismissHint)

return () => {
eventBus.off(events.APP_SHOW_HINT, handleShowHint)
eventBus.off(events.APP_DISMISS_HINT, handleDismissHint)
}
}, [eventBus, hints])

// Escape dismisses the active hint when the keypress originates inside this
// map instance. The viewport and features listbox already dismiss hints on
// Escape within their own narrower focus scope (useKeyboardHint.js /
// useFeatureFocus.js) — this covers hints shown via the public showHint()
// API from anywhere else in the same map's UI (e.g. a plugin button).
//
// Listening on document (rather than the map's own container) and checking
// containment ourselves, rather than a container-scoped listener, is
// deliberate: with multiple map instances on one page, each has its own
// hints service and its own isHintActiveRef, so without the containment
// check an Escape press anywhere on the host page would dismiss a hint on
// every instance that happened to have one showing — including maps the
// user isn't even looking at.
useEffect(() => {
return hints.subscribe((hint) => {
isHintActiveRef.current = Boolean(hint)
})
}, [hints])

useEffect(() => {
const handleKeyDown = (e) => {
if (e.key !== 'Escape' || !isHintActiveRef.current) {
return
}
const container = layoutRefs.appContainerRef?.current
if (container && !container.contains(e.target)) {
return
}
hints.dismiss()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [hints, layoutRefs])
}
145 changes: 145 additions & 0 deletions src/App/hooks/useHintsAPI.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { renderHook, act } from '@testing-library/react'
import { useHintsAPI } from './useHintsAPI.js'
import { useService } from '../store/serviceContext.js'
import { useApp } from '../store/appContext.js'

jest.mock('../store/serviceContext.js')
jest.mock('../store/appContext.js')

const makeEventBus = () => {
const handlers = {}
return {
on: jest.fn((event, handler) => { handlers[event] = handler }),
off: jest.fn(),
emit: (event, payload) => handlers[event]?.(payload),
_handlers: handlers
}
}

const makeHints = () => {
let subscriber = null
return {
show: jest.fn(),
dismiss: jest.fn(),
subscribe: jest.fn((fn) => {
subscriber = fn
return () => { subscriber = null }
}),
_emit: (hint) => subscriber?.(hint)
}
}

const pressEscape = (target) => {
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}

describe('useHintsAPI', () => {
let mockEventBus, mockHints, containerEl, insideEl, outsideEl

beforeEach(() => {
mockEventBus = makeEventBus()
mockHints = makeHints()
useService.mockReturnValue({ eventBus: mockEventBus, hints: mockHints })

// Simulates this map instance's root DOM (layoutRefs.appContainerRef),
// plus an element outside it — standing in for a second map instance,
// or unrelated content elsewhere on the host page.
containerEl = document.createElement('div')
insideEl = document.createElement('button')
containerEl.appendChild(insideEl)
document.body.appendChild(containerEl)

outsideEl = document.createElement('button')
document.body.appendChild(outsideEl)

useApp.mockReturnValue({ layoutRefs: { appContainerRef: { current: containerEl } } })
})

afterEach(() => {
containerEl.remove()
outsideEl.remove()
})

it('calls hints.show with text and options on app:showhint', () => {
renderHook(() => useHintsAPI())
act(() => mockEventBus.emit('app:showhint', { text: 'Press Enter to select', options: { duration: 2000 } }))
expect(mockHints.show).toHaveBeenCalledWith('Press Enter to select', { duration: 2000 })
})

it('ignores app:showhint with no text', () => {
renderHook(() => useHintsAPI())
act(() => mockEventBus.emit('app:showhint', {}))
act(() => mockEventBus.emit('app:showhint'))
expect(mockHints.show).not.toHaveBeenCalled()
})

it('calls hints.dismiss on app:dismisshint', () => {
renderHook(() => useHintsAPI())
act(() => mockEventBus.emit('app:dismisshint'))
expect(mockHints.dismiss).toHaveBeenCalled()
})

it('unsubscribes on unmount', () => {
const { unmount } = renderHook(() => useHintsAPI())
unmount()
expect(mockEventBus.off).toHaveBeenCalledWith('app:showhint', expect.any(Function))
expect(mockEventBus.off).toHaveBeenCalledWith('app:dismisshint', expect.any(Function))
})

describe('Escape key', () => {
it('dismisses the active hint when Escape originates inside this map instance', () => {
renderHook(() => useHintsAPI())
act(() => mockHints._emit({ html: 'Press Enter to select' }))

act(() => pressEscape(insideEl))
expect(mockHints.dismiss).toHaveBeenCalled()
})

it('does not dismiss when Escape originates outside this map instance', () => {
// e.g. a second map instance on the page, or unrelated host-page content
renderHook(() => useHintsAPI())
act(() => mockHints._emit({ html: 'Press Enter to select' }))

act(() => pressEscape(outsideEl))
expect(mockHints.dismiss).not.toHaveBeenCalled()
})

it('does nothing on Escape when no hint is showing', () => {
renderHook(() => useHintsAPI())
act(() => pressEscape(insideEl))
expect(mockHints.dismiss).not.toHaveBeenCalled()
})

it('stops reacting to Escape once the hint has been dismissed', () => {
renderHook(() => useHintsAPI())
act(() => mockHints._emit({ html: 'Press Enter to select' }))
act(() => mockHints._emit(null)) // hints service reports no active hint

act(() => pressEscape(insideEl))
expect(mockHints.dismiss).not.toHaveBeenCalled()
})

it('falls back to dismissing when appContainerRef is not yet available', () => {
useApp.mockReturnValue({ layoutRefs: { appContainerRef: { current: null } } })
renderHook(() => useHintsAPI())
act(() => mockHints._emit({ html: 'Press Enter to select' }))

act(() => pressEscape(outsideEl))
expect(mockHints.dismiss).toHaveBeenCalled()
})

it('removes the keydown listener on unmount', () => {
const addSpy = jest.spyOn(document, 'addEventListener')
const removeSpy = jest.spyOn(document, 'removeEventListener')

const { unmount } = renderHook(() => useHintsAPI())
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function))

unmount()
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function))

addSpy.mockRestore()
removeSpy.mockRestore()
})
})
})
4 changes: 4 additions & 0 deletions src/App/renderer/PluginInits.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { useEffect } from 'react'
import { withPluginContexts } from './pluginWrapper.js'
import { withPluginApiContexts, usePluginApiState } from './pluginApiWrapper.js'
import { useInterfaceAPI } from '../hooks/useInterfaceAPI.js'
import { useHintsAPI } from '../hooks/useHintsAPI.js'
import { useApp } from '../store/appContext.js'
import { useConfig } from '../store/configContext.js'
import { useEvaluateProp } from '../hooks/useEvaluateProp.js'
Expand Down Expand Up @@ -53,6 +54,9 @@ export const PluginInits = () => {
// Add button, panel and control API methods (Needs to be top-level)
useInterfaceAPI()

// Wire the showHint()/dismissHint() public API onto the hints service
useHintsAPI()

// Evaluate reactive button states globally
const evaluateProp = useEvaluateProp()
useButtonStateEvaluator(evaluateProp)
Expand Down
8 changes: 7 additions & 1 deletion src/App/renderer/PluginInits.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useButtonStateEvaluator } from '../hooks/useButtonStateEvaluator.js'
import { withPluginApiContexts } from './pluginApiWrapper.js'
import { withPluginContexts } from './pluginWrapper.js'
import { useInterfaceAPI } from '../hooks/useInterfaceAPI.js'
import { useHintsAPI } from '../hooks/useHintsAPI.js'
import { useApp } from '../store/appContext.js'
import { useConfig } from '../store/configContext.js'

Expand All @@ -29,6 +30,10 @@ jest.mock('../hooks/useInterfaceAPI.js', () => ({
useInterfaceAPI: jest.fn()
}))

jest.mock('../hooks/useHintsAPI.js', () => ({
useHintsAPI: jest.fn()
}))

jest.mock('../hooks/useEvaluateProp.js', () => ({
useEvaluateProp: jest.fn(() => (x) => x)
}))
Expand Down Expand Up @@ -59,10 +64,11 @@ describe('PluginInits', () => {
useConfig.mockReturnValue({ pluginRegistry: pluginRegistryMock })
})

it('calls useButtonStateEvaluator and useInterfaceAPI on render', () => {
it('calls useButtonStateEvaluator, useInterfaceAPI and useHintsAPI on render', () => {
render(<PluginInits />)
expect(useButtonStateEvaluator).toHaveBeenCalled()
expect(useInterfaceAPI).toHaveBeenCalled()
expect(useHintsAPI).toHaveBeenCalled()
})

it('renders nothing when no plugins registered', () => {
Expand Down
20 changes: 20 additions & 0 deletions src/InteractiveMap/InteractiveMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/**
* @typedef {import('../types.js').ButtonDefinition} ButtonDefinition
* @typedef {import('../types.js').ControlDefinition} ControlDefinition
* @typedef {import('../types.js').HintOptions} HintOptions
* @typedef {import('../types.js').InteractiveMapConfig} InteractiveMapConfig
* @typedef {import('../types.js').MarkerOptions} MarkerOptions
* @typedef {import('../types.js').PanelDefinition} PanelDefinition
Expand Down Expand Up @@ -481,6 +482,25 @@ export default class InteractiveMap {
this.eventBus.emit(events.APP_ADD_CONTROL, { id, config })
}

/**
* Show a toast hint, announced to screen readers via the live region.
* Replaces any hint currently showing and restarts its dismiss timer.
*
* @param {string} text - Hint text. May contain simple HTML (e.g. `<kbd>`).
* @param {HintOptions} [options] - Optional hint behaviour.
*/
showHint (text, options) {
this.eventBus.emit(events.APP_SHOW_HINT, { text, options })
}

/**
* Dismiss the active toast hint, if any. No-op if no hint is showing.
* Mainly useful for hints shown with `{ duration: 0 }`, which otherwise persist indefinitely.
*/
dismissHint () {
this.eventBus.emit(events.APP_DISMISS_HINT)
}

/**
* Fit the map view to a bounding box or GeoJSON geometry, respecting the safe zone padding.
*
Expand Down
7 changes: 7 additions & 0 deletions src/InteractiveMap/InteractiveMap.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -579,4 +579,11 @@ describe('InteractiveMap — Public API Methods', () => {
expect(map.eventBus.emitWhenReady).toHaveBeenCalledWith('map:fittobounds', bbox)
expect(map.eventBus.emitWhenReady).toHaveBeenCalledWith('map:setview', { center, zoom: 12 })
})

it('showHint and dismissHint emit correct events', () => {
map.showHint('Press <kbd>Enter</kbd> to select', { duration: 2000 })
map.dismissHint()
expect(map.eventBus.emit).toHaveBeenCalledWith('app:showhint', { text: 'Press <kbd>Enter</kbd> to select', options: { duration: 2000 } })
expect(map.eventBus.emit).toHaveBeenCalledWith('app:dismisshint')
})
})
Loading
Loading