diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts index 9e8ab5d6ff7..a88f464d5a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts @@ -21,11 +21,11 @@ export const RESOURCE_HEADER_CLASSES = { stripGeometry: '[--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)]', /** - * Bottom-aligned rather than centred: the header's own controls sit in the tab - * strip's band, one pixel clear of its border, and an overlaid control has to - * land in that same band to read as part of the row. + * Centred, matching the `floating` strip: its tabs and controls sit centred in + * the header band rather than hanging from the top, so an overlaid control has + * to centre too or it lands a pixel below the row it belongs to. */ - overlay: 'absolute top-0 flex h-[var(--resource-header-controls-height)] items-end pb-px', + overlay: 'absolute top-0 flex h-[var(--resource-header-controls-height)] items-center', endPosition: 'right-[var(--resource-header-end-inset)]', /** * Sits a control 1px clear of the overlaid 30px collapse toggle — the same diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index de4a99ef22d..490467861e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -256,7 +256,7 @@ export function ResourceTabs({ resources.map((resource) => ({ id: resource.id, title: nameLookup.get(`${resource.type}:${resource.id}`) ?? resource.title, - icon: getResourceConfig(resource.type).renderTabIcon(resource, 'size-[14px] shrink-0'), + icon: getResourceConfig(resource.type).renderTabIcon(resource, 'size-[16px] shrink-0'), active: activeId === resource.id, selected: selectedIds.size > 1 && selectedIds.has(resource.id), attention: activityIds?.has(resource.id) ?? false, @@ -452,6 +452,7 @@ export function ResourceTabs({ onClose={handleClose} onReorder={handleReorder} onTabDragStart={handleTabDragStart} + variant='floating' className={RESOURCE_HEADER_CLASSES.stripGeometry} newTabControl={ // Offered before the chat exists too: a resource opened while composing diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index a412ecb3738..1d04e2270e1 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -187,6 +187,7 @@ export { type TabStripItem, type TabStripProps, type TabStripSelectionSource, + type TabStripVariant, tabDropIndex, tabStripItemSelector, tabStripWheelPosition, diff --git a/packages/emcn/src/components/tab-strip/tab-strip.dom.test.tsx b/packages/emcn/src/components/tab-strip/tab-strip.dom.test.tsx index 09c9ebd7085..6b7f1c04013 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.dom.test.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.dom.test.tsx @@ -178,6 +178,94 @@ describe('TabStrip interactions', () => { expect(item.className).not.toContain('opacity-30') }) + describe('floating variant', () => { + const plain = tabs.filter((tab) => !tab.pinned) + + function mountFloating(items = plain) { + mount() + } + + it('gives a shape to the active tab only', () => { + mountFloating() + + expect(tabButton('one').className).toContain('bg-[var(--surface-active)]') + // A bare tab paints no surface of its own, which is what keeps the row + // from reading as a strip of buttons. + expect(tabButton('two').className).not.toContain('bg-[var(--surface-active)]') + expect(tabButton('two').className).not.toContain('border-[var(--border)]') + }) + + it('divides two adjacent bare tabs, but not a bare tab from a shaped one', () => { + // three/four are both bare and adjacent; two sits right after active one. + mountFloating([ + { id: 'one', title: 'One', active: true }, + { id: 'two', title: 'Two' }, + { id: 'three', title: 'Three' }, + ]) + + const divider = (id: string) => stripItem(id).querySelector('.w-px') + // 'two' follows the active tab, whose pill already separates them. + expect(divider('two')).toBeNull() + expect(divider('three')).not.toBeNull() + // Nothing precedes the first tab. + expect(divider('one')).toBeNull() + }) + + it('offers a close affordance on every tab, at rest only on the active one', () => { + mountFloating() + + // `opacity-0` is not a substring of `opacity-100`, so these two assertions + // genuinely separate the states. (`toContain('pointer-events-none')` would + // not: the Button base carries `disabled:pointer-events-none`.) + const bare = container?.querySelector('[aria-label="Close Two"]') + expect(bare).not.toBeNull() + expect(bare?.className).toContain('opacity-0') + expect(bare?.className).toContain('group-hover:opacity-100') + + const active = container?.querySelector('[aria-label="Close One"]') + expect(active?.className).not.toContain('opacity-0') + }) + + it('reserves the close slot on every tab, so activating one shifts nothing', () => { + mountFloating() + + // Floating tabs are content-sized, so reserving only on the active tab + // would grow it on activation and shove the rest of the row sideways. + expect(tabButton('one').className).toContain('pr-8') + expect(tabButton('two').className).toContain('pr-8') + }) + + it('keeps a selected tab distinguishable from the active one', () => { + mountFloating([ + { id: 'one', title: 'One', active: true, selected: true }, + { id: 'two', title: 'Two', selected: true }, + ]) + + // The active tab owns `--surface-active`; a selected tab takes the step + // below it, or the two would be one undifferentiated run of pills. + expect(tabButton('one').className).toContain('bg-[var(--surface-active)]') + expect(tabButton('two').className).toContain('bg-[var(--surface-4)]') + expect(tabButton('two').className).not.toContain('bg-[var(--surface-active)]') + }) + + it('lets tabs overflow rather than compress, so the strip can scroll', () => { + mountFloating() + + // A flex child that both shrinks and has no floor collapses to fit its + // container, so scrollWidth never exceeds clientWidth and the edge fades, + // reveal-on-select and drag auto-scroll all go dead. + expect(stripItem('two').className).toContain('shrink-0') + expect(stripItem('two').className).not.toContain('min-w-0') + }) + + it('leaves the attached variant unchanged', () => { + mount() + + expect(tabButton('one').className).toContain('bg-[var(--bg)]') + expect(stripItem('two').querySelector('.w-px')).toBeNull() + }) + }) + it('closes an unpinned tab with the middle mouse button', () => { const onClose = vi.fn() mount(renderStrip(tabs, vi.fn(), onClose)) diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx index ce049b2e337..65b113f2733 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -24,6 +24,68 @@ const DRAG_SCROLL_SPEED = 8 const TITLE_TOOLTIP_HIDDEN_PX = 8 const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const } +/** + * Width, not flex-basis: `flex-1` compiles to `flex: 1 1 0%`, and Tailwind emits + * the `flex` shorthand after `flex-basis`, so pairing the two silently discarded + * the basis and left every tab sized by its own title. + * + * `attached` gives every tab the same width and a floor, so a crowded strip + * degrades evenly and then scrolls. `floating` sizes to content up to a cap, so + * short labels stay short and only a long one ellipsizes — a row of bare labels + * must not read as a grid of buttons. + * + * Both refuse to shrink below their floor, and that is what makes the strip + * scrollable at all: a flex child that is both shrinkable and `min-w-0` compresses + * to fit its container instead of overflowing it, so `scrollWidth` never exceeds + * `clientWidth`, the edge fades never appear, and every label crushes to an + * ellipsis. `floating` therefore never shrinks; `attached` shrinks only to 96px. + */ +const TAB_WIDTH: Record = { + attached: 'w-[156px] min-w-[96px] shrink', + floating: 'max-w-[200px] shrink-0', +} + +/** The resting shape of a tab that is not the active one. */ +const TAB_SHAPE: Record = { + attached: 'rounded-b-none border border-transparent border-b-0', + // No shape at all at rest: bare labels, so the row reads as one quiet line + // rather than a strip of buttons competing with the panel's own controls. A + // shape appears on hover, which is where the close affordance lives. + floating: + 'rounded-lg text-[var(--text-secondary)] hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-primary)]', +} + +/** + * The active tab. `attached` fills with the page background and keeps a border, + * having already dropped its bottom edge to merge into the surface below; + * `floating` has no surface to merge with, so it reads by fill alone. + */ +const TAB_ACTIVE: Record = { + attached: + 'hover-hover:!border-[var(--border)] hover-hover:!bg-[var(--bg)] hover-hover:!text-[var(--text-primary)] hover-hover:!brightness-100 hover-hover:!opacity-100 border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)] transition-none', + floating: + 'hover-hover:!bg-[var(--surface-active)] hover-hover:!text-[var(--text-primary)] bg-[var(--surface-active)] text-[var(--text-primary)]', +} + +/** + * A tab held in a multi-selection that is not the one on screen. `attached` can + * reuse `--surface-active` because its active tab reads by border and page + * background instead. `floating`'s ramp has four rungs and each token does the + * job it is named for: bare is transparent, hover is `--surface-hover`, active + * is `--surface-active`, and a selected tab takes the one rung left between + * them. Keep them in that order — the fills are 3 hex steps apart, so swapping + * any two makes a state read as another. + */ +const TAB_SELECTED: Record = { + attached: 'bg-[var(--surface-active)]', + floating: 'bg-[var(--surface-4)]', +} + +/** Whether a tab draws no shape of its own, and so needs dividing from its neighbour. */ +function isBareTab(tab: TabStripItem | undefined): boolean { + return Boolean(tab) && !tab?.active && !tab?.selected +} + /** One tab in a {@link TabStrip}. */ export interface TabStripItem { id: string @@ -69,6 +131,18 @@ export interface TabStripDragContext { preventReorder: () => void } +/** + * How the tabs are drawn. + * + * - `attached` — browser-style. Every tab is a shape, and the active one loses + * its bottom border to merge into the surface below. For a strip that owns + * the whole surface under it. + * - `floating` — only the active tab carries a shape; the rest are bare labels + * divided by a hairline. Quieter, and it does not claim the surface below, so + * it suits a panel header that sits above content it does not own. + */ +export type TabStripVariant = 'attached' | 'floating' + /** How a tab selection was initiated. */ export type TabStripSelectionSource = 'pointer' | 'keyboard' @@ -138,6 +212,8 @@ interface TabStripBaseProps { * what separates it from {@link TabStripBaseProps.endActions}. */ overlays?: ReactNode + /** Defaults to `attached`. See {@link TabStripVariant}. */ + variant?: TabStripVariant /** * Merged onto the strip root. Intended for the geometry custom properties * below rather than for competing utility classes, so a caller that owns the @@ -214,6 +290,13 @@ export function tabDropIndex( interface TabProps { tab: TabStripItem + variant: TabStripVariant + /** + * Draws the hairline that separates two adjacent bare tabs in the `floating` + * variant. Suppressed next to a tab that has a shape of its own, since the + * shape already does the dividing. + */ + showDivider: boolean onSelect: ( id: string, source?: TabStripSelectionSource, @@ -235,6 +318,8 @@ interface TabProps { const Tab = forwardRef(function Tab( { tab, + variant, + showDivider, onSelect, onClose, onContextMenu, @@ -274,14 +359,9 @@ const Tab = forwardRef(function Tab( transition={TAB_TRANSITION} className={cn( 'group relative select-none', - // Width, not flex-basis: `flex-1` compiles to `flex: 1 1 0%`, and - // Tailwind emits the `flex` shorthand after `flex-basis`, so pairing - // the two silently discarded the basis and left every tab sized by its - // own title. `shrink` still lets a crowded strip squeeze them to the - // floor before it starts scrolling. - tab.pinned - ? 'w-[34px] min-w-[34px] max-w-[34px] flex-none' - : 'w-[156px] min-w-[96px] shrink', + // `shrink` lets a crowded strip squeeze tabs to their floor before it + // starts scrolling. + tab.pinned ? 'w-[34px] min-w-[34px] max-w-[34px] flex-none' : TAB_WIDTH[variant], dragging && 'opacity-30' )} data-tab-strip-item={tab.id} @@ -295,11 +375,14 @@ const Tab = forwardRef(function Tab( onClose?.(tab.id) }} > + {showDivider && ( +
+ )} {showDropBefore && ( -
+
)} {showDropAfter && ( -
+
)} @@ -313,12 +396,13 @@ const Tab = forwardRef(function Tab( data-tab-strip-button={tab.id} tabIndex={focusable ? 0 : -1} className={cn( - 'h-[var(--tab-strip-band,30px)] w-full select-none rounded-b-none border border-transparent border-b-0 bg-transparent py-0 text-caption', + 'h-[var(--tab-strip-band,30px)] w-full select-none bg-transparent py-0 text-caption', tab.pinned ? 'justify-center px-0' : 'justify-start gap-1.5 px-2', - closeable && !tab.pinned && 'pr-8', - tab.selected && !tab.active && 'bg-[var(--surface-active)]', - tab.active && - 'hover-hover:!border-[var(--border)] hover-hover:!bg-[var(--bg)] hover-hover:!text-[var(--text-primary)] hover-hover:!brightness-100 hover-hover:!opacity-100 relative z-10 border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)] transition-none' + closeable && 'pr-8', + TAB_SHAPE[variant], + tab.selected && !tab.active && TAB_SELECTED[variant], + tab.active && 'relative z-10', + tab.active && TAB_ACTIVE[variant] )} onClick={(event) => onSelect(tab.id, 'pointer', event)} onKeyDown={(event) => onKeyDown(event, tab.id)} @@ -392,6 +476,7 @@ export function TabStrip({ newTabControl, endActions, overlays, + variant = 'attached', className, }: TabStripProps) { const atLimit = maxTabs !== undefined && tabs.length >= maxTabs @@ -653,12 +738,23 @@ export function TabStrip({ [focusTab, onClose, onSelect, tabs] ) - const renderTab = (tab: TabStripItem) => { + // Called as a `map` callback, so `lane` is whichever of the two rows — pinned + // or regular — is being rendered. The neighbour has to come from that lane + // rather than from `tabs`: the rows are separate containers, so a tab's + // predecessor in the combined list may not be the one beside it on screen, and + // the first tab in a lane has no on-screen predecessor at all. + const renderTab = (tab: TabStripItem, laneIndex: number, lane: TabStripItem[]) => { const index = tabs.findIndex((candidate) => candidate.id === tab.id) + // A hairline stands between two adjacent bare tabs only. A tab that carries + // a shape — the active one, or one held in a multi-selection — already + // separates itself, and doubling up reads as a seam. + const previous = lane[laneIndex - 1] return ( {pinnedTabs.length > 0 && ( -
+
{pinnedTabs.map(renderTab)} @@ -729,7 +836,10 @@ export function TabStrip({
{regularTabs.map(renderTab)} @@ -746,7 +856,12 @@ export function TabStrip({ {/* Both slots sit in the tab row's band so whatever fills them lines up with the tabs rather than with the taller strip box. */} {newTabControl ? ( -
+
{newTabControl}
) : onNew ? ( @@ -757,7 +872,10 @@ export function TabStrip({ variant='ghost-secondary' size='sm' aria-label={newTabLabel} - className='mb-px size-[var(--tab-strip-band,30px)] shrink-0 p-0' + className={cn( + 'size-[var(--tab-strip-band,30px)] shrink-0 p-0', + variant === 'attached' && 'mb-px' + )} disabled={atLimit} onClick={onNew} > @@ -770,7 +888,12 @@ export function TabStrip({ ) : null} {endActions && ( -
+
{endActions}
)}