diff --git a/client/src/app.jsx b/client/src/app.jsx index 2c4db6ac..da24a95e 100644 --- a/client/src/app.jsx +++ b/client/src/app.jsx @@ -32,6 +32,7 @@ import { dockHideTimeoutMs, wantsActivityListeners, } from './utils/dockIdleHide.js'; +import { createDialogWatcher } from './utils/dialogVisibility.js'; import { BLOCK_PINCH_TOUCH_ACTION, attachTwoFingerSwipe, @@ -1211,6 +1212,15 @@ const App = () => { // phone, where the dock is the navigation, and never while Admin is open. const dockHideMs = isMobile ? null : dockHideTimeoutMs(screensaverSettings.dockHideMinutes); const [dockHidden, setDockHidden] = useState(false); + // A dialog anywhere hides the dock too: the dock sits above MUI dialogs, + // so a dialog opened from a widget would end up with its bottom buttons + // under it. Any open dialog hides it, on any screen, and it returns when + // the last dialog closes (stacked dialogs included — the watcher counts). + const [dialogOpen, setDialogOpen] = useState(false); + useEffect(() => { + const watcher = createDialogWatcher({ onChange: setDialogOpen }); + return () => watcher.dispose(); + }, []); // How big the expanded widget is, if any (utils/widgetExpand.js). const [expandedWidgetSize, setExpandedWidgetSize] = useState('normal'); const dockIdleRef = useRef(null); @@ -2922,9 +2932,11 @@ const App = () => { - {/* Floating Dock TabBar. The dock renders above MUI dialogs, so hide it - while the Admin Panel is open full-screen on mobile — otherwise it - covers the bottom action buttons of the panel's dialogs. */} + {/* Floating Dock TabBar. The dock renders above MUI dialogs, so it + hides whenever one is open (any dialog, any screen — dialogOpen + above), and while the Admin Panel is open full-screen on mobile it + is not rendered at all — otherwise it covers the bottom action + buttons of the dialogs. */} {!(isMobile && showAdminPanel) && ( { onOpenSettings={toggleAdminPanel} onRefresh={handlePageRefresh} // A widget filling the whole page covers the dock too. - hidden={!carry.dragging && (dockHidden || expandedWidgetSize === 'full')} + hidden={!carry.dragging && (dockHidden || expandedWidgetSize === 'full' || dialogOpen)} underDialogs={showAdminPanel} theme={displayTheme} themeMode={themeMode} diff --git a/client/src/utils/dialogVisibility.js b/client/src/utils/dialogVisibility.js new file mode 100644 index 00000000..12edbc00 --- /dev/null +++ b/client/src/utils/dialogVisibility.js @@ -0,0 +1,66 @@ +// Helpers behind "hide the dock while a dialog is open". The floating dock +// renders above MUI dialogs (z-index 9999), so on a phone it covers the +// bottom action buttons of a dialog opened from a widget — the dialog's own +// buttons end up under the dock. The rule is general: any open dialog hides +// the dock, whatever opened it and on whatever screen, and the dock returns +// when the last dialog closes. +// +// How an open dialog is recognised: a MUI Dialog renders its paper +// (role="dialog") inside a .MuiModal-root portal on document.body. Menus, +// select popups and other Modal users carry a different role (menu, +// listbox), so they never hide the dock. No dialog in this app uses +// keepMounted, so a closed dialog's modal root is removed from the document +// entirely — presence means open, including while it fades out. Counting +// (not a boolean) is what makes stacked dialogs work: two dialogs open at +// once must not bring the dock back when only one closes. +// +// What this does not cover: dialogs inside plugin iframes live in their own +// documents, which this watcher cannot see from the parent page. A plugin +// dialog's buttons can still end up under the dock; that needs a +// per-iframe approach, not this one. + +export const OPEN_DIALOG_SELECTOR = '.MuiModal-root [role="dialog"]'; + +// How many dialogs are open in a document-like root. A root without +// querySelectorAll (or no root at all) counts as none open. +export function countOpenDialogs(root) { + if (!root || typeof root.querySelectorAll !== 'function') return 0; + return root.querySelectorAll(OPEN_DIALOG_SELECTOR).length; +} + +// Watches the document for open dialogs. `onChange(open)` fires only when +// the state flips — open on the first dialog, closed again only when the +// last one goes. The initial scan covers a dialog that is already open +// when the watcher starts (for example the mirror PIN gate). Pass a +// MutationObserver implementation only to test with; the real one is used +// otherwise, and with no observer at all the watcher still reports the +// initial scan and never crashes. +export function createDialogWatcher({ + root = typeof document !== 'undefined' ? document : null, + onChange, + MutationObserverImpl = typeof MutationObserver !== 'undefined' ? MutationObserver : null, +} = {}) { + let open = false; + let observer = null; + + const refresh = () => { + const next = countOpenDialogs(root) > 0; + if (next === open) return; + open = next; + onChange(open); + }; + + if (root && root.body && MutationObserverImpl) { + observer = new MutationObserverImpl(() => refresh()); + observer.observe(root.body, { childList: true, subtree: true }); + } + refresh(); + + return { + refresh, + dispose() { + if (observer) observer.disconnect(); + observer = null; + }, + }; +} diff --git a/client/src/utils/dialogVisibility.test.js b/client/src/utils/dialogVisibility.test.js new file mode 100644 index 00000000..ba353c8c --- /dev/null +++ b/client/src/utils/dialogVisibility.test.js @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import { countOpenDialogs, createDialogWatcher, OPEN_DIALOG_SELECTOR } from './dialogVisibility.js'; + +// A document-like root whose dialog count the test controls. +const fakeRoot = (count) => ({ + body: {}, + querySelectorAll: (selector) => { + expect(selector).toBe(OPEN_DIALOG_SELECTOR); + return Array.from({ length: count }, () => ({})); + }, +}); + +describe('countOpenDialogs', () => { + it('counts the dialogs the selector finds', () => { + expect(countOpenDialogs(fakeRoot(0))).toBe(0); + expect(countOpenDialogs(fakeRoot(1))).toBe(1); + expect(countOpenDialogs(fakeRoot(3))).toBe(3); + }); + + it('counts none open without a searchable root', () => { + expect(countOpenDialogs(null)).toBe(0); + expect(countOpenDialogs(undefined)).toBe(0); + expect(countOpenDialogs({})).toBe(0); + }); +}); + +describe('createDialogWatcher', () => { + // A MutationObserver stand-in the test drives by hand: the watcher gets + // the callback, the test fires it after changing the fake root's count. + const fakeObserverClass = () => { + const instances = []; + return { + instances, + Impl: class { + constructor(callback) { + this.callback = callback; + this.observed = null; + this.disconnected = false; + instances.push(this); + } + + observe(target, options) { + this.observed = { target, options }; + } + + disconnect() { + this.disconnected = true; + } + }, + }; + }; + + it('fires only when the open state flips, so stacked dialogs keep the dock hidden', () => { + const changes = []; + let count = 0; + const root = { + body: {}, + querySelectorAll: () => Array.from({ length: count }, () => ({})), + }; + const { instances, Impl } = fakeObserverClass(); + const watcher = createDialogWatcher({ root, onChange: (open) => changes.push(open), MutationObserverImpl: Impl }); + + expect(changes).toEqual([]); + expect(instances).toHaveLength(1); + expect(instances[0].observed.options).toEqual({ childList: true, subtree: true }); + + count = 1; + instances[0].callback(); + expect(changes).toEqual([true]); + + count = 2; + instances[0].callback(); + expect(changes).toEqual([true]); + + count = 1; + instances[0].callback(); + expect(changes).toEqual([true]); + + count = 0; + instances[0].callback(); + expect(changes).toEqual([true, false]); + + watcher.dispose(); + expect(instances[0].disconnected).toBe(true); + }); + + it('reports a dialog that is already open on start', () => { + const changes = []; + const { Impl } = fakeObserverClass(); + createDialogWatcher({ root: fakeRoot(1), onChange: (open) => changes.push(open), MutationObserverImpl: Impl }); + expect(changes).toEqual([true]); + }); + + it('still reports the initial scan with no observer available', () => { + const changes = []; + const watcher = createDialogWatcher({ root: fakeRoot(1), onChange: (open) => changes.push(open), MutationObserverImpl: null }); + expect(changes).toEqual([true]); + expect(() => watcher.dispose()).not.toThrow(); + expect(() => watcher.refresh()).not.toThrow(); + }); +});