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
7 changes: 7 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
wb-mqtt-homeui (2.253.2) stable; urgency=medium

* Always show a scrollbar in the config editor tab list when it overflows
* Fit the config editor tab list and pane into the window so they scroll instead of the page

-- Valerii Trofimov <valeriy.trofimov@wirenboard.com> Thu, 10 Sep 2026 15:25:02 +0300

wb-mqtt-homeui (2.253.1) stable; urgency=medium

* Fix json-editor silently dropping an enabled optional parameter when its
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { JSONEditor } from '@wirenboard/json-editor';
import { TAB_LIST_SCROLLBAR_CLASS, TAB_LIST_THUMB_CLASS } from '../tab-list-layout';
import '@/components/textarea/styles.css';

export function makeWbBootstrap3Theme() {
Expand All @@ -11,6 +12,19 @@ export function makeWbBootstrap3Theme() {
return el;
}

getTabHolder(propertyName) {
const el = super.getTabHolder(propertyName);
// tab-list-layout places and shows it, the library indexes only children[0] and [1]
const scrollbar = document.createElement('div');
scrollbar.className = TAB_LIST_SCROLLBAR_CLASS;
scrollbar.hidden = true;
const thumb = document.createElement('div');
thumb.className = TAB_LIST_THUMB_CLASS;
scrollbar.appendChild(thumb);
el.appendChild(scrollbar);
return el;
}

getTab(text, tabId) {
const li = document.createElement('li');
li.setAttribute('role', 'presentation');
Expand Down
51 changes: 2 additions & 49 deletions frontend/src/components/json-editor/json-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,10 @@ import { observer } from 'mobx-react-lite';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import i18n from '@/i18n/config';
import { createJSONEditor } from './extensions/wb-json-editor';
import { attachTabListLayout } from './tab-list-layout';
import { type JsonEditorProps } from './types';
import './styles.css';

// Cap the sticky vertical tab list so it scrolls on its own instead of running off-screen.
const TAB_LIST_SELECTOR = 'ul.nav-stacked';
const VIEWPORT_GAP = 12;
const DESKTOP_QUERY = '(min-width: 992px)';
// floor so a short form can't crush the list to a couple of rows
const MIN_TAB_LIST_HEIGHT = 360;

const syncTabListMaxHeight = (root: HTMLElement | null) => {
if (!root) {
return;
}
const isDesktop = window.matchMedia(DESKTOP_QUERY).matches;
root.querySelectorAll<HTMLElement>(TAB_LIST_SELECTOR).forEach((list) => {
if (!isDesktop) {
list.style.maxHeight = '';
return;
}
// clamp the top to the pinned position, else a list scrolled above the fold un-caps
const top = Math.max(list.getBoundingClientRect().top, VIEWPORT_GAP);
const viewportAvailable = window.innerHeight - top - VIEWPORT_GAP;
// follow the content pane (no towering over a short form), floored by MIN and capped by the viewport
const sibling = Array.from(list.parentElement?.children ?? []).find((el) => el !== list);
const contentHeight = sibling ? sibling.getBoundingClientRect().height : viewportAvailable;
const available = Math.min(viewportAvailable, Math.max(contentHeight, MIN_TAB_LIST_HEIGHT));
list.style.maxHeight = available > 0 ? `${available}px` : '';
});
};

export const JsonEditor = observer((props: JsonEditorProps) => {
const container = useRef<HTMLDivElement>(null);
let jse = useRef(null);
Expand Down Expand Up @@ -87,27 +60,7 @@ export const JsonEditor = observer((props: JsonEditorProps) => {

useEffect(() => {
const root = container.current;
if (!root) {
return undefined;
}
let frame = 0;
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => syncTabListMaxHeight(root));
};
schedule();
// capture phase to catch the inner page container scroll, not just window
window.addEventListener('scroll', schedule, true);
window.addEventListener('resize', schedule);
// recompute when tabs change or content resizes the layout
const resizeObserver = new ResizeObserver(schedule);
resizeObserver.observe(root);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', schedule, true);
window.removeEventListener('resize', schedule);
resizeObserver.disconnect();
};
return root ? attachTabListLayout(root) : undefined;
}, []);

return <div ref={container} className={classNames('json-editor', props.className)} />;
Expand Down
70 changes: 60 additions & 10 deletions frontend/src/components/json-editor/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,10 @@
.json-editor div:has(> ul.nav-pills) {
display: flex;
gap: 12px;
/* don't stretch the content pane to the (taller) sticky tab list */
/* don't stretch the content pane to a taller tab list */
align-items: flex-start;
/* anchors the tab list scrollbar placed by tab-list-layout */
position: relative;

@media (max-width: 991px) {
flex-direction: column;
Expand Down Expand Up @@ -194,15 +196,63 @@
border: 1px solid var(--border-color) !important;
}

.json-editor ul.nav-tabs.nav-stacked,
.json-editor ul.nav-pills.nav-stacked {
@media (min-width: 992px) {
position: sticky;
top: 0;
align-self: flex-start;
max-height: calc(100vh - 24px);
overflow-y: auto;
scrollbar-width: thin;
/* top-level tabs capped by tab-list-layout, the list and the pane scroll inside */
.json-editor .wb-jsonEditor-tabsFit > .tab-content {
overflow-y: auto;
/* the cap reaches the area bottom, a margin would push the page past it */
margin-bottom: 0;
}

/* tabs starting too low for that, the list sticks to the top of the scrolling page */
.json-editor .wb-jsonEditor-tabsSticky > ul.nav-stacked {
position: sticky;
top: 0;
}

.json-editor .wb-jsonEditor-tabsFit > ul.nav-stacked,
.json-editor .wb-jsonEditor-tabsSticky > ul.nav-stacked {
overflow-y: auto;
/* tab-list-layout draws it, the browser one is overlay on Firefox for Windows 11, macOS and Edge */
scrollbar-width: none;
}

/* same for Safari before 18.2, which has no scrollbar-width */
.json-editor .wb-jsonEditor-tabsFit > ul.nav-stacked::-webkit-scrollbar,
.json-editor .wb-jsonEditor-tabsSticky > ul.nav-stacked::-webkit-scrollbar {
display: none;
}

/* set while the list overflows, so rows end at the drawn scrollbar as with a native one */
.json-editor .wb-jsonEditor-tabListGutter {
padding-right: 10px;
}

.json-editor .wb-jsonEditor-tabListScrollbar {
position: absolute;
width: 10px;
background: var(--scrollbar-background);
}

/* an empty div, so the selector has to outrank the div:empty rule */
.json-editor .wb-jsonEditor-tabListScrollbar .wb-jsonEditor-tabListThumb {
display: block;
position: absolute;
left: 2px;
width: 6px;
border-radius: 3px;
background: var(--scrollbar-color);
touch-action: none;
}

.json-editor .wb-jsonEditor-tabListScrollbar .wb-jsonEditor-tabListThumb:hover,
.json-editor .wb-jsonEditor-tabListScrollbar .wb-jsonEditor-tabListThumbActive {
background: color-mix(in srgb, var(--scrollbar-color), var(--text-color) 35%);
}

/* forced colours paint the thumb like the list behind it, a system colour stays visible */
@media (forced-colors: active) {
.json-editor .wb-jsonEditor-tabListScrollbar .wb-jsonEditor-tabListThumb {
background: CanvasText;
}
}

Expand Down
186 changes: 186 additions & 0 deletions frontend/src/components/json-editor/tab-list-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// @vitest-environment happy-dom
import { attachTabListLayout, TAB_LIST_SCROLLBAR_CLASS, TAB_LIST_THUMB_CLASS } from './tab-list-layout';

const GAP = 12;
const ROW_HEIGHT = 45;

interface Geometry {
rows?: number;
holderTop?: number;
/** bottom of the scrolling page area, above the window bottom while the console panel is open */
scrollerBottom?: number;
/** what the editor keeps under the tabs, a trailing array on the NTP page */
trailing?: number;
/** a list inside another tab pane, as the channels of a WBIO module are */
nested?: boolean;
/** in the DOM but not rendered, as a KNX device list with no devices is */
empty?: boolean;
desktop?: boolean;
}

const rect = (top: number, bottom: number) => ({
top, bottom, left: 0, right: 200, width: 200, height: bottom - top, x: 0, y: top, toJSON: () => ({}),
}) as DOMRect;

const stubRect = (el: HTMLElement, top: number, bottom: number) => {
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue(rect(top, bottom));
};

const buildEditor = ({
rows = 4, holderTop = 100, scrollerBottom = 900 - GAP, trailing = 0, nested = false, empty = false, desktop = true,
}: Geometry = {}) => {
vi.stubGlobal('innerHeight', 900);
vi.stubGlobal('matchMedia', () => ({ matches: desktop }));
vi.stubGlobal('requestAnimationFrame', (cb: (time: number) => void) => {
cb(0);
return 0;
});
vi.stubGlobal('cancelAnimationFrame', () => {});
vi.stubGlobal('ResizeObserver', class {
observe() {}
disconnect() {}
});
vi.stubGlobal('MutationObserver', class {
observe() {}
disconnect() {}
});

const scroller = document.createElement('div');
scroller.style.overflowY = 'auto';
const root = document.createElement('div');
root.className = 'json-editor';
const outerPane = document.createElement('div');
outerPane.className = 'tab-content';
const holder = document.createElement('div');
const list = document.createElement('ul');
list.className = 'nav nav-pills nav-stacked';
const pane = document.createElement('div');
pane.className = 'tab-content';
const scrollbar = document.createElement('div');
scrollbar.className = TAB_LIST_SCROLLBAR_CLASS;
scrollbar.hidden = true;
const thumb = document.createElement('div');
thumb.className = TAB_LIST_THUMB_CLASS;

scrollbar.appendChild(thumb);
holder.append(list, pane, scrollbar);
if (nested) {
outerPane.appendChild(holder);
root.appendChild(outerPane);
} else {
root.appendChild(holder);
}
scroller.appendChild(root);
document.body.appendChild(scroller);

const scrollHeight = rows * ROW_HEIGHT;
Object.defineProperty(list, 'scrollHeight', { get: () => scrollHeight, configurable: true });
Object.defineProperty(list, 'clientHeight', {
get: () => Math.min(scrollHeight, parseFloat(list.style.maxHeight) || scrollHeight),
configurable: true,
});
vi.spyOn(list, 'getClientRects').mockReturnValue(
(empty ? [] : [rect(holderTop, holderTop + scrollHeight)]) as unknown as DOMRectList,
);

stubRect(scroller, GAP, scrollerBottom);
stubRect(holder, holderTop, holderTop + scrollHeight);
stubRect(root, holderTop, holderTop + scrollHeight + trailing);
stubRect(list, holderTop, holderTop + scrollHeight);

return { root, holder, list, pane, scrollbar, dispose: attachTabListLayout(root) };
};

afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

describe('attachTabListLayout, a top-level tab list', () => {
// the tabs start at 100 in a 900px window
test.each([
{ name: 'is capped at the page area bottom', scrollerBottom: 888, trailing: 0, cap: 788 },
{ name: 'follows an area shortened by the console panel', scrollerBottom: 600, trailing: 0, cap: 500 },
{ name: 'leaves room for the fields under the tabs', scrollerBottom: 888, trailing: 120, cap: 668 },
])('$name', ({ scrollerBottom, trailing, cap }) => {
const { holder, list, pane } = buildEditor({ rows: 20, scrollerBottom, trailing });

expect(holder.classList.contains('wb-jsonEditor-tabsFit')).toBe(true);
expect(list.style.maxHeight).toBe(`${cap}px`);
// the pane is capped too, so it scrolls on its own instead of the page
expect(pane.style.maxHeight).toBe(`${cap}px`);
});

test('starting too low for a cap of its own, sticks to the top of the scrolling page instead', () => {
// a long form above the tabs: only 88px left below them
const { holder, list, pane } = buildEditor({ rows: 20, holderTop: 800, scrollerBottom: 888, trailing: 20 });

expect(holder.classList.contains('wb-jsonEditor-tabsSticky')).toBe(true);
expect(holder.classList.contains('wb-jsonEditor-tabsFit')).toBe(false);
// as tall as the area allows once pinned, wherever the list stands now
expect(list.style.maxHeight).toBe('856px');
// the page scrolls in this mode, so the pane keeps its natural height
expect(pane.style.maxHeight).toBe('');
});

test.each([
{ name: 'a nested list, which scrolls with the pane around it', geometry: { nested: true } },
{ name: 'an empty list, which is not rendered at all', geometry: { empty: true } },
{ name: 'any list below the desktop width', geometry: { desktop: false } },
])('leaves $name alone', ({ geometry }) => {
const { holder, list, pane, scrollbar } = buildEditor({ rows: 20, ...geometry });

expect(holder.className).toBe('');
expect(list.style.maxHeight).toBe('');
expect(pane.style.maxHeight).toBe('');
expect(scrollbar.hidden).toBe(true);
});
});

describe('attachTabListLayout, the drawn scrollbar', () => {
test('appears with the row gutter only while the list overflows its cap', () => {
const overflowing = buildEditor({ rows: 20 });

expect(overflowing.scrollbar.hidden).toBe(false);
expect(overflowing.list.classList.contains('wb-jsonEditor-tabListGutter')).toBe(true);

overflowing.dispose();
document.body.innerHTML = '';
const short = buildEditor({ rows: 4 });

expect(short.scrollbar.hidden).toBe(true);
// no gutter, so rows and the selected-row marker reach the border
expect(short.list.classList.contains('wb-jsonEditor-tabListGutter')).toBe(false);
});

test('drags the list by the thumb', () => {
const { list, scrollbar } = buildEditor({ rows: 20 });
const thumb = scrollbar.querySelector<HTMLElement>(`.${TAB_LIST_THUMB_CLASS}`)!;
Object.defineProperty(thumb, 'offsetHeight', { value: 100, configurable: true });
thumb.setPointerCapture = vi.fn();

thumb.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientY: 0 }));
thumb.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientY: 50 }));

// 50px of a (788 - 100) travel over a (900 - 788) scrollable range
expect(Math.round(list.scrollTop)).toBe(8);
expect(thumb.classList.contains('wb-jsonEditor-tabListThumbActive')).toBe(true);

thumb.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientY: 50 }));
expect(thumb.classList.contains('wb-jsonEditor-tabListThumbActive')).toBe(false);
});
});

describe('attachTabListLayout teardown', () => {
test('stops recomputing after dispose', () => {
const { list, dispose } = buildEditor({ rows: 20 });
expect(list.style.maxHeight).toBe('788px');

dispose();
list.style.maxHeight = '';
window.dispatchEvent(new Event('resize'));

expect(list.style.maxHeight).toBe('');
});
});
Loading