From 13d10896ccdf9aeee4354dfbd408083f01c0ebd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9?= Date: Tue, 8 Sep 2026 18:41:45 +0300 Subject: [PATCH 1/4] fix: make json-editor tab list overflow visible INT-1311 --- .../extensions/wb-bootstrap3-theme.js | 13 ++ .../components/json-editor/json-editor.tsx | 121 ++++++++++++++---- .../src/components/json-editor/styles.css | 38 +++++- frontend/src/components/json-editor/types.ts | 8 ++ 4 files changed, 156 insertions(+), 24 deletions(-) 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..48be38ac3 100644 --- a/frontend/src/components/json-editor/extensions/wb-bootstrap3-theme.js +++ b/frontend/src/components/json-editor/extensions/wb-bootstrap3-theme.js @@ -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'); diff --git a/frontend/src/components/json-editor/json-editor.tsx b/frontend/src/components/json-editor/json-editor.tsx index 51584c84d..55442d5a3 100644 --- a/frontend/src/components/json-editor/json-editor.tsx +++ b/frontend/src/components/json-editor/json-editor.tsx @@ -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(`: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(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(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(TAB_LIST_SELECTOR).forEach((list) => syncTabList(list, isDesktop)); }; export const JsonEditor = observer((props: JsonEditorProps) => { @@ -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(THUMB_SELECTOR); + const scrollbar = thumb?.parentElement; + const list = scrollbar?.parentElement?.querySelector(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); }; }, []); diff --git a/frontend/src/components/json-editor/styles.css b/frontend/src/components/json-editor/styles.css index 418722fe1..5a85e76f2 100644 --- a/frontend/src/components/json-editor/styles.css +++ b/frontend/src/components/json-editor/styles.css @@ -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; @@ -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; diff --git a/frontend/src/components/json-editor/types.ts b/frontend/src/components/json-editor/types.ts index f422585a2..625db896a 100644 --- a/frontend/src/components/json-editor/types.ts +++ b/frontend/src/components/json-editor/types.ts @@ -8,3 +8,11 @@ export interface JsonEditorProps { cells?: Option[]; onChange: (_val: any, _errors: any[]) => void; } + +export interface TabListThumbDrag { + list: HTMLElement; + scrollbar: HTMLElement; + thumb: HTMLElement; + startY: number; + startScrollTop: number; +} From cd7c366b158f3000a97651c7e52c4cdbce414d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9?= Date: Tue, 8 Sep 2026 19:01:45 +0300 Subject: [PATCH 2/4] bump changelog --- debian/changelog | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/debian/changelog b/debian/changelog index 7d0f4e338..978bfa158 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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 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 From 615d8444610118189c17d707716f0b5c2c26f43f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9?= Date: Thu, 10 Sep 2026 15:26:12 +0300 Subject: [PATCH 3/4] update after review --- debian/changelog | 3 +- .../components/json-editor/json-editor.tsx | 64 ++++++++++++------- .../src/components/json-editor/styles.css | 43 +++++++------ 3 files changed, 67 insertions(+), 43 deletions(-) diff --git a/debian/changelog b/debian/changelog index 2bd312c2b..6006f2c36 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,8 +1,9 @@ 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 Wed, 09 Sep 2026 16:59:02 +0300 + -- Valerii Trofimov Thu, 10 Sep 2026 15:25:02 +0300 wb-mqtt-homeui (2.253.1) stable; urgency=medium diff --git a/frontend/src/components/json-editor/json-editor.tsx b/frontend/src/components/json-editor/json-editor.tsx index 55442d5a3..bc3c1e146 100644 --- a/frontend/src/components/json-editor/json-editor.tsx +++ b/frontend/src/components/json-editor/json-editor.tsx @@ -7,42 +7,60 @@ import { createJSONEditor } from './extensions/wb-json-editor'; import { type JsonEditorProps, type TabListThumbDrag } from './types'; import './styles.css'; -// 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. +// Top-level tabs are capped at the window bottom, so the list and the pane scroll instead of the page. +// Tabs starting too low (a long form above) get a sticky list capped by the window instead. +// The list scrollbar is drawn here, the native one is hidden in styles.css. const TAB_LIST_SELECTOR = 'ul.nav-stacked'; +const TAB_PANE_SELECTOR = '.tab-content'; const SCROLLBAR_SELECTOR = '.je-tablist-scrollbar'; const THUMB_SELECTOR = '.je-tablist-thumb'; const THUMB_ACTIVE_CLASS = 'je-tablist-thumb--active'; +const FIT_CLASS = 'je-tabs-fit'; +const STICKY_CLASS = 'je-tabs-sticky'; const DESKTOP_QUERY = '(min-width: 992px)'; 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; +// below this the list sticks to the scrolling page instead +const MIN_FIT_HEIGHT = 240; const MIN_THUMB_HEIGHT = 24; -const syncTabList = (list: HTMLElement, isDesktop: boolean) => { +// top as if nothing were scrolled, so the cap does not depend on the scroll position +const unscrolledTop = (el: HTMLElement) => { + let top = el.getBoundingClientRect().top; + for (let node = el.parentElement; node; node = node.parentElement) { + top += node.scrollTop; + } + return top; +}; + +const syncTabList = (root: HTMLElement, list: HTMLElement, isDesktop: boolean) => { const holder = list.parentElement; + const pane = holder?.querySelector(`:scope > ${TAB_PANE_SELECTOR}`); const scrollbar = holder?.querySelector(`:scope > ${SCROLLBAR_SELECTOR}`); - if (!isDesktop) { + if (!holder || !pane || !scrollbar) { + return; + } + // nested holders flow inside the top-level pane, an empty list is not rendered + const isTopLevel = isDesktop && !root.contains(holder.closest(TAB_PANE_SELECTOR)) && list.getClientRects().length > 0; + if (!isTopLevel) { + holder.classList.remove(FIT_CLASS, STICKY_CLASS); list.style.maxHeight = ''; - if (scrollbar) { - scrollbar.hidden = true; - } + pane.style.maxHeight = ''; + 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` : ''; + // what follows the holder (margins, trailing fields) has to fit under it + const trailing = root.getBoundingClientRect().bottom - holder.getBoundingClientRect().bottom; + const available = window.innerHeight - unscrolledTop(holder) - VIEWPORT_GAP - trailing; + const isFit = available >= MIN_FIT_HEIGHT; + holder.classList.toggle(FIT_CLASS, isFit); + holder.classList.toggle(STICKY_CLASS, !isFit); + // a sticky list is as tall as the window allows once pinned + const listCap = isFit ? available : window.innerHeight - 2 * VIEWPORT_GAP - trailing; + list.style.maxHeight = listCap > 0 ? `${listCap}px` : ''; + pane.style.maxHeight = isFit ? `${available}px` : ''; const { clientHeight, scrollHeight, scrollTop } = list; const canScroll = scrollHeight > clientHeight; - if (!scrollbar) { - return; - } scrollbar.hidden = !canScroll; if (!canScroll) { return; @@ -67,7 +85,7 @@ const syncTabLists = (root: HTMLElement | null) => { return; } const isDesktop = window.matchMedia(DESKTOP_QUERY).matches; - root.querySelectorAll(TAB_LIST_SELECTOR).forEach((list) => syncTabList(list, isDesktop)); + root.querySelectorAll(TAB_LIST_SELECTOR).forEach((list) => syncTabList(root, list, isDesktop)); }; export const JsonEditor = observer((props: JsonEditorProps) => { @@ -131,13 +149,13 @@ export const JsonEditor = observer((props: JsonEditorProps) => { frame = requestAnimationFrame(() => syncTabLists(root)); }; schedule(); - // capture phase to catch the inner page container scroll and the tab list scroll, not just window + // capture phase, the page container and the tab list scroll, not the window window.addEventListener('scroll', schedule, true); window.addEventListener('resize', schedule); // 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 + // a capped list does not grow with new tabs, so the root does not resize either const mutationObserver = new MutationObserver(schedule); mutationObserver.observe(root, { childList: true, subtree: true }); diff --git a/frontend/src/components/json-editor/styles.css b/frontend/src/components/json-editor/styles.css index 5a85e76f2..87f1fcba0 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; /* anchors the tab list scrollbar placed by json-editor.tsx */ position: relative; @@ -197,27 +197,32 @@ 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; - /* 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); - } +/* top-level tabs capped at the window bottom by json-editor.tsx, the list and the pane scroll inside */ +.json-editor .je-tabs-fit > .tab-content { + overflow-y: auto; + /* the cap reaches the window bottom, the 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 .je-tabs-sticky > ul.nav-stacked { + position: sticky; + top: 0; +} + +.json-editor .je-tabs-fit > ul.nav-stacked, +.json-editor .je-tabs-sticky > ul.nav-stacked { + overflow-y: auto; + /* json-editor.tsx draws the scrollbar, the browser one is overlay on Firefox for Windows 11, macOS and 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-tabs-fit > ul.nav-stacked::-webkit-scrollbar, +.json-editor .je-tabs-sticky > ul.nav-stacked::-webkit-scrollbar { + display: none; } .json-editor .je-tablist-scrollbar { From 5df2b14026153136aa0e78ae60f56a62f712d01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9?= Date: Thu, 10 Sep 2026 22:05:10 +0300 Subject: [PATCH 4/4] update after claude review --- .../extensions/wb-bootstrap3-theme.js | 7 +- .../components/json-editor/json-editor.tsx | 146 +------------- .../src/components/json-editor/styles.css | 45 +++-- .../json-editor/tab-list-layout.test.ts | 186 ++++++++++++++++++ .../components/json-editor/tab-list-layout.ts | 171 ++++++++++++++++ frontend/src/components/json-editor/types.ts | 15 +- 6 files changed, 398 insertions(+), 172 deletions(-) create mode 100644 frontend/src/components/json-editor/tab-list-layout.test.ts create mode 100644 frontend/src/components/json-editor/tab-list-layout.ts 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 48be38ac3..a0716d955 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_LIST_SCROLLBAR_CLASS, TAB_LIST_THUMB_CLASS } from '../tab-list-layout'; import '@/components/textarea/styles.css'; export function makeWbBootstrap3Theme() { @@ -13,12 +14,12 @@ export function makeWbBootstrap3Theme() { getTabHolder(propertyName) { const el = super.getTabHolder(propertyName); - // json-editor.tsx positions and shows it, the library indexes only children[0] and [1] + // tab-list-layout places and shows it, the library indexes only children[0] and [1] const scrollbar = document.createElement('div'); - scrollbar.className = 'je-tablist-scrollbar'; + scrollbar.className = TAB_LIST_SCROLLBAR_CLASS; scrollbar.hidden = true; const thumb = document.createElement('div'); - thumb.className = 'je-tablist-thumb'; + thumb.className = TAB_LIST_THUMB_CLASS; scrollbar.appendChild(thumb); el.appendChild(scrollbar); return el; diff --git a/frontend/src/components/json-editor/json-editor.tsx b/frontend/src/components/json-editor/json-editor.tsx index bc3c1e146..49bd8fe26 100644 --- a/frontend/src/components/json-editor/json-editor.tsx +++ b/frontend/src/components/json-editor/json-editor.tsx @@ -4,90 +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 { type JsonEditorProps, type TabListThumbDrag } from './types'; +import { attachTabListLayout } from './tab-list-layout'; +import { type JsonEditorProps } from './types'; import './styles.css'; -// Top-level tabs are capped at the window bottom, so the list and the pane scroll instead of the page. -// Tabs starting too low (a long form above) get a sticky list capped by the window instead. -// The list scrollbar is drawn here, the native one is hidden in styles.css. -const TAB_LIST_SELECTOR = 'ul.nav-stacked'; -const TAB_PANE_SELECTOR = '.tab-content'; -const SCROLLBAR_SELECTOR = '.je-tablist-scrollbar'; -const THUMB_SELECTOR = '.je-tablist-thumb'; -const THUMB_ACTIVE_CLASS = 'je-tablist-thumb--active'; -const FIT_CLASS = 'je-tabs-fit'; -const STICKY_CLASS = 'je-tabs-sticky'; -const DESKTOP_QUERY = '(min-width: 992px)'; -const VIEWPORT_GAP = 12; -// below this the list sticks to the scrolling page instead -const MIN_FIT_HEIGHT = 240; -const MIN_THUMB_HEIGHT = 24; - -// top as if nothing were scrolled, so the cap does not depend on the scroll position -const unscrolledTop = (el: HTMLElement) => { - let top = el.getBoundingClientRect().top; - for (let node = el.parentElement; node; node = node.parentElement) { - top += node.scrollTop; - } - return top; -}; - -const syncTabList = (root: HTMLElement, list: HTMLElement, isDesktop: boolean) => { - const holder = list.parentElement; - const pane = holder?.querySelector(`:scope > ${TAB_PANE_SELECTOR}`); - const scrollbar = holder?.querySelector(`:scope > ${SCROLLBAR_SELECTOR}`); - if (!holder || !pane || !scrollbar) { - return; - } - // nested holders flow inside the top-level pane, an empty list is not rendered - const isTopLevel = isDesktop && !root.contains(holder.closest(TAB_PANE_SELECTOR)) && list.getClientRects().length > 0; - if (!isTopLevel) { - holder.classList.remove(FIT_CLASS, STICKY_CLASS); - list.style.maxHeight = ''; - pane.style.maxHeight = ''; - scrollbar.hidden = true; - return; - } - // what follows the holder (margins, trailing fields) has to fit under it - const trailing = root.getBoundingClientRect().bottom - holder.getBoundingClientRect().bottom; - const available = window.innerHeight - unscrolledTop(holder) - VIEWPORT_GAP - trailing; - const isFit = available >= MIN_FIT_HEIGHT; - holder.classList.toggle(FIT_CLASS, isFit); - holder.classList.toggle(STICKY_CLASS, !isFit); - // a sticky list is as tall as the window allows once pinned - const listCap = isFit ? available : window.innerHeight - 2 * VIEWPORT_GAP - trailing; - list.style.maxHeight = listCap > 0 ? `${listCap}px` : ''; - pane.style.maxHeight = isFit ? `${available}px` : ''; - - const { clientHeight, scrollHeight, scrollTop } = list; - const canScroll = scrollHeight > clientHeight; - 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(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 syncTabLists = (root: HTMLElement | null) => { - if (!root) { - return; - } - const isDesktop = window.matchMedia(DESKTOP_QUERY).matches; - root.querySelectorAll(TAB_LIST_SELECTOR).forEach((list) => syncTabList(root, list, isDesktop)); -}; - export const JsonEditor = observer((props: JsonEditorProps) => { const container = useRef(null); let jse = useRef(null); @@ -140,67 +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(() => syncTabLists(root)); - }; - schedule(); - // capture phase, the page container and the tab list scroll, not the window - window.addEventListener('scroll', schedule, true); - window.addEventListener('resize', schedule); - // recompute when content resizes the layout - const resizeObserver = new ResizeObserver(schedule); - resizeObserver.observe(root); - // a capped list does not grow with new tabs, so the root does not resize either - 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(THUMB_SELECTOR); - const scrollbar = thumb?.parentElement; - const list = scrollbar?.parentElement?.querySelector(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); - }; + return root ? attachTabListLayout(root) : undefined; }, []); return
; diff --git a/frontend/src/components/json-editor/styles.css b/frontend/src/components/json-editor/styles.css index 87f1fcba0..b328cbadd 100644 --- a/frontend/src/components/json-editor/styles.css +++ b/frontend/src/components/json-editor/styles.css @@ -166,9 +166,8 @@ gap: 12px; /* don't stretch the content pane to a taller tab list */ align-items: flex-start; - /* anchors the tab list scrollbar placed by json-editor.tsx */ + /* anchors the tab list scrollbar placed by tab-list-layout */ position: relative; - --tablist-scrollbar-width: 10px; @media (max-width: 991px) { flex-direction: column; @@ -197,42 +196,45 @@ border: 1px solid var(--border-color) !important; } -/* top-level tabs capped at the window bottom by json-editor.tsx, the list and the pane scroll inside */ -.json-editor .je-tabs-fit > .tab-content { +/* 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 window bottom, the margin would push the page past it */ + /* 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 .je-tabs-sticky > ul.nav-stacked { +.json-editor .wb-jsonEditor-tabsSticky > ul.nav-stacked { position: sticky; top: 0; } -.json-editor .je-tabs-fit > ul.nav-stacked, -.json-editor .je-tabs-sticky > ul.nav-stacked { +.json-editor .wb-jsonEditor-tabsFit > ul.nav-stacked, +.json-editor .wb-jsonEditor-tabsSticky > ul.nav-stacked { overflow-y: auto; - /* json-editor.tsx draws the scrollbar, the browser one is overlay on Firefox for Windows 11, macOS and Edge */ + /* tab-list-layout draws it, the browser one is overlay on Firefox for Windows 11, macOS and 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 .je-tabs-fit > ul.nav-stacked::-webkit-scrollbar, -.json-editor .je-tabs-sticky > ul.nav-stacked::-webkit-scrollbar { +.json-editor .wb-jsonEditor-tabsFit > ul.nav-stacked::-webkit-scrollbar, +.json-editor .wb-jsonEditor-tabsSticky > ul.nav-stacked::-webkit-scrollbar { display: none; } -.json-editor .je-tablist-scrollbar { +/* 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: var(--tablist-scrollbar-width); + width: 10px; 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 { +.json-editor .wb-jsonEditor-tabListScrollbar .wb-jsonEditor-tabListThumb { display: block; position: absolute; left: 2px; @@ -242,11 +244,18 @@ touch-action: none; } -.json-editor .je-tablist-scrollbar .je-tablist-thumb:hover, -.json-editor .je-tablist-scrollbar .je-tablist-thumb--active { +.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; + } +} + .json-editor .nav li { list-style: none; padding: 12px !important; 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..836ad198d --- /dev/null +++ b/frontend/src/components/json-editor/tab-list-layout.test.ts @@ -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(`.${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(''); + }); +}); 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..999065185 --- /dev/null +++ b/frontend/src/components/json-editor/tab-list-layout.ts @@ -0,0 +1,171 @@ +import { type TabListThumbDrag } from './types'; + +// The top-level tab list and its pane are capped at the bottom of the scrolling area, so they +// scroll instead of the page. Tabs starting too low for that get a sticky list capped by the area. +// The list scrollbar is drawn here, the native one is hidden in styles.css. +const TAB_LIST_SELECTOR = 'ul.nav-stacked'; +const TAB_PANE_SELECTOR = '.tab-content'; +const FIT_CLASS = 'wb-jsonEditor-tabsFit'; +const STICKY_CLASS = 'wb-jsonEditor-tabsSticky'; +const GUTTER_CLASS = 'wb-jsonEditor-tabListGutter'; +const THUMB_ACTIVE_CLASS = 'wb-jsonEditor-tabListThumbActive'; +const DESKTOP_QUERY = '(min-width: 992px)'; +const VIEWPORT_GAP = 12; +// below this the list sticks to the scrolling page instead +const MIN_FIT_HEIGHT = 240; +const MIN_THUMB_HEIGHT = 24; + +// the theme builds these, this module places and shows them +export const TAB_LIST_SCROLLBAR_CLASS = 'wb-jsonEditor-tabListScrollbar'; +export const TAB_LIST_THUMB_CLASS = 'wb-jsonEditor-tabListThumb'; + +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; +}; + +// the visible band of the closest scrolling ancestor, which the console panel can cut short +const scrollingBand = (el: HTMLElement) => { + const band = { top: VIEWPORT_GAP, bottom: window.innerHeight - VIEWPORT_GAP }; + const scroller = closestScroller(el); + if (!scroller) { + return band; + } + const rect = scroller.getBoundingClientRect(); + return { top: Math.max(band.top, rect.top), bottom: Math.min(band.bottom, rect.bottom) }; +}; + +// top as if nothing were scrolled, so the cap does not depend on the scroll position +const unscrolledTop = (el: HTMLElement) => { + let top = el.getBoundingClientRect().top; + for (let node = el.parentElement; node; node = node.parentElement) { + top += node.scrollTop; + } + return top; +}; + +const syncTabList = (root: HTMLElement, list: HTMLElement, isDesktop: boolean) => { + const holder = list.parentElement; + const pane = holder?.querySelector(`:scope > ${TAB_PANE_SELECTOR}`); + const scrollbar = holder?.querySelector(`:scope > .${TAB_LIST_SCROLLBAR_CLASS}`); + if (!holder || !pane || !scrollbar) { + return; + } + // nested holders flow inside the top-level pane, an empty list is not rendered + const isTopLevel = isDesktop && !root.contains(holder.closest(TAB_PANE_SELECTOR)) && list.getClientRects().length > 0; + if (!isTopLevel) { + holder.classList.remove(FIT_CLASS, STICKY_CLASS); + list.classList.remove(GUTTER_CLASS); + list.style.maxHeight = ''; + pane.style.maxHeight = ''; + scrollbar.hidden = true; + return; + } + const band = scrollingBand(holder); + // what follows the holder (margins, trailing fields) has to fit under it + const trailing = root.getBoundingClientRect().bottom - holder.getBoundingClientRect().bottom; + const available = band.bottom - unscrolledTop(holder) - trailing; + const isFit = available >= MIN_FIT_HEIGHT; + holder.classList.toggle(FIT_CLASS, isFit); + holder.classList.toggle(STICKY_CLASS, !isFit); + // a sticky list is as tall as the band allows once pinned + const listCap = isFit ? available : band.bottom - band.top - trailing; + list.style.maxHeight = listCap > 0 ? `${listCap}px` : ''; + pane.style.maxHeight = isFit ? `${available}px` : ''; + + // measured without the gutter, so a row wrapping because of it cannot flip the decision + list.classList.remove(GUTTER_CLASS); + const { clientHeight, scrollHeight, scrollTop } = list; + const canScroll = scrollHeight > clientHeight; + list.classList.toggle(GUTTER_CLASS, canScroll); + 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(`.${TAB_LIST_THUMB_CLASS}`); + 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`; + } +}; + +export const attachTabListLayout = (root: HTMLElement) => { + let frame = 0; + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const isDesktop = window.matchMedia(DESKTOP_QUERY).matches; + root.querySelectorAll(TAB_LIST_SELECTOR).forEach((list) => syncTabList(root, list, isDesktop)); + }); + }; + schedule(); + // capture phase, the page container and the tab list scroll, not the window + window.addEventListener('scroll', schedule, true); + window.addEventListener('resize', schedule); + const resizeObserver = new ResizeObserver(schedule); + resizeObserver.observe(root); + // the console panel resizes the scrolling area, touching neither the window nor the editor + const scroller = closestScroller(root); + if (scroller) { + resizeObserver.observe(scroller); + } + // a capped list does not grow with new tabs, so the root does not resize either + 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(`.${TAB_LIST_THUMB_CLASS}`); + const list = thumb?.parentElement?.parentElement?.querySelector(TAB_LIST_SELECTOR); + if (!thumb || !list) { + return; + } + e.preventDefault(); + drag = { list, 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, thumb, startY, startScrollTop } = drag; + const travel = list.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); + }; +}; diff --git a/frontend/src/components/json-editor/types.ts b/frontend/src/components/json-editor/types.ts index 625db896a..84100c0c2 100644 --- a/frontend/src/components/json-editor/types.ts +++ b/frontend/src/components/json-editor/types.ts @@ -1,5 +1,12 @@ import { type Option } from '@/components/dropdown'; +export interface TabListThumbDrag { + list: HTMLElement; + thumb: HTMLElement; + startY: number; + startScrollTop: number; +} + export interface JsonEditorProps { schema: any; data: any; @@ -8,11 +15,3 @@ export interface JsonEditorProps { cells?: Option[]; onChange: (_val: any, _errors: any[]) => void; } - -export interface TabListThumbDrag { - list: HTMLElement; - scrollbar: HTMLElement; - thumb: HTMLElement; - startY: number; - startScrollTop: number; -}