Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
wb-mqtt-homeui (2.251.2) stable; urgency=medium

* Always show a scrollbar in the config editor tab list when it overflows

-- Valerii Trofimov <valeriy.trofimov@wirenboard.com> Tue, 08 Sep 2026 19:01:00 +0300

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

* Fix dropdown multiline options overlap in small size
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ export function makeWbBootstrap3Theme() {
return el;
}

getTabHolder(propertyName) {
const el = super.getTabHolder(propertyName);
// json-editor.tsx positions and shows it, the library indexes only children[0] and [1]
const scrollbar = document.createElement('div');
scrollbar.className = 'je-tablist-scrollbar';
scrollbar.hidden = true;
const thumb = document.createElement('div');
thumb.className = 'je-tablist-thumb';
scrollbar.appendChild(thumb);
el.appendChild(scrollbar);
return el;
}

getTab(text, tabId) {
const li = document.createElement('li');
li.setAttribute('role', 'presentation');
Expand Down
121 changes: 98 additions & 23 deletions frontend/src/components/json-editor/json-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,70 @@ 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 { type JsonEditorProps } from './types';
import { type JsonEditorProps, type TabListThumbDrag } from './types';
import './styles.css';

// Cap the sticky vertical tab list so it scrolls on its own instead of running off-screen.
// The sticky vertical tab list is capped so it scrolls on its own instead of running off-screen.
// Its scrollbar is drawn here, the native one is hidden in styles.css.
const TAB_LIST_SELECTOR = 'ul.nav-stacked';
const VIEWPORT_GAP = 12;
const SCROLLBAR_SELECTOR = '.je-tablist-scrollbar';
const THUMB_SELECTOR = '.je-tablist-thumb';
const THUMB_ACTIVE_CLASS = 'je-tablist-thumb--active';
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 VIEWPORT_GAP = 12;
// floor for a short form, not a multiple of the row height so the next row peeks out
const MIN_TAB_LIST_HEIGHT = 390;
const MIN_THUMB_HEIGHT = 24;

const syncTabList = (list: HTMLElement, isDesktop: boolean) => {
const holder = list.parentElement;
const scrollbar = holder?.querySelector<HTMLElement>(`:scope > ${SCROLLBAR_SELECTOR}`);
if (!isDesktop) {
list.style.maxHeight = '';
if (scrollbar) {
scrollbar.hidden = true;
}
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(holder?.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` : '';

const { clientHeight, scrollHeight, scrollTop } = list;
const canScroll = scrollHeight > clientHeight;
if (!scrollbar) {
return;
}
scrollbar.hidden = !canScroll;
if (!canScroll) {
return;
}
const holderRect = holder.getBoundingClientRect();
const listRect = list.getBoundingClientRect();
// inside the 1px border, same on every side
scrollbar.style.top = `${listRect.top - holderRect.top + list.clientTop}px`;
scrollbar.style.right = `${holderRect.right - listRect.right + list.clientLeft}px`;
scrollbar.style.height = `${clientHeight}px`;
const thumb = scrollbar.querySelector<HTMLElement>(THUMB_SELECTOR);
if (thumb) {
const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.round((clientHeight * clientHeight) / scrollHeight));
const thumbTop = Math.round((scrollTop / (scrollHeight - clientHeight)) * (clientHeight - thumbHeight));
thumb.style.height = `${thumbHeight}px`;
thumb.style.top = `${thumbTop}px`;
}
};

const syncTabListMaxHeight = (root: HTMLElement | null) => {
const syncTabLists = (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` : '';
});
root.querySelectorAll<HTMLElement>(TAB_LIST_SELECTOR).forEach((list) => syncTabList(list, isDesktop));
};

export const JsonEditor = observer((props: JsonEditorProps) => {
Expand Down Expand Up @@ -93,20 +128,60 @@ export const JsonEditor = observer((props: JsonEditorProps) => {
let frame = 0;
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => syncTabListMaxHeight(root));
frame = requestAnimationFrame(() => syncTabLists(root));
};
schedule();
// capture phase to catch the inner page container scroll, not just window
// capture phase to catch the inner page container scroll and the tab list scroll, not just window
window.addEventListener('scroll', schedule, true);
window.addEventListener('resize', schedule);
// recompute when tabs change or content resizes the layout
// recompute when content resizes the layout
const resizeObserver = new ResizeObserver(schedule);
resizeObserver.observe(root);
// and when tabs are added or removed, a capped list keeps its size while its content grows
const mutationObserver = new MutationObserver(schedule);
mutationObserver.observe(root, { childList: true, subtree: true });

let drag: TabListThumbDrag | null = null;
const onPointerDown = (e: PointerEvent) => {
const thumb = (e.target as HTMLElement).closest<HTMLElement>(THUMB_SELECTOR);
const scrollbar = thumb?.parentElement;
const list = scrollbar?.parentElement?.querySelector<HTMLElement>(TAB_LIST_SELECTOR);
if (!thumb || !list) {
return;
}
e.preventDefault();
drag = { list, scrollbar, thumb, startY: e.clientY, startScrollTop: list.scrollTop };
thumb.classList.add(THUMB_ACTIVE_CLASS);
thumb.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!drag) {
return;
}
const { list, scrollbar, thumb, startY, startScrollTop } = drag;
const travel = scrollbar.clientHeight - thumb.offsetHeight;
if (travel > 0) {
list.scrollTop = startScrollTop + ((e.clientY - startY) * (list.scrollHeight - list.clientHeight)) / travel;
}
};
const onPointerUp = () => {
drag?.thumb.classList.remove(THUMB_ACTIVE_CLASS);
drag = null;
};
root.addEventListener('pointerdown', onPointerDown);
root.addEventListener('pointermove', onPointerMove);
root.addEventListener('pointerup', onPointerUp);
root.addEventListener('pointercancel', onPointerUp);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', schedule, true);
window.removeEventListener('resize', schedule);
resizeObserver.disconnect();
mutationObserver.disconnect();
root.removeEventListener('pointerdown', onPointerDown);
root.removeEventListener('pointermove', onPointerMove);
root.removeEventListener('pointerup', onPointerUp);
root.removeEventListener('pointercancel', onPointerUp);
};
}, []);

Expand Down
38 changes: 37 additions & 1 deletion frontend/src/components/json-editor/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@
gap: 12px;
/* don't stretch the content pane to the (taller) sticky tab list */
align-items: flex-start;
/* anchors the tab list scrollbar placed by json-editor.tsx */
position: relative;
--tablist-scrollbar-width: 10px;

@media (max-width: 991px) {
flex-direction: column;
Expand Down Expand Up @@ -202,10 +205,43 @@
align-self: flex-start;
max-height: calc(100vh - 24px);
overflow-y: auto;
scrollbar-width: thin;
/* the browser scrollbar is hidden, json-editor.tsx draws its own so it stays visible
where the browser one is overlay (Firefox on Windows 11, macOS, Edge) */
scrollbar-width: none;
/* rows end at the drawn scrollbar, as with a native one */
padding-right: var(--tablist-scrollbar-width);
}
}

/* same for Safari before 18.2, which has no scrollbar-width */
.json-editor ul.nav-stacked::-webkit-scrollbar {
@media (min-width: 992px) {
display: none;
}
}

.json-editor .je-tablist-scrollbar {
position: absolute;
width: var(--tablist-scrollbar-width);
background: var(--scrollbar-background);
}

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

.json-editor .je-tablist-scrollbar .je-tablist-thumb:hover,
.json-editor .je-tablist-scrollbar .je-tablist-thumb--active {
background: color-mix(in srgb, var(--scrollbar-color), var(--text-color) 35%);
}

.json-editor .nav li {
list-style: none;
padding: 12px !important;
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/components/json-editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,11 @@ export interface JsonEditorProps {
cells?: Option<string>[];
onChange: (_val: any, _errors: any[]) => void;
}

export interface TabListThumbDrag {
list: HTMLElement;
scrollbar: HTMLElement;
thumb: HTMLElement;
startY: number;
startScrollTop: number;
}