diff --git a/demo/draw-ol.html b/demo/draw-ol.html index e7cd812ea..175f3f97e 100644 --- a/demo/draw-ol.html +++ b/demo/draw-ol.html @@ -5,7 +5,7 @@
Hi
', desktop: { slot: 'left-top' } } } + const hidden = renderWithSlots({ controlConfig, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['c2'] }] }) + expect(hidden.container.querySelector('.im-c-control').style.display).toBe('none') + const kept = renderWithSlots({ controlConfig, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['c1'] }] }) + expect(kept.container.querySelector('[data-slot="left-top"] .im-c-control').style.display).toBe('') + }) + + it('hides an open non-modal consumer panel that exclusive control doesn\'t keep, but never a modal one', () => { + const exclusiveControl = [{ pluginId: 'draw', name: null, keep: [] }] + const nonModal = renderWithSlots({ + panelConfig: { p1: { html: 'Hi
', label: 'Test', desktop: { slot: 'left-top' } } }, + openPanels: { p1: { props: {} } }, + exclusiveControl + }) + expect(nonModal.getByTestId('panel-p1').style.display).toBe('none') + nonModal.unmount() + const modal = renderWithSlots({ + panelConfig: { p1: { html: 'Hi
', label: 'Test', desktop: { slot: 'side', modal: true } } }, + openPanels: { p1: { props: {} } }, + exclusiveControl + }) + expect(modal.getByTestId('panel-p1').style.display).toBe('') + }) + it('hides panel when slot is not allowed', () => { const { getByTestId } = renderWithSlots({ panelConfig: { p1: { html: 'Hi
', label: 'Test', desktop: { slot: 'invalid' } } }, diff --git a/src/App/renderer/mapButtons.js b/src/App/renderer/mapButtons.js index 80351c290..a96cb57b0 100755 --- a/src/App/renderer/mapButtons.js +++ b/src/App/renderer/mapButtons.js @@ -5,6 +5,7 @@ import { groupByKey } from './groupByKey.js' import { orderItems } from './orderItems.js' import { classifyPanel, getPanelRole } from '../../utils/getPanelRole.js' import { logger } from '../../services/logger.js' +import { isHiddenByExclusiveControl } from './slotHelpers.js' function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) { const { breakpoint, mode } = appState @@ -95,6 +96,12 @@ function applySlotExclusivity (matching, appState) { return matching.filter(([_, config]) => config.pluginId === exclusivePluginId) } +// Hidden via toggleButtonState/hiddenWhen, or by another plugin's exclusive control. Either way the +// button stays mounted (display: none), so its refs, focus-return target and state survive. +const isButtonHidden = (buttonId, config, appState) => + appState.hiddenButtons.has(buttonId) || + isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [buttonId], pluginId: config.pluginId }) + /** * Builds the props for aHi
' }) }) + it('hides an HTML control (keeping it mounted) when exclusive control doesn\'t keep it', () => { + defaultAppState.controlConfig = ({ + ctrlHtml: { id: 'ctrlHtml', pluginId: 'plugin1', desktop: { slot: 'header' }, html: 'Hi
', includeModes: ['view'] } + }) + const style = () => mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })[0].element.props.style + expect(style()).toBeUndefined() + defaultAppState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: [] }] + expect(style()).toEqual({ display: 'none' }) + defaultAppState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['ctrlHtml'] }] + expect(style()).toBeUndefined() + }) + + it('always wraps a plugin control, switching the wrapper to display: none when exclusive control hides it', () => { + defaultAppState.controlConfig = ({ + ctrl1: { id: 'ctrl1', desktop: { slot: 'header' }, render: () => null, includeModes: ['view'] } + }) + const wrapper = () => mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })[0].element + expect(wrapper().props).toMatchObject({ className: 'im-c-control-wrapper', style: { display: 'contents' } }) + defaultAppState.exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['mapStyles'] }] + expect(wrapper().props).toMatchObject({ className: 'im-c-control-wrapper im-c-control-wrapper--hidden', style: { display: 'none' } }) + // The claiming plugin's own controls stay + defaultAppState.exclusiveControl = [{ pluginId: 'plugin1', name: null, keep: [] }] + expect(wrapper().props.style).toEqual({ display: 'contents' }) + }) + it('filters out consumer HTML controls (handled by HtmlElementHost)', () => { defaultAppState.controlConfig = ({ ctrlHtml: { id: 'ctrlHtml', desktop: { slot: 'header' }, html: 'Hi
', includeModes: ['view'] } diff --git a/src/App/renderer/mapPanels.js b/src/App/renderer/mapPanels.js index f44314a68..98cd561a1 100755 --- a/src/App/renderer/mapPanels.js +++ b/src/App/renderer/mapPanels.js @@ -2,7 +2,7 @@ import React from 'react' import { withPluginContexts } from './pluginWrapper.js' import { Panel } from '../components/Panel/Panel.jsx' -import { resolveTargetSlot, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId } from './slotHelpers.js' +import { resolveTargetSlot, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, isHiddenByExclusiveControl } from './slotHelpers.js' import { mapControls } from './mapControls.js' import { orderItems } from './orderItems.js' import { groupIntoTabs } from './groupIntoTabs.js' @@ -57,6 +57,9 @@ export function mapPanels ({ slot, appState, evaluateProp }) { const plugin = pluginRegistry.registeredPlugins.find(p => p.id === config.pluginId) const pluginId = plugin?.id + // Modal panels are never hidden by exclusive control, so focus is never trapped in a hidden one + const isHidden = !bpConfig.modal && isHiddenByExclusiveControl(appState.exclusiveControl, { ids: [panelId], pluginId }) + const html = pluginId ? evaluateProp(config.html, pluginId) : config.html const label = evaluateProp(config.label, pluginId) @@ -72,6 +75,7 @@ export function mapPanels ({ slot, appState, evaluateProp }) { props={props} focusOnOpen={focusOnOpen} isOpen={isOpen} + isHidden={isHidden} {...(isOpen ? buildPanelBody({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp }) : {})} label={label} html={html} diff --git a/src/App/renderer/mapPanels.test.js b/src/App/renderer/mapPanels.test.js index 450e78747..ad1d2dfb7 100755 --- a/src/App/renderer/mapPanels.test.js +++ b/src/App/renderer/mapPanels.test.js @@ -114,6 +114,15 @@ describe('mapPanels', () => { expect(result).toEqual([]) }) + it('hides a non-modal panel exclusive control doesn\'t keep, but never a modal one', () => { + const exclusiveControl = [{ pluginId: 'draw', name: null, keep: ['other'] }] + expect(map({ ...defaultAppState, exclusiveControl })[0].element.props.isHidden).toBe(true) + expect(map({ ...defaultAppState, exclusiveControl: [{ pluginId: 'draw', name: null, keep: ['p1'] }] })[0].element.props.isHidden).toBe(false) + expect(map(defaultAppState)[0].element.props.isHidden).toBe(false) + defaultAppState.panelConfig = ({ p1: { desktop: { modal: true }, includeModes: ['view'] } }) + expect(map({ ...defaultAppState, exclusiveControl }, 'modal')[0].element.props.isHidden).toBe(false) + }) + it('renders both modal panels\' shells but only marks the last-opened one as open', () => { defaultAppState.panelConfig = ({ p1: { desktop: { modal: true }, includeModes: ['view'] }, diff --git a/src/App/renderer/pluginWrapper.js b/src/App/renderer/pluginWrapper.js index b3fb94b53..12211fc1c 100755 --- a/src/App/renderer/pluginWrapper.js +++ b/src/App/renderer/pluginWrapper.js @@ -49,10 +49,11 @@ export function withPluginContexts (Component, { pluginId, pluginConfig }) { const services = useService() const pluginState = usePlugin(pluginId) // Bound to this plugin's id so authors never pass it: true claims control, a string claims it - // with a name (--{name} class suffix), anything falsy releases it. - const setExclusiveControl = useCallback((value) => appState.dispatch({ + // with a name (--{name} class suffix), anything falsy releases it. options.keep, if given, has + // core hide every button, panel and control not listed (or not this plugin's own) meanwhile. + const setExclusiveControl = useCallback((value, { keep = null } = {}) => appState.dispatch({ type: 'SET_EXCLUSIVE_CONTROL', - payload: { pluginId, name: typeof value === 'string' ? value : null, active: !!value } + payload: { pluginId, name: typeof value === 'string' ? value : null, keep, active: !!value } }), [appState.dispatch]) return ( diff --git a/src/App/renderer/pluginWrapper.test.js b/src/App/renderer/pluginWrapper.test.js index c8c8cb969..e77274d8f 100755 --- a/src/App/renderer/pluginWrapper.test.js +++ b/src/App/renderer/pluginWrapper.test.js @@ -52,7 +52,19 @@ describe('withPluginContexts', () => { render(