enhancement
major
minor
major
minor
major
#29474
Neither React drag handle shields the page (or locks the cursor) while dragging
**Affects** 8.0.0-alpha7 · **Severity** real bug for the split-panel divider (confirmed reproducible against an application's own PDF viewer); minor UX polish for the table handle
Summary
Two related gaps, found together while checking a drag-handle design guide against tl-layout-react's two resizable-drag affordances:
- No pointer-events overlay during a drag. Neither .tlSplitPanel__splitter nor .tlTableView__resizeHandle's drag handler inserts a shielding layer over the rest of the page. If the pointer passes over an <iframe> mid-drag, that iframe's own document can steal mouse capture and freeze the resize. Reproduced concretely with a <pdf> element (PdfElement/`TLPdfViewer`, an actual <iframe>) sitting next to a split-panel divider.
- The table column handle does not lock the cursor to <body>. The split-panel divider does this correctly (document.body.style.cursor set on mousedown, cleared on mouseup), so its cursor never flickers when the pointer drifts off the thin divider line during a fast drag. .tlTableView__resizeHandle only relies on its own element's CSS cursor, which stops applying the instant the pointer leaves that element.
Reproduction
- (Overlay) Open a view with a split-panel divider next to an iframe (e.g. a PDF preview), drag the divider fast enough that the pointer crosses over the iframe mid-drag. The drag stops tracking as soon as the pointer enters the iframe.
- (Cursor) Open any table, start dragging a column border, then move the pointer a few pixels above or below the header row while still holding the mouse button. The cursor reverts to the default arrow even though the drag is still active.
Impact
The overlay gap is not hypothetical — it is the exact mechanism a reference design guide singles out, and any application with an iframe next to a split-panel divider triggers it. The cursor gap is pure polish, but costs nothing to fix once the overlay exists (the overlay element itself can carry the cursor style, covering both).
Suggested fix
Both drag handlers should, on mousedown, insert a position: fixed; inset: 0 layer above the rest of the page for the duration of the drag (with the appropriate col-resize/`row-resize` cursor set on it, or on <body>), removed on mouseup. The split-panel divider already proves the "lock the cursor on <body>" half of this is a three-line fix; the table handle needs the same three lines, plus the overlay element itself on both.
Workaround in the application (Consulting)
Shipped as a plain client-side module script — document-level mousedown/`mouseup` listeners that insert/remove the overlay and lock/unlock the body cursor for both handle types. No server command involved. Verified live: the overlay appears and disappears correctly for both handle types, the table handle's cursor stays col-resize for the whole drag, and neither change interferes with the framework's own resize.
Lösung
The React client (com.top_logic.layout.react) gets one shared drag-gesture helper (startPointerDrag in react-src/bridge/pointer-drag.ts, exported from tl-react-bridge) that every drag handle in the React UI uses. Starting a drag from a pointerdown event
- captures the pointer on the handle element (setPointerCapture), so the browser routes the whole gesture to the handle regardless of what is under the pointer — an <iframe> (PDF viewer, embedded HTML) never receives the events and cannot freeze the drag; mouse, touch and pen work alike,
- once the pointer has moved 3 px from the press, inserts a full-viewport shield element (.tlDragShield, position: fixed; inset: 0, z-index above every dialog, menu and snackbar layer) that carries the gesture's cursor (col-resize, row-resize, grabbing, the directional resize cursors, …) and suppresses text selection, so the cursor never flickers when the pointer leaves the thin handle. A press that never moves raises no shield and reports nothing: a click or double click on a handle still reaches the handle (double-click fit-to-content on a column border, double-click maximize on a window title), and a click on a divider no longer sends the pane sizes to the server,
- removes shield and listeners on pointerup and tells the handle whether the gesture was a drag at all; on pointercancel / lost capture (e.g. a native gesture taking over, the handle leaving the document) the handle is told to restore its pre-drag state instead, so no drag survives without a pointer to end it.
All press-drag-release gestures are converted to the helper: the split-panel divider, the table column resize handle, the table frozen-column splitter, the window title bar (move) and window edge/corner resize handles, the calendar event move / resize / create drags (captured to the time grid, since an event dragged into another day is re-rendered in that day's column), and the colour mixer's saturation field and hue slider. The per-control document.body.style.cursor hack and the ad-hoc document/`window`-level move/up listeners are gone; the application-side workaround script is no longer needed.
Popups close on pointerdown. The handles prevent the default of their pointerdown, so the browser dispatches no compatibility mousedown for it, and a popup that closed on a document mousedown would stay open when a drag on a splitter or a window title starts. Every popup of the React UI (TLMenu, the toolbar dropdown group, the sidebar group flyout, TLDropdownSelect, the icon and colour pickers, the table column context menu) therefore closes through one shared bridge hook (useCloseOnOutsidePress in react-src/bridge/outside-press.ts, exported from tl-react-bridge) that listens for the document's pointerdown in the capture phase: a press reaches the popup whatever the element under the pointer makes of it, neither preventDefault nor stopPropagation at a handle can keep a popup open. A popup names the elements a press may land in without closing it (its own element, and its trigger where that trigger toggles it), so a click on a toggling trigger still closes instead of closing on the press and reopening on the click. The seven private mousedown listeners are gone.
A click on the trigger of an open popup closes it. A trigger that opens its popup on click without knowing whether the popup is open — the click-triggered menu region (TLMenuRegion, whose menu the server places by coordinates), the icon swatch (TLIconSelect) and the colour swatch (TLColorInput) — closed the popup on the press and reopened it on the click of the same gesture, so the popup never visibly closed. The same module tracks the gesture under way: a document pointerdown listener installed at bridge load (initOutsidePress, ahead of every popup's own listener) resets a flag that a close through the hook sets, and pressClosedSurface() answers the following click. The three triggers skip opening when the press of their click has already closed a popup. A click that no press carries (keyboard activation of the swatch button, detail === 0) clears the flag, so Enter or Space on a focused swatch always opens.
The last frozen column is resizable by its border. The frozen-column grip (.tlTableView__frozenSplitter, 7 px) sits to the right of the frozen boundary instead of straddling it, and the resize handle of the last frozen heading (.tlTableView__resizeHandle, 5 px) ends at the boundary inside its cell, the same way the heading in front of the first pinned-end column keeps its handle inside. The two handles no longer overlap: the pixels left of the border resize the column, the pixels on and right of it move the frozen boundary. The visible 1 px line of the grip stays on the border.
Verified with Playwright in tl-demo-react: the PDF demo divider dragged 440 px with the pointer deep inside the PDF <iframe> (pane follows for the full distance, shield present with col-resize, gone after release, size persisted), a column border dragged with the pointer 60 px below the header row (width follows, shield present, gone after release), a press without movement (no shield), double-click fit on a column border, the frozen-column splitter, the calendar's create-by-drag gesture; the column context menu and an open dropdown select close on a press on the split-panel divider (shield present during that drag), a context menu item still executes on click, a click outside still closes; the user menu region, the icon swatch and the colour swatch open on the first click, close on the second and stay closed, open again on the third, and the icon swatch opens on Enter; the last frozen column ("Name" in the Attributes table) resized by its border, the frozen grip still dragged to the next column border and back, and it starts at the table's left edge when nothing is frozen; no console errors.