Skip to content

Commit 968eb9c

Browse files
committed
fix(tabs): restore the resource header's spacing and clear the scroll fades
Porting the tabs onto the shared strip had taken the header from 43px to 34px, which moved the overlaid collapse toggle from 6.5px below the panel's top edge to 2px while its right inset stayed at 16px — the corner read lopsided. The header goes to 40px: still shorter than it was, with the toggle back to 5px. The toggle also gets its 8px radius back, dropped in that port for no reason anyone asked for. Selecting a partly-hidden tab scrolled it flush against the container edge, which is exactly where the fade gradient sits, so it arrived half-faded and still looked cut off. Reveal now insets by the fade width and clamps at the scroll extremes, where no gradient is drawn. Floating tabs cap at 160px so no single tab dominates the row.
1 parent 818f714 commit 968eb9c

4 files changed

Lines changed: 58 additions & 11 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,21 @@ export const RESOURCE_TAB_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]'
1010
/** Shared geometry for the resource header and controls positioned over it. */
1111
export const RESOURCE_HEADER_CLASSES = {
1212
layout:
13-
'[--resource-header-controls-height:34px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:54px] [--resource-header-toggle-size:30px]',
13+
'[--resource-header-controls-height:40px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:54px] [--resource-header-toggle-size:30px]',
1414
/**
1515
* Drives the tab strip from this header's own tokens rather than restating the
1616
* strip's defaults, so the height the overlaid controls below are positioned
1717
* against and the height the strip renders at cannot drift apart. Set on the
1818
* strip itself, not an ancestor — the browser and terminal strips nested in
1919
* this panel keep their own geometry.
20+
*
21+
* The `+ 1px` is the strip's own bottom border. The controls height is the
22+
* CONTENT box both clusters centre in, so the strip's box has to be a pixel
23+
* taller than it or the tabs would centre in 43px while the overlaid toggle
24+
* centres in 44px, and the two rows would sit half a pixel apart.
2025
*/
2126
stripGeometry:
22-
'[--tab-strip-height:var(--resource-header-controls-height)] [--tab-strip-inline-start:var(--resource-header-end-inset)] [--tab-strip-inline-end:var(--resource-header-fixed-reserve)]',
27+
'[--tab-strip-height:calc(var(--resource-header-controls-height)_+_1px)] [--tab-strip-max-tab-width:160px] [--tab-strip-inline-start:var(--resource-header-end-inset)] [--tab-strip-inline-end:var(--resource-header-fixed-reserve)]',
2328
/**
2429
* Centred, matching the `floating` strip: its tabs and controls sit centred in
2530
* the header band rather than hanging from the top, so an overlaid control has

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
725725
size={null}
726726
type='button'
727727
onClick={isResourceCollapsed ? expandResource : collapseResource}
728-
className='size-[var(--resource-header-toggle-size)] hover-hover:bg-[var(--surface-active)]'
728+
className='size-[var(--resource-header-toggle-size)] rounded-[8px] hover-hover:bg-[var(--surface-active)]'
729729
aria-label={isResourceCollapsed ? 'Expand resource view' : 'Collapse resource view'}
730730
>
731731
<span className='relative'>

packages/emcn/src/components/tab-strip/tab-strip.dom.test.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,29 @@ describe('TabStrip interactions', () => {
351351

352352
act(() => root?.render(renderStrip(tabs.map((tab) => ({ ...tab, active: tab.id === 'two' })))))
353353

354-
expect(row.scrollTo).toHaveBeenCalledWith({ left: 180, behavior: 'smooth' })
354+
// 280 - 100 would park the tab's right edge flush with the container's,
355+
// which is exactly where the fade gradient sits — the tab would arrive
356+
// half-faded. The extra 16px carries it clear of the gradient.
357+
expect(row.scrollTo).toHaveBeenCalledWith({ left: 196, behavior: 'smooth' })
358+
})
359+
360+
it('lets the last tab rest flush, since no gradient is drawn at a scroll extreme', () => {
361+
mount(renderStrip(tabs))
362+
const row = scrollRow()
363+
Object.defineProperties(row, {
364+
clientWidth: { configurable: true, value: 100 },
365+
scrollWidth: { configurable: true, value: 300 },
366+
scrollLeft: { configurable: true, value: 0, writable: true },
367+
})
368+
row.getBoundingClientRect = () => ({ left: 0, right: 100, width: 100 }) as DOMRect
369+
const last = tabButton('two').parentElement as HTMLDivElement
370+
// Flush against the end of the scrollable area.
371+
last.getBoundingClientRect = () => ({ left: 200, right: 300, width: 100 }) as DOMRect
372+
row.scrollTo = vi.fn()
373+
374+
act(() => root?.render(renderStrip(tabs.map((tab) => ({ ...tab, active: tab.id === 'two' })))))
375+
376+
// Wants 216; clamped to the 200 maximum rather than over-scrolling.
377+
expect(row.scrollTo).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' })
355378
})
356379
})

packages/emcn/src/components/tab-strip/tab-strip.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ import { Tooltip } from '../tooltip/tooltip'
2222
const DRAG_EDGE_ZONE = 40
2323
const DRAG_SCROLL_SPEED = 8
2424
const TITLE_TOOLTIP_HIDDEN_PX = 8
25+
/**
26+
* Width of the scroll-edge fades, and so the margin a tab has to clear to be
27+
* genuinely visible. Keep in step with the `w-4` on the gradients below: a tab
28+
* revealed flush against the container edge lands under its gradient and reads
29+
* as half-faded, which is indistinguishable from "there is more to scroll".
30+
*/
31+
const EDGE_FADE_PX = 16
2532
const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const }
2633

2734
/**
@@ -42,7 +49,7 @@ const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const }
4249
*/
4350
const TAB_WIDTH: Record<TabStripVariant, string> = {
4451
attached: 'w-[156px] min-w-[96px] shrink',
45-
floating: 'max-w-[200px] shrink-0',
52+
floating: 'max-w-[var(--tab-strip-max-tab-width,200px)] shrink-0',
4653
}
4754

4855
/** The resting shape of a tab that is not the active one. */
@@ -223,6 +230,8 @@ interface TabStripBaseProps {
223230
* - `--tab-strip-band` (default `30px`) — the height of the tabs and the
224231
* controls beside them, which is the band an overlaid control must match.
225232
* - `--tab-strip-inline-start` / `--tab-strip-inline-end` (default `8px`).
233+
* - `--tab-strip-max-tab-width` (default `200px`) — the width a `floating`
234+
* tab's label ellipsizes at. `attached` is fixed-width and ignores it.
226235
*/
227236
className?: string
228237
}
@@ -537,13 +546,22 @@ export function TabStrip({
537546
const nodeRect = node.getBoundingClientRect()
538547
const tabLeft = tabRect.left - nodeRect.left + node.scrollLeft
539548
const tabRight = tabLeft + tabRect.width
540-
const nextLeft =
541-
tabLeft < node.scrollLeft
542-
? tabLeft
543-
: tabRight > node.scrollLeft + node.clientWidth
544-
? tabRight - node.clientWidth
549+
// Inset by the fade on both sides so the tab comes to rest clear of the
550+
// gradient rather than beneath it.
551+
const viewLeft = node.scrollLeft + EDGE_FADE_PX
552+
const viewRight = node.scrollLeft + node.clientWidth - EDGE_FADE_PX
553+
const maxScrollLeft = Math.max(0, node.scrollWidth - node.clientWidth)
554+
const target =
555+
tabLeft < viewLeft
556+
? tabLeft - EDGE_FADE_PX
557+
: tabRight > viewRight
558+
? tabRight - node.clientWidth + EDGE_FADE_PX
545559
: null
546-
if (nextLeft === null) return
560+
if (target === null) return
561+
// The clamp is what lets the first and last tabs sit flush: there is no
562+
// gradient at a scroll extreme, so no margin is needed to clear one.
563+
const nextLeft = Math.max(0, Math.min(maxScrollLeft, target))
564+
if (Math.abs(nextLeft - node.scrollLeft) < 1) return
547565
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
548566
node.scrollTo({ left: nextLeft, behavior: reduceMotion ? 'auto' : 'smooth' })
549567
}, [activeRegularId, regularTabOrder])
@@ -846,6 +864,7 @@ export function TabStrip({
846864
</AnimatePresence>
847865
</div>
848866
{canScrollLeft && (
867+
/* w-4 — see EDGE_FADE_PX */
849868
<div className='pointer-events-none absolute inset-y-0 left-0 z-20 w-4 bg-gradient-to-r from-[var(--bg)] to-transparent' />
850869
)}
851870
{canScrollRight && (

0 commit comments

Comments
 (0)