mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-31 01:11:53 +08:00
improvement(tabs): give the resource tabs a quieter floating look (#6960)
* improvement(tabs): give the resource tabs a quieter floating look Adds a `floating` variant to the shared TabStrip and uses it for the mothership resource tabs. Only the active tab carries a shape; the rest are bare labels divided by a hairline, sized to their content up to a cap. The browser and terminal strips keep the attached look, which stays the default. * improvement(tabs): align the floating tab ramp and icon size with the platform Each surface token now does the job it is named for: a bare tab hovers to --surface-hover instead of the Button variant's --surface-4, and a selected tab takes the rung between hover and --surface-active. Tab icons match the action icons beside them at 16px.
This commit is contained in:
+4
-4
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -187,6 +187,7 @@ export {
|
||||
type TabStripItem,
|
||||
type TabStripProps,
|
||||
type TabStripSelectionSource,
|
||||
type TabStripVariant,
|
||||
tabDropIndex,
|
||||
tabStripItemSelector,
|
||||
tabStripWheelPosition,
|
||||
|
||||
@@ -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(<TabStrip tabs={items} onSelect={vi.fn()} onClose={vi.fn()} variant='floating' />)
|
||||
}
|
||||
|
||||
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<HTMLElement>('[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<HTMLElement>('[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(<TabStrip tabs={plain} onSelect={vi.fn()} onClose={vi.fn()} />)
|
||||
|
||||
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))
|
||||
|
||||
@@ -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<TabStripVariant, string> = {
|
||||
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<TabStripVariant, string> = {
|
||||
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<TabStripVariant, string> = {
|
||||
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<TabStripVariant, string> = {
|
||||
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<HTMLDivElement, TabProps>(function Tab(
|
||||
{
|
||||
tab,
|
||||
variant,
|
||||
showDivider,
|
||||
onSelect,
|
||||
onClose,
|
||||
onContextMenu,
|
||||
@@ -274,14 +359,9 @@ const Tab = forwardRef<HTMLDivElement, TabProps>(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<HTMLDivElement, TabProps>(function Tab(
|
||||
onClose?.(tab.id)
|
||||
}}
|
||||
>
|
||||
{showDivider && (
|
||||
<div className='-translate-y-1/2 -left-1 pointer-events-none absolute top-1/2 h-4 w-px bg-[var(--border)]' />
|
||||
)}
|
||||
{showDropBefore && (
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-0 z-30 h-[16px] w-[2px] rounded-full bg-[var(--text-subtle)]' />
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-0 z-30 h-4 w-[2px] rounded-full bg-[var(--text-subtle)]' />
|
||||
)}
|
||||
{showDropAfter && (
|
||||
<div className='-translate-y-1/2 pointer-events-none absolute top-1/2 right-0 z-30 h-[16px] w-[2px] translate-x-1/2 rounded-full bg-[var(--text-subtle)]' />
|
||||
<div className='-translate-y-1/2 pointer-events-none absolute top-1/2 right-0 z-30 h-4 w-[2px] translate-x-1/2 rounded-full bg-[var(--text-subtle)]' />
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -313,12 +396,13 @@ const Tab = forwardRef<HTMLDivElement, TabProps>(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 (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
variant={variant}
|
||||
showDivider={variant === 'floating' && isBareTab(tab) && isBareTab(previous)}
|
||||
draggable={reorderable || Boolean(onTabDragStart)}
|
||||
dragging={draggedId === tab.id}
|
||||
focusable={tab.active || (activeIndex < 0 && index === 0)}
|
||||
@@ -682,7 +778,10 @@ export function TabStrip({
|
||||
// `var()` calls, so a caller resizes the strip by setting a property
|
||||
// rather than by passing a utility class that has to out-merge this one.
|
||||
className={cn(
|
||||
'flex h-[var(--tab-strip-height,34px)] shrink-0 select-none items-end gap-1 border-[var(--border)] border-b bg-transparent pt-1 pr-[var(--tab-strip-inline-end,8px)] pl-[var(--tab-strip-inline-start,8px)]',
|
||||
'flex h-[var(--tab-strip-height,34px)] shrink-0 select-none gap-1 border-[var(--border)] border-b bg-transparent pr-[var(--tab-strip-inline-end,8px)] pl-[var(--tab-strip-inline-start,8px)]',
|
||||
// Attached tabs hang from the top so the active one can reach the strip's
|
||||
// bottom border and cover it; floating tabs are centred in the bar.
|
||||
variant === 'attached' ? 'items-end pt-1' : 'items-center',
|
||||
className
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -717,10 +816,18 @@ export function TabStrip({
|
||||
<div
|
||||
role='tablist'
|
||||
aria-label='Tabs'
|
||||
className='-mb-px flex min-w-0 shrink items-end gap-0.5'
|
||||
className={cn(
|
||||
'flex min-w-0 shrink gap-0.5',
|
||||
variant === 'attached' ? '-mb-px items-end' : 'items-center gap-2'
|
||||
)}
|
||||
>
|
||||
{pinnedTabs.length > 0 && (
|
||||
<div className='flex shrink-0 items-end gap-0.5'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 gap-0.5',
|
||||
variant === 'attached' ? 'items-end' : 'items-center gap-2'
|
||||
)}
|
||||
>
|
||||
<AnimatePresence initial={false} mode='popLayout'>
|
||||
{pinnedTabs.map(renderTab)}
|
||||
</AnimatePresence>
|
||||
@@ -729,7 +836,10 @@ export function TabStrip({
|
||||
<div className='relative flex min-w-0 shrink'>
|
||||
<div
|
||||
ref={scrollNodeRef}
|
||||
className='flex min-w-0 shrink select-none items-end gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
className={cn(
|
||||
'flex min-w-0 shrink select-none gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
variant === 'attached' ? 'items-end' : 'items-center gap-2'
|
||||
)}
|
||||
>
|
||||
<AnimatePresence initial={false} mode='popLayout'>
|
||||
{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 ? (
|
||||
<div className='mb-px flex h-[var(--tab-strip-band,30px)] shrink-0 items-center'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[var(--tab-strip-band,30px)] shrink-0 items-center',
|
||||
variant === 'attached' && 'mb-px'
|
||||
)}
|
||||
>
|
||||
{newTabControl}
|
||||
</div>
|
||||
) : 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({
|
||||
</Tooltip.Root>
|
||||
) : null}
|
||||
{endActions && (
|
||||
<div className='mb-px ml-auto flex h-[var(--tab-strip-band,30px)] shrink-0 items-center gap-1'>
|
||||
<div
|
||||
className={cn(
|
||||
'ml-auto flex h-[var(--tab-strip-band,30px)] shrink-0 items-center gap-1',
|
||||
variant === 'attached' && 'mb-px'
|
||||
)}
|
||||
>
|
||||
{endActions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user