Skip to content
Open
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
20 changes: 16 additions & 4 deletions client/src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
dockHideTimeoutMs,
wantsActivityListeners,
} from './utils/dockIdleHide.js';
import { createDialogWatcher } from './utils/dialogVisibility.js';
import {
BLOCK_PINCH_TOUCH_ACTION,
attachTwoFingerSwipe,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2922,9 +2932,11 @@ const App = () => {
</DialogContent>
</Dialog>

{/* 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) && (
<TabBar
dropTarget={carry.hoverTarget}
Expand All @@ -2941,7 +2953,7 @@ const App = () => {
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}
Expand Down
66 changes: 66 additions & 0 deletions client/src/utils/dialogVisibility.js
Original file line number Diff line number Diff line change
@@ -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;
},
};
}
101 changes: 101 additions & 0 deletions client/src/utils/dialogVisibility.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading