mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 00:01:49 +00:00
fix(frontend): draw the tab strip's scroll bar instead of the native one (#10547)
* fix(frontend): draw the tab strip's scroll bar instead of the native one The strip sizes its scroll row to the tabs, but a native horizontal scrollbar claims layout height on top of that: Firefox spends 11px on `scrollbar-width: thin` — `--wm-scrollbar-size` is WebKit-only, so the 4px it asks for is ignored there — which clipped the tabs at the top of the 32px sessions strip and left a wide gutter under them. Hide the native bar and draw a 4px thumb from `scrollLeft`/`scrollWidth` instead: it costs no layout height, is the same size in every engine, and sits on the strip's bottom edge, flush under the tabs. Tabs drop to `h-6` so they clear it, and the strip's default height matches the sessions caller's `h-8` so every strip has the same geometry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(frontend): clamp the tab strip thumb at both ends of its track WebKit's elastic overscroll drives `scrollLeft` negative, which slid the thumb out of the track's left edge and into the strip's padding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -280,8 +280,9 @@
|
||||
}
|
||||
|
||||
/* Subtle scrollbar: a thin, rounded thumb that only appears on hover, on both
|
||||
axes. Shared by ScrollableX (tab strips, code blocks) and the AI chat. Size
|
||||
via the `--wm-scrollbar-size` var (default 6px). Higher specificity than the
|
||||
axes. Shared by ScrollableX (code blocks, tab headers) and the AI chat. Size
|
||||
via the `--wm-scrollbar-size` var (default 6px) — WebKit only; Firefox sizes
|
||||
`thin` itself and spends ~11px of the box on it. Higher specificity than the
|
||||
app-wide `*::-webkit-scrollbar`, so it overrides it. */
|
||||
.scrollbar-subtle {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
// Subtle horizontal scroll: native overflow (so wheel/trackpad/drag always
|
||||
// work) with the shared `.scrollbar-subtle` thumb (thin, hover-revealed). Bar
|
||||
// thickness is tunable via the `--wm-scrollbar-size` CSS var (pass through
|
||||
// `style`), so denser callers (e.g. the tab strip) can shrink it.
|
||||
// `style`) — WebKit only, so Firefox spends its own ~11px of the box on the
|
||||
// bar whatever this says. Not for a height-constrained row: draw the thumb
|
||||
// yourself there, the way the tab strip does.
|
||||
let {
|
||||
class: c = '',
|
||||
style = '',
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
import { X } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { untrack } from 'svelte'
|
||||
import ScrollableX from '../ScrollableX.svelte'
|
||||
|
||||
interface Props {
|
||||
tabs: TabItem[]
|
||||
@@ -41,7 +40,9 @@
|
||||
* activated via Enter/Space — lets the active tab host a secondary affordance
|
||||
* (e.g. toggling the breadcrumb picker rendered in `tabAccessory`). */
|
||||
onActiveClick?: (id: string) => void
|
||||
/** Extra classes for the outer tab strip. */
|
||||
/** Extra classes for the outer tab strip. It is 32px tall unless `trailing`
|
||||
* content is taller; a fixed height here overrides that, and going taller
|
||||
* pulls the tabs away from the scroll bar, which stays on the bottom edge. */
|
||||
class?: string
|
||||
/** Render inside the scroll row, right after the last tab (e.g. a "+" new-tab
|
||||
* button) — scrolls with the tabs, unlike `trailing`. */
|
||||
@@ -88,6 +89,85 @@
|
||||
if (!isDragging) dndMiddle = next
|
||||
})
|
||||
|
||||
// Scroll bar. The native one is hidden (`no-scrollbar`) and redrawn here: its
|
||||
// height is a WebKit-only setting, so Firefox spends 11px of the strip on a
|
||||
// bar there is no room for and clips the tabs. Ours is 4px in every engine and
|
||||
// costs no layout height at all.
|
||||
const MIN_THUMB = 24
|
||||
let scrollEl = $state<HTMLElement | undefined>(undefined)
|
||||
let scrollLeft = $state(0)
|
||||
let viewport = $state(0)
|
||||
let content = $state(0)
|
||||
const scrollable = $derived(Math.max(0, content - viewport))
|
||||
const overflowing = $derived(scrollable > 1)
|
||||
// Clamped to the viewport: a pane dragged shut leaves a few pixels of strip,
|
||||
// and an unclamped minimum-width thumb would hang out of it.
|
||||
const thumbWidth = $derived(
|
||||
overflowing ? Math.min(viewport, Math.max(MIN_THUMB, (viewport / content) * viewport)) : 0
|
||||
)
|
||||
// Clamped at both ends: `scrollLeft` is fractional on HiDPI while the widths
|
||||
// are rounded, so the ratio can tip past 1, and WebKit's elastic overscroll
|
||||
// drives it negative — either way the thumb would leave the track.
|
||||
const thumbLeft = $derived(
|
||||
scrollable > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(viewport - thumbWidth, (scrollLeft / scrollable) * (viewport - thumbWidth))
|
||||
)
|
||||
: 0
|
||||
)
|
||||
|
||||
function measure() {
|
||||
const el = scrollEl
|
||||
if (!el) return
|
||||
scrollLeft = el.scrollLeft
|
||||
viewport = el.clientWidth
|
||||
content = el.scrollWidth
|
||||
}
|
||||
|
||||
// Both ends move independently: the viewport on a pane resize, the content as
|
||||
// tabs open, close and get renamed.
|
||||
$effect(() => {
|
||||
const el = scrollEl
|
||||
if (!el) return
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
||||
return () => ro.disconnect()
|
||||
})
|
||||
|
||||
// Drag the thumb: pointer capture keeps the gesture alive past the strip's
|
||||
// edges, and the ratio maps thumb travel back onto scroll travel. Recomputing
|
||||
// from the anchor each move (rather than accumulating) means clamping at
|
||||
// either end doesn't drift, and reading the travel live keeps a tab opening
|
||||
// mid-drag from scaling every later move against a stale track.
|
||||
function handleThumbPointerDown(e: PointerEvent) {
|
||||
const el = scrollEl
|
||||
// Primary button only: a right-click would open the context menu without
|
||||
// delivering the pointerup that ends the drag.
|
||||
if (!el || e.button !== 0) return
|
||||
e.preventDefault()
|
||||
const target = e.currentTarget as HTMLElement
|
||||
const startX = e.clientX
|
||||
const startScroll = el.scrollLeft
|
||||
target.setPointerCapture(e.pointerId)
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const travel = viewport - thumbWidth
|
||||
if (travel <= 0) return
|
||||
el.scrollLeft = startScroll + ((ev.clientX - startX) / travel) * scrollable
|
||||
}
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
target.releasePointerCapture(ev.pointerId)
|
||||
target.removeEventListener('pointermove', onMove)
|
||||
target.removeEventListener('pointerup', onUp)
|
||||
target.removeEventListener('pointercancel', onUp)
|
||||
}
|
||||
target.addEventListener('pointermove', onMove)
|
||||
target.addEventListener('pointerup', onUp)
|
||||
target.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
function handleConsider(e: CustomEvent<DndEvent<TabItem>>) {
|
||||
isDragging = true
|
||||
dndMiddle = e.detail.items
|
||||
@@ -100,7 +180,7 @@
|
||||
|
||||
function tabClasses(isActive: boolean) {
|
||||
return twMerge(
|
||||
'group relative inline-flex items-center gap-1.5 px-2.5 h-7 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset',
|
||||
'group relative inline-flex items-center gap-1.5 px-2.5 h-6 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset',
|
||||
isActive
|
||||
? 'bg-surface-tertiary text-emphasis'
|
||||
: 'bg-transparent text-hint hover:text-secondary'
|
||||
@@ -189,41 +269,69 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div bind:this={stripEl} class={twMerge('flex items-center bg-surface', c)}>
|
||||
<!-- 4px bar to match the strip's `pb-1` reserve. -->
|
||||
<ScrollableX class="flex-1 min-w-0 pt-1 pl-1 pb-1" style="--wm-scrollbar-size: 4px;">
|
||||
<div class="flex items-center" role="tablist">
|
||||
{#each pinnedLeft as tab (tab.id)}
|
||||
{@render tabButton(tab)}
|
||||
{/each}
|
||||
|
||||
<div
|
||||
class="flex items-center"
|
||||
use:dndzone={{
|
||||
items: dndMiddle,
|
||||
flipDurationMs: 150,
|
||||
type: dndType,
|
||||
dropTargetStyle: {}
|
||||
}}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each dndMiddle as tab (tab.id)}
|
||||
<div>
|
||||
{@render tabButton(tab)}
|
||||
</div>
|
||||
<div bind:this={stripEl} class={twMerge('flex items-center bg-surface min-h-8', c)}>
|
||||
<!-- The tabs centre in the full strip and the bar overlays the air under them,
|
||||
flush with the strip's bottom edge — it takes no height of its own, so the
|
||||
strip never resizes and the tabs sit at the same place whether or not they
|
||||
overflow. -->
|
||||
<div class="group/scroll relative flex-1 min-w-0 self-stretch">
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
onscroll={() => scrollEl && (scrollLeft = scrollEl.scrollLeft)}
|
||||
class="h-full overflow-x-auto overflow-y-hidden no-scrollbar pl-1"
|
||||
>
|
||||
<!-- `w-max`: without it the row is pinned to the viewport width and the tabs
|
||||
overflow *out* of it, so the ResizeObserver below never sees a tab open
|
||||
or a label change and the scroll bar goes stale. -->
|
||||
<div class="flex items-center h-full w-max" role="tablist">
|
||||
{#each pinnedLeft as tab (tab.id)}
|
||||
{@render tabButton(tab)}
|
||||
{/each}
|
||||
|
||||
<div
|
||||
class="flex items-center"
|
||||
use:dndzone={{
|
||||
items: dndMiddle,
|
||||
flipDurationMs: 150,
|
||||
type: dndType,
|
||||
dropTargetStyle: {}
|
||||
}}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each dndMiddle as tab (tab.id)}
|
||||
<!-- `flex`, not the default block: an inline-flex tab in a block wrapper
|
||||
sits on a text baseline and rides ~1.5px off the row's centre. -->
|
||||
<div class="flex">
|
||||
{@render tabButton(tab)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each pinnedRight as tab (tab.id)}
|
||||
{@render tabButton(tab)}
|
||||
{/each}
|
||||
|
||||
{#if afterTabs}
|
||||
{@render afterTabs()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each pinnedRight as tab (tab.id)}
|
||||
{@render tabButton(tab)}
|
||||
{/each}
|
||||
|
||||
{#if afterTabs}
|
||||
{@render afterTabs()}
|
||||
{/if}
|
||||
</div>
|
||||
</ScrollableX>
|
||||
|
||||
{#if overflowing}
|
||||
<!-- Decorative: it mirrors the scroll position and can be dragged, but the
|
||||
strip is scrollable without it (wheel, trackpad, and the arrow keys that
|
||||
scroll the focused tab into view), so it stays out of the a11y tree
|
||||
rather than posing as a control at a 4px hit target. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute bottom-0 left-0 h-1 rounded-full touch-none bg-hint/0 group-hover/scroll:bg-hint/40 hover:!bg-secondary/60 transition-colors"
|
||||
style="width: {thumbWidth}px; transform: translateX({thumbLeft}px);"
|
||||
onpointerdown={handleThumbPointerDown}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if trailing}
|
||||
<div class="ml-1 pr-1 flex items-center shrink-0">
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
let tab = $state('button')
|
||||
|
||||
// Enough tabs to overflow a narrow strip so the shared ScrollableX hover
|
||||
// scrollbar is exercised: drag to reorder, hover to reveal the 4px thumb.
|
||||
// Enough tabs to overflow a narrow strip so the strip's own hover scrollbar is
|
||||
// exercised: drag to reorder, hover to reveal the 4px thumb.
|
||||
let draggableTabs = $state<TabItem[]>(
|
||||
Array.from({ length: 14 }, (_, i) => ({ id: `t${i}`, label: `Preview tab ${i + 1}` }))
|
||||
)
|
||||
@@ -200,8 +200,8 @@ That's the full round-trip.`
|
||||
</TabContent>
|
||||
<TabContent value="scrollbar" class="p-4">
|
||||
<div class="text-xs text-tertiary mb-3">
|
||||
DraggableTabs (uses the shared <code>ScrollableX</code>, 4px bar): hover to reveal the
|
||||
thumb, drag to reorder.
|
||||
DraggableTabs (draws its own 4px bar on the strip's bottom edge): hover to reveal the thumb,
|
||||
drag to reorder.
|
||||
</div>
|
||||
<div class="border border-border-light rounded-md" style="max-width: 420px;">
|
||||
<DraggableTabs
|
||||
|
||||
Reference in New Issue
Block a user