diff --git a/debian/changelog b/debian/changelog index 25367569d..b5200c4ba 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +wb-mqtt-homeui (2.255.3) stable; urgency=medium + + * Fit the config editor tab list and pane into the window so they scroll instead of the page + + -- Valerii Trofimov Mon, 14 Sep 2026 18:49:02 +0300 + wb-mqtt-homeui (2.255.2) stable; urgency=medium * Reject uploaded custom-font files whose content does not match their extension diff --git a/frontend/src/components/json-editor/extensions/wb-bootstrap3-theme.js b/frontend/src/components/json-editor/extensions/wb-bootstrap3-theme.js index 0989156dd..e819a004f 100644 --- a/frontend/src/components/json-editor/extensions/wb-bootstrap3-theme.js +++ b/frontend/src/components/json-editor/extensions/wb-bootstrap3-theme.js @@ -1,4 +1,5 @@ import { JSONEditor } from '@wirenboard/json-editor'; +import { TAB_HOLDER_CLASS, TAB_LIST_CLASS, TAB_PANE_CLASS } from '../tab-list-layout'; import '@/components/textarea/styles.css'; export function makeWbBootstrap3Theme() { @@ -11,6 +12,15 @@ export function makeWbBootstrap3Theme() { return el; } + getTabHolder(propertyName) { + const el = super.getTabHolder(propertyName); + // tab-list-layout and styles.css address the tabs by these instead of the Bootstrap names + el.classList.add(TAB_HOLDER_CLASS); + el.children[0].classList.add(TAB_LIST_CLASS); + el.children[1].classList.add(TAB_PANE_CLASS); + return el; + } + getTab(text, tabId) { const li = document.createElement('li'); li.setAttribute('role', 'presentation'); diff --git a/frontend/src/components/json-editor/json-editor.tsx b/frontend/src/components/json-editor/json-editor.tsx index 51584c84d..20bc1bcaf 100644 --- a/frontend/src/components/json-editor/json-editor.tsx +++ b/frontend/src/components/json-editor/json-editor.tsx @@ -4,40 +4,14 @@ 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(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(null); let jse = useRef(null); + const syncTabList = useRef<() => void>(null); const stateRef = useRef(null); const [schema, setSchema] = useState(undefined); const [firstStart, setFirstStart] = useState(true); @@ -56,6 +30,8 @@ export const JsonEditor = observer((props: JsonEditorProps) => { props.cells, ); editor.on('change', () => { + // a changed value can fold fields in or out and move the tabs + syncTabList.current?.(); if (props.onChange) { props.onChange(editor.getValue(), editor.validate(), stateRef.current); } @@ -63,6 +39,8 @@ export const JsonEditor = observer((props: JsonEditorProps) => { setFirstStart(false); } }); + // the form is new, its tabs have no caps yet + syncTabList.current?.(); // json-editor can modify an internal schema object, // so store original one to recreate editor only on real schema change setSchema(props.schema); @@ -86,28 +64,12 @@ export const JsonEditor = observer((props: JsonEditorProps) => { }); useEffect(() => { - const root = container.current; - if (!root) { + if (!container.current) { 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(); - }; + const { sync, dispose } = attachTabListLayout(container.current, () => jse.current); + syncTabList.current = sync; + return dispose; }, []); return
; diff --git a/frontend/src/components/json-editor/styles.css b/frontend/src/components/json-editor/styles.css index 418722fe1..56c282ea1 100644 --- a/frontend/src/components/json-editor/styles.css +++ b/frontend/src/components/json-editor/styles.css @@ -164,7 +164,7 @@ .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; @media (max-width: 991px) { @@ -194,16 +194,23 @@ 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; - } +/* tab-list-layout caps the top-level tabs, so the list and the pane scroll inside the page area */ +.json-editor .wb-jsonEditor-tabsFit > .wb-jsonEditor-tabPane { + overflow-y: auto; + /* the cap ends at the bottom of the area, a margin below would push the page past it */ + margin-bottom: 0; +} + +/* too little room left under the tabs for a cap, so the list is pinned while the page scrolls */ +.json-editor .wb-jsonEditor-tabsSticky > .wb-jsonEditor-tabList { + position: sticky; + top: 0; +} + +.json-editor .wb-jsonEditor-tabsFit > .wb-jsonEditor-tabList, +.json-editor .wb-jsonEditor-tabsSticky > .wb-jsonEditor-tabList { + overflow-y: auto; + scrollbar-width: thin; } .json-editor .nav li { diff --git a/frontend/src/components/json-editor/tab-list-layout.test.ts b/frontend/src/components/json-editor/tab-list-layout.test.ts new file mode 100644 index 000000000..a5bab0344 --- /dev/null +++ b/frontend/src/components/json-editor/tab-list-layout.test.ts @@ -0,0 +1,167 @@ +// @vitest-environment happy-dom +import { attachTabListLayout, TAB_HOLDER_CLASS, TAB_LIST_CLASS, TAB_PANE_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; + /** tabs on top, a row of them instead of a list beside the pane */ + topTabs?: boolean; + /** built by an object editor that lays its fields out in rows, so it never reaches the form */ + detached?: 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 buildHolder = (rows: number) => { + const holder = document.createElement('div'); + holder.className = TAB_HOLDER_CLASS; + const list = document.createElement('ul'); + list.className = TAB_LIST_CLASS; + Array.from({ length: rows }, () => list.appendChild(document.createElement('li'))); + const pane = document.createElement('div'); + pane.className = TAB_PANE_CLASS; + holder.append(list, pane); + return { holder, list, pane }; +}; + +const buildEditor = ({ + rows = 4, holderTop = 100, scrollerBottom = 900 - GAP, trailing = 0, nested = false, empty = false, + topTabs = false, detached = 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() {} + }); + + const scroller = document.createElement('div'); + scroller.style.overflowY = 'auto'; + const root = document.createElement('div'); + root.className = 'json-editor'; + const { holder, list, pane } = buildHolder(empty ? 0 : rows); + const outer = buildHolder(2); + if (topTabs) { + holder.classList.remove(TAB_HOLDER_CLASS); + } + + // the root object editor builds a holder of its own and lays its fields out in rows instead + const rootEditor = { tabs_holder: buildHolder(3).holder, parent: undefined }; + const outerEditor = { tabs_holder: outer.holder, parent: rootEditor }; + const editor = { tabs_holder: holder, parent: nested ? outerEditor : rootEditor }; + + if (nested) { + outer.pane.appendChild(holder); + root.appendChild(outer.holder); + } else if (!detached) { + root.appendChild(holder); + } + scroller.appendChild(root); + document.body.appendChild(scroller); + + const listHeight = (empty ? 0 : rows) * ROW_HEIGHT; + stubRect(scroller, GAP, scrollerBottom); + stubRect(holder, holderTop, holderTop + listHeight); + stubRect(root, holderTop, holderTop + listHeight + trailing); + stubRect(list, holderTop, holderTop + listHeight); + + const jsonEditor = { editors: { 'root.channels': editor, 'root.channels.0.items': outerEditor } }; + return { root, holder, list, pane, ...attachTabListLayout(root, () => jsonEditor) }; +}; + +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('is recomputed on demand, as the editor reshapes the form without resizing the root', () => { + const { root, holder, list, sync } = buildEditor({ rows: 20 }); + expect(list.style.maxHeight).toBe('788px'); + + // a field above the tabs unfolds and pushes them 200px down + stubRect(holder, 300, 1200); + stubRect(root, 300, 1200); + sync(); + + expect(list.style.maxHeight).toBe('588px'); + }); + + 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: 'tabs drawn on top, which the theme does not mark', geometry: { topTabs: true } }, + { name: 'a holder its editor left out of the form', geometry: { detached: true } }, + { name: 'any list below the desktop width', geometry: { desktop: false } }, + ])('leaves $name alone', ({ geometry }) => { + const { holder, list, pane } = buildEditor({ rows: 20, ...geometry }); + + expect(holder.classList.contains('wb-jsonEditor-tabsFit')).toBe(false); + expect(holder.classList.contains('wb-jsonEditor-tabsSticky')).toBe(false); + expect(list.style.maxHeight).toBe(''); + expect(pane.style.maxHeight).toBe(''); + }); +}); + +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(''); + }); +}); diff --git a/frontend/src/components/json-editor/tab-list-layout.ts b/frontend/src/components/json-editor/tab-list-layout.ts new file mode 100644 index 000000000..5cff06bb5 --- /dev/null +++ b/frontend/src/components/json-editor/tab-list-layout.ts @@ -0,0 +1,130 @@ +import { type TabbedEditor, type TabbedEditorRegistry } from './types'; + +// Keeps the top-level tab list and its pane inside the visible page area: both get a height cap +// and scroll on their own, so the page does not. When the form above the tabs leaves too little +// room for that, the list is pinned to the top of the area instead and the page scrolls as before. +const FIT_CLASS = 'wb-jsonEditor-tabsFit'; +const STICKY_CLASS = 'wb-jsonEditor-tabsSticky'; +const DESKTOP_QUERY = '(min-width: 992px)'; +const VIEWPORT_GAP = 12; +// a cap shorter than this leaves a cramped slot at the bottom of the page, pin the list instead +const MIN_FIT_HEIGHT = 240; + +// the theme puts these on the holder of a vertical tab list, so a row of tabs on top is left alone +export const TAB_HOLDER_CLASS = 'wb-jsonEditor-tabHolder'; +export const TAB_LIST_CLASS = 'wb-jsonEditor-tabList'; +export const TAB_PANE_CLASS = 'wb-jsonEditor-tabPane'; + +const closestScroller = (el: HTMLElement) => { + for (let node = el.parentElement; node; node = node.parentElement) { + const { overflowY } = getComputedStyle(node); + if (overflowY === 'auto' || overflowY === 'scroll') { + return node; + } + } + return null; +}; + +// top and bottom of what is on screen of the page area the tabs scroll in, +// which the open console panel cuts short from below +const visibleBounds = (el: HTMLElement) => { + const bounds = { top: VIEWPORT_GAP, bottom: window.innerHeight - VIEWPORT_GAP }; + const scroller = closestScroller(el); + if (!scroller) { + return bounds; + } + const rect = scroller.getBoundingClientRect(); + return { top: Math.max(bounds.top, rect.top), bottom: Math.min(bounds.bottom, rect.bottom) }; +}; + +// top of the element as if nothing were scrolled, so its cap does not change as the page scrolls +const unscrolledTop = (el: HTMLElement) => { + let top = el.getBoundingClientRect().top; + for (let node = el.parentElement; node; node = node.parentElement) { + top += node.scrollTop; + } + return top; +}; + +// tabs count only once their holder is in the form: an object editor builds one even when it +// lays its fields out in rows, and then never inserts it +const holdsTabs = (root: HTMLElement, editor: TabbedEditor) => + !!editor?.tabs_holder && root.contains(editor.tabs_holder); + +// a list inside another tab pane flows with that pane, only the outermost one is capped. +// walk the whole way up: one level of tabs is two editors, the tabbed array and the editor of its item +const isTopLevel = (root: HTMLElement, editor: TabbedEditor) => { + for (let parent = editor.parent; parent; parent = parent.parent) { + if (holdsTabs(root, parent)) { + return false; + } + } + return true; +}; + +const tabbedEditors = (root: HTMLElement, jsonEditor: TabbedEditorRegistry) => + Object.values(jsonEditor?.editors ?? {}).filter( + (editor) => + holdsTabs(root, editor) && + editor.tabs_holder.classList.contains(TAB_HOLDER_CLASS) && + isTopLevel(root, editor), + ); + +const syncTabList = (root: HTMLElement, editor: TabbedEditor, isDesktop: boolean) => { + const holder = editor.tabs_holder; + // the theme builds the holder as the tab list followed by the pane + const list = holder.children[0] as HTMLElement; + const pane = holder.children[1] as HTMLElement; + // an empty list is not drawn at all, as on a KNX page without devices + if (!isDesktop || !list.children.length) { + holder.classList.remove(FIT_CLASS, STICKY_CLASS); + list.style.maxHeight = ''; + pane.style.maxHeight = ''; + return; + } + const bounds = visibleBounds(holder); + // margins and fields the editor draws under the tabs have to fit under them too + const trailing = root.getBoundingClientRect().bottom - holder.getBoundingClientRect().bottom; + // what is left from the top of the tabs down to the bottom of the area + const available = bounds.bottom - unscrolledTop(holder) - trailing; + const isFit = available >= MIN_FIT_HEIGHT; + holder.classList.toggle(FIT_CLASS, isFit); + holder.classList.toggle(STICKY_CLASS, !isFit); + // pinned to the top, the list may run the whole height of the area + const listCap = isFit ? available : bounds.bottom - bounds.top - trailing; + list.style.maxHeight = listCap > 0 ? `${listCap}px` : ''; + pane.style.maxHeight = isFit ? `${available}px` : ''; +}; + +export const attachTabListLayout = (root: HTMLElement, getJsonEditor: () => TabbedEditorRegistry) => { + let frame = 0; + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const isDesktop = window.matchMedia(DESKTOP_QUERY).matches; + tabbedEditors(root, getJsonEditor()).forEach((editor) => syncTabList(root, editor, isDesktop)); + }); + }; + schedule(); + // in the capture phase: what scrolls is the page container or the list itself, never the window + window.addEventListener('scroll', schedule, true); + window.addEventListener('resize', schedule); + const resizeObserver = new ResizeObserver(schedule); + resizeObserver.observe(root); + // the console panel resizes that area without resizing either the window or the editor + const scroller = closestScroller(root); + if (scroller) { + resizeObserver.observe(scroller); + } + + return { + // the editor rebuilds and reshapes the form on its own, and not every reshape resizes the root + sync: schedule, + dispose: () => { + cancelAnimationFrame(frame); + window.removeEventListener('scroll', schedule, true); + window.removeEventListener('resize', schedule); + resizeObserver.disconnect(); + }, + }; +}; diff --git a/frontend/src/components/json-editor/types.ts b/frontend/src/components/json-editor/types.ts index f422585a2..4a2ff5376 100644 --- a/frontend/src/components/json-editor/types.ts +++ b/frontend/src/components/json-editor/types.ts @@ -1,5 +1,16 @@ import { type Option } from '@/components/dropdown'; +/** A json-editor editor that draws tabs, as the library builds it */ +export interface TabbedEditor { + tabs_holder?: HTMLElement; + parent?: TabbedEditor; +} + +/** The part of a json-editor instance the tab layout reads */ +export interface TabbedEditorRegistry { + editors?: Record; +} + export interface JsonEditorProps { schema: any; data: any;