diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx
index 51e7cf33625..6d3f32de76f 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx
@@ -1,6 +1,13 @@
'use client'
-import { chipVariants, cn, DropdownMenuItem, OverflowText } from '@sim/emcn'
+import {
+ chipVariants,
+ cn,
+ DropdownMenuItem,
+ OverflowText,
+ RowActions,
+ rowActionsGroupClass,
+} from '@sim/emcn'
import { MoreHorizontal, Pin, Task } from '@sim/emcn/icons'
import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
import { useOrganizationChatActions } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions'
@@ -8,7 +15,6 @@ import {
ChatNavigationLink,
CollapsedChatFlyoutItem,
CollapsedSidebarMenu,
- SidebarRowActions,
SidebarSection,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row'
@@ -51,12 +57,12 @@ function ChatRow({
isCurrentRoute={isCurrentRoute}
className={cn(
chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true }),
- 'group/sidebar-row'
+ rowActionsGroupClass
)}
onContextMenu={(e) => onContextMenu(e, chat.id)}
>
-
-
+
)
}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx
index d969ab325ff..f371d69d4a8 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx
@@ -7,6 +7,8 @@ import {
DropdownMenuItem,
DropdownMenuItemAction,
OverflowText,
+ RowActions,
+ rowActionsGroupClass,
toast,
} from '@sim/emcn'
import { MoreHorizontal, Pin } from '@sim/emcn/icons'
@@ -17,10 +19,7 @@ import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context-
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces'
import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row'
-import {
- SidebarRowAction,
- SidebarRowActions,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions'
+import { SidebarRowAction } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions'
import { useFlyoutInlineRename } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu'
import { useToggleWorkspacePin, useUpdateWorkspace } from '@/hooks/queries/workspace'
@@ -176,12 +175,12 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis
href={href}
className={cn(
chipVariants({ active: isActive || isMenuOpen, fullWidth: true }),
- 'group/sidebar-row'
+ rowActionsGroupClass
)}
onContextMenu={(event) => openMenu(event, workspace.id)}
>
{label}
-
-
+
)
})}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
index 583f7b5aeca..e735e674e89 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
@@ -1,4 +1,3 @@
-export { SidebarRowActions } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions'
export { ChatNavigationLink } from './chat-navigation-link/chat-navigation-link'
export {
CollapsedChatFlyoutItem,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions.tsx
index f8faef2fac9..29426771cc7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions.tsx
@@ -1,54 +1,4 @@
-import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from 'react'
-import { cn } from '@sim/emcn'
-
-interface SidebarRowActionsProps {
- children: ReactNode
- indicator?: ReactNode
- open?: boolean
- revealOnHover?: boolean
-}
-
-/** Reclaims idle action space while retaining indicators and touch/keyboard access. */
-export function SidebarRowActions({
- children,
- indicator,
- open = false,
- revealOnHover = true,
-}: SidebarRowActionsProps) {
- return (
-
- {indicator && (
-
- {indicator}
-
- )}
-
- {children}
-
-
- )
-}
+import { type ButtonHTMLAttributes, forwardRef } from 'react'
interface SidebarRowActionProps
extends Omit, 'className' | 'type'> {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
index 9a927fec370..17967c4d26c 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
@@ -7,6 +7,8 @@ import {
cn,
disclosureChevronClass,
OverflowText,
+ RowActions,
+ rowActionsGroupClass,
toast,
} from '@sim/emcn'
import { ChevronRight, Folder, FolderOpen, Lock, MoreHorizontal } from '@sim/emcn/icons'
@@ -17,10 +19,7 @@ import { useRouter } from 'next/navigation'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types'
import { generateSubfolderName } from '@/lib/workspaces/naming'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
-import {
- SidebarRowAction,
- SidebarRowActions,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions'
+import { SidebarRowAction } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import {
@@ -504,7 +503,7 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold
aria-label={`${folder.name} folder, ${isExpanded ? 'expanded' : 'collapsed'}`}
className={cn(
chipVariants({ active: isSelected || isContextMenuOpen, fullWidth: true }),
- 'group/sidebar-row',
+ rowActionsGroupClass,
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
)}
onClick={handleFolderSelect}
@@ -550,7 +549,7 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold
>
-
-
+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
index 84fcd61abfe..13f7ac2f977 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
@@ -1,15 +1,12 @@
'use client'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
-import { chipVariants, cn, OverflowText } from '@sim/emcn'
+import { chipVariants, cn, OverflowText, RowActions, rowActionsGroupClass } from '@sim/emcn'
import { Lock, MoreHorizontal } from '@sim/emcn/icons'
import Link from 'next/link'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
-import {
- SidebarRowAction,
- SidebarRowActions,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions'
+import { SidebarRowAction } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars'
@@ -414,7 +411,7 @@ export const WorkflowItem = memo(function WorkflowItem({
active: active || isContextMenuOpen || (isSelected && selectedWorkflows.size > 1),
fullWidth: true,
}),
- 'group/sidebar-row',
+ rowActionsGroupClass,
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
)}
draggable={!isEditing && !dragDisabled && !effectiveLocked}
@@ -453,7 +450,7 @@ export const WorkflowItem = memo(function WorkflowItem({
{!isEditing && (
-
-
+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
index f52386e4abb..de55e6f4552 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
@@ -15,6 +15,8 @@ import {
Library,
Loader,
OverflowText,
+ RowActions,
+ rowActionsGroupClass,
scrollFadeAttributes,
scrollFadeClass,
Tooltip,
@@ -75,7 +77,6 @@ import {
SidebarFooter,
SidebarNavChip,
type SidebarNavItemData,
- SidebarRowActions,
SidebarSection,
SidebarTooltip,
StatusNotice,
@@ -245,7 +246,7 @@ const SidebarChatItem = memo(function SidebarChatItem({
active: isCurrentRoute || isSelected || isMenuOpen,
fullWidth: true,
}),
- 'group/sidebar-row'
+ rowActionsGroupClass
)}
onClick={(e) => {
if (e.metaKey || e.ctrlKey) return
@@ -263,7 +264,7 @@ const SidebarChatItem = memo(function SidebarChatItem({
>
{chat.id !== 'new' && (
-
-
+
)}
diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx
index 6b064ae690b..2c5f32bd84f 100644
--- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx
+++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx
@@ -22,6 +22,7 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
+import { RowActions, rowActionsGroupClass } from '@sim/emcn'
import { cva, type VariantProps } from 'class-variance-authority'
import { Check, ChevronRight, Circle, Search } from '../../icons'
import { cn } from '../../lib/cn'
@@ -375,7 +376,7 @@ const DropdownMenuItem = React.forwardRef<
if (action) {
return (
{
if (
event.defaultPrevented ||
@@ -412,7 +413,9 @@ const DropdownMenuItem = React.forwardRef<
actionIndicator || actionOpen
? 'pr-[28px]'
: '[@media(hover:hover)]:group-focus-within/dropdownitem:pr-[28px] [@media(hover:hover)]:group-hover/dropdownitem:pr-[28px]',
- actionIndicator ? '[@media(hover:none)]:pr-[52px]' : '[@media(hover:none)]:pr-[28px]',
+ actionIndicator
+ ? '[@media(any-pointer:coarse)]:pr-[52px] [@media(hover:none)]:pr-[52px]'
+ : '[@media(any-pointer:coarse)]:pr-[28px] [@media(hover:none)]:pr-[28px]',
inset && 'pl-7',
className
)}
@@ -421,27 +424,14 @@ const DropdownMenuItem = React.forwardRef<
>
{content}
-
- {actionIndicator && (
-
- {actionIndicator}
-
- )}
-
- {action}
-
-
+
+ {action}
+
)
}
diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts
index 8a6c0391020..a7bbaccfe2d 100644
--- a/packages/emcn/src/components/index.ts
+++ b/packages/emcn/src/components/index.ts
@@ -213,6 +213,7 @@ export {
} from './popover/popover'
export { POPOVER_ANIMATION_CLASSES } from './popover/popover-animation'
export { ProgressItem } from './progress-item/progress-item'
+export { RowActions, type RowActionsProps, rowActionsGroupClass } from './row-actions/row-actions'
export { SecretInput } from './secret-input/secret-input'
export { SecretReveal } from './secret-reveal/secret-reveal'
export { WORDMARK_PATHS, WORDMARK_VIEW_BOX } from './sim-wordmark/paths'
@@ -229,7 +230,6 @@ export {
} from './status-page/status-page'
export { Switch } from './switch/switch'
export {
- isTabTitleTruncated,
TabStrip,
type TabStripDragContext,
type TabStripItem,
diff --git a/packages/emcn/src/components/row-actions/row-actions.tsx b/packages/emcn/src/components/row-actions/row-actions.tsx
new file mode 100644
index 00000000000..db5f8891a25
--- /dev/null
+++ b/packages/emcn/src/components/row-actions/row-actions.tsx
@@ -0,0 +1,74 @@
+import type { ReactNode, Ref } from 'react'
+import { cn } from '@sim/emcn'
+
+export const rowActionsGroupClass = 'group/row-actions'
+
+export interface RowActionsProps {
+ children: ReactNode
+ indicator?: ReactNode
+ open?: boolean
+ revealOnHover?: boolean
+ size?: 'sm' | 'md'
+ /** Layout and positioning only. */
+ className?: string
+ /** The action layer, for composite controls that move focus into it. */
+ actionRef?: Ref
+}
+
+/**
+ * Reclaims idle action space and overlays actions on an indicator when the row
+ * is hovered or focused. Touch layouts keep both visible side by side. Place
+ * {@link rowActionsGroupClass} on the interactive row that owns this slot.
+ */
+export function RowActions({
+ children,
+ indicator,
+ open = false,
+ revealOnHover = true,
+ size = 'sm',
+ className,
+ actionRef,
+}: RowActionsProps) {
+ const sizeClass = size === 'sm' ? 'size-[18px]' : 'size-[24px]'
+
+ return (
+
+ {indicator && (
+
+ {indicator}
+
+ )}
+
+ {children}
+
+
+ )
+}
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 ac2a5c785bc..b6bbec86125 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
@@ -125,6 +125,60 @@ describe('TabStrip interactions', () => {
expect(onClose).toHaveBeenCalledWith('two')
})
+ it('shares the action slot between background activity and closing', () => {
+ const onClose = vi.fn()
+ const items = tabs.map((tab) => ({ ...tab, attention: true }))
+ mount(renderStrip(items, vi.fn(), onClose))
+
+ const slot = stripItem('two').querySelector('[data-row-actions]')
+ expect(slot?.querySelector('[data-row-action-indicator]')).not.toBeNull()
+ expect(tabButton('two').getAttribute('aria-label')).toBe('Two, Background activity')
+ const close = slot?.querySelector('[aria-label="Close Two"]')
+ expect(close).not.toBeNull()
+ expect(tabButton('two').querySelector('[data-row-actions]')).toBeNull()
+ expect(tabButton('one').hasAttribute('aria-label')).toBe(false)
+ expect(tabButton('pinned').getAttribute('aria-label')).toBe('Pinned, Background activity')
+ act(() => close?.click())
+ expect(onClose).toHaveBeenCalledWith('two')
+ })
+
+ it('uses the shared overflow tooltip for clipped labels', () => {
+ mount(renderStrip(tabs))
+ const label = tabButton('two').querySelector('[data-overflow-text]')
+ if (!label) throw new Error('Missing shared overflow label')
+ Object.defineProperties(label, {
+ clientWidth: { configurable: true, value: 20 },
+ scrollWidth: { configurable: true, value: 80 },
+ })
+ act(() => {
+ window.dispatchEvent(new Event('resize'))
+ label.dispatchEvent(
+ new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
+ )
+ })
+ expect(document.querySelector('[data-native-surface-overlay]')?.textContent).toBe('Two')
+ })
+
+ it('keeps one tooltip when a tab supplies fuller detail', () => {
+ mount(renderStrip([{ id: 'detail', title: 'Shell', tooltip: 'Project directory — shell' }]))
+ const button = tabButton('detail')
+ const label = button.querySelector('[data-overflow-text]')
+ if (!label) throw new Error('Missing shared overflow label')
+ Object.defineProperties(label, {
+ clientWidth: { configurable: true, value: 20 },
+ scrollWidth: { configurable: true, value: 80 },
+ })
+ act(() => {
+ window.dispatchEvent(new Event('resize'))
+ label.dispatchEvent(
+ new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
+ )
+ })
+ const tooltips = document.querySelectorAll('[data-native-surface-overlay]')
+ expect(tooltips).toHaveLength(1)
+ expect(tooltips[0].textContent).toBe('Project directory — shell')
+ })
+
it('cycles, jumps, and closes from the keyboard', () => {
const onSelect = vi.fn()
const onClose = vi.fn()
@@ -328,16 +382,17 @@ describe('TabStrip interactions', () => {
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"]')
+ const bare = container?.querySelector(
+ '[data-tab-strip-item="two"] [data-row-action-controls]'
+ )
expect(bare).not.toBeNull()
- expect(bare?.className).toContain('opacity-0')
- expect(bare?.className).toContain('group-hover:opacity-100')
+ expect(bare?.classList.contains('opacity-0')).toBe(true)
+ expect(bare?.className).toContain('group-hover/row-actions:opacity-100')
- const active = container?.querySelector('[aria-label="Close One"]')
- expect(active?.className).not.toContain('opacity-0')
+ const active = container?.querySelector(
+ '[data-tab-strip-item="one"] [data-row-action-controls]'
+ )
+ expect(active?.classList.contains('opacity-100')).toBe(true)
})
it('reserves the close slot on every tab, so activating one shifts nothing', () => {
@@ -420,9 +475,10 @@ describe('TabStrip interactions', () => {
it('shows background activity without marking that tab selected', () => {
mount(renderStrip(tabs.map((tab) => ({ ...tab, attention: tab.id === 'two' }))))
- expect(tabButton('two').querySelector('[aria-label="Background activity"]')).not.toBeNull()
+ expect(stripItem('two').querySelector('[data-row-action-indicator]')).not.toBeNull()
+ expect(tabButton('two').getAttribute('aria-label')).toBe('Two, Background activity')
expect(tabButton('two').getAttribute('aria-selected')).toBe('false')
- expect(tabButton('one').querySelector('[aria-label="Background activity"]')).toBeNull()
+ expect(tabButton('one').hasAttribute('aria-label')).toBe(false)
})
it('does not reserve phantom space after a pointer close', () => {
diff --git a/packages/emcn/src/components/tab-strip/tab-strip.test.ts b/packages/emcn/src/components/tab-strip/tab-strip.test.ts
index c501ce8cea6..d422a23a5e3 100644
--- a/packages/emcn/src/components/tab-strip/tab-strip.test.ts
+++ b/packages/emcn/src/components/tab-strip/tab-strip.test.ts
@@ -1,22 +1,6 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
-import {
- isTabTitleTruncated,
- type TabStripItem,
- tabDropIndex,
- tabStripWheelPosition,
-} from './tab-strip'
-
-describe('isTabTitleTruncated', () => {
- it('shows title help only after a meaningful amount of text is clipped', () => {
- expect(isTabTitleTruncated({ clientWidth: 100, scrollWidth: 140 })).toBe(true)
- expect(isTabTitleTruncated({ clientWidth: 100, scrollWidth: 108 })).toBe(true)
- expect(isTabTitleTruncated({ clientWidth: 160, scrollWidth: 167 })).toBe(false)
- expect(isTabTitleTruncated({ clientWidth: 160, scrollWidth: 200 })).toBe(true)
- expect(isTabTitleTruncated({ clientWidth: 100, scrollWidth: 100 })).toBe(false)
- expect(isTabTitleTruncated({ clientWidth: 120, scrollWidth: 80 })).toBe(false)
- })
-})
+import { type TabStripItem, tabDropIndex, tabStripWheelPosition } from './tab-strip'
describe('tabStripWheelPosition', () => {
it('uses native horizontal deltas and falls back to vertical wheel movement', () => {
@@ -64,41 +48,6 @@ describe('tabDropIndex', () => {
})
})
-describe('tab tooltips', () => {
- /** Mirrors the render condition: tooltip text, and whether it is shown. */
- function tooltipFor(tab: TabStripItem, titleTruncated: boolean) {
- const shown = Boolean(tab.tooltip || tab.pinned || titleTruncated)
- return shown ? (tab.tooltip ?? tab.title) : null
- }
-
- it('prefers the fuller detail a tab supplies over its label', () => {
- // A terminal labelled with a basename can say where it actually is.
- const tab: TabStripItem = { id: '1', title: 'sim', tooltip: '/Users/me/code/sim — bun test' }
-
- expect(tooltipFor(tab, false)).toBe('/Users/me/code/sim — bun test')
- })
-
- it('shows that detail even when the label fits', () => {
- // It says something the tab cannot, so there is always a reason to hover.
- const tab: TabStripItem = { id: '1', title: 'sim', tooltip: '/Users/me/code/sim' }
-
- expect(tooltipFor(tab, false)).not.toBeNull()
- })
-
- it('falls back to the title, and only when the title is clipped', () => {
- const tab: TabStripItem = { id: '1', title: 'a very long tab title' }
-
- expect(tooltipFor(tab, true)).toBe('a very long tab title')
- expect(tooltipFor(tab, false)).toBeNull()
- })
-
- it('still explains a pinned tab, which renders with no label at all', () => {
- const tab: TabStripItem = { id: '1', title: 'GitHub', pinned: true }
-
- expect(tooltipFor(tab, false)).toBe('GitHub')
- })
-})
-
describe('tab strip vertical overflow', () => {
const source = readFileSync(new URL('./tab-strip.tsx', import.meta.url), 'utf8')
/** Declarations only — the comments here discuss the very class they removed. */
diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx
index ad39ae8f21e..b052ace0627 100644
--- a/packages/emcn/src/components/tab-strip/tab-strip.tsx
+++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx
@@ -14,17 +14,16 @@ import {
useRef,
useState,
} from 'react'
+import { OverflowText, RowActions, rowActionsGroupClass } from '@sim/emcn'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { Plus, X } from '../../icons'
import { cn } from '../../lib/cn'
import { Button } from '../button/button'
-import { overflowTextClipClass, overflowTextFadeClass } from '../overflow-text/overflow-text'
import { Tooltip } from '../tooltip/tooltip'
import { TabStripAction } from './tab-strip-action'
const DRAG_EDGE_ZONE = 40
const DRAG_SCROLL_SPEED = 8
-const TITLE_TOOLTIP_HIDDEN_PX = 8
/**
* Width of the scroll-edge fades, and so the margin a tab has to clear to be
* genuinely visible. Keep in step with the `w-4` on the gradients below: a tab
@@ -262,18 +261,6 @@ interface TabStripBaseProps {
className?: string
}
-/**
- * Whether a title is clipped enough to be worth a tooltip. A couple of hidden
- * pixels is not, but a tab should not lose a meaningful part of its identity
- * before it explains itself.
- */
-export function isTabTitleTruncated(
- element: Pick
-): boolean {
- const hiddenWidth = element.scrollWidth - element.clientWidth
- return hiddenWidth >= TITLE_TOOLTIP_HIDDEN_PX
-}
-
/**
* Selector matching a tab's outer element. Part of the strip's API: a caller
* building its own drag image needs the real, laid-out nodes to snapshot, and
@@ -372,20 +359,69 @@ const Tab = forwardRef(function Tab(
},
ref
) {
- const titleRef = useRef(null)
- const [titleTruncated, setTitleTruncated] = useState(false)
const closeable = Boolean(onClose) && !tab.pinned
- useLayoutEffect(() => {
- const element = titleRef.current
- if (!element) return
- const update = () => setTitleTruncated(isTabTitleTruncated(element))
- update()
- if (typeof ResizeObserver === 'undefined') return
- const observer = new ResizeObserver(update)
- observer.observe(element)
- return () => observer.disconnect()
- }, [tab.title])
+ const tabButton = (
+
+ )
return (
(function Tab(
transition={TAB_TRANSITION}
className={cn(
'group relative select-none',
+ rowActionsGroupClass,
// `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],
@@ -421,79 +458,40 @@ const Tab = forwardRef(function Tab(
{showDropAfter && (
)}
-
-
+ {tab.tooltip || tab.pinned ? (
+
+ {tabButton}
+ {tab.tooltip || tab.title}
+
+ ) : (
+ tabButton
+ )}
+ {closeable && (
+
+ ) : undefined
+ }
+ className='-translate-y-1/2 absolute top-1/2 right-0.5 z-20'
+ >
-
- {(tab.tooltip || tab.pinned || titleTruncated) && (
- {tab.tooltip || tab.title}
- )}
-
- {closeable && (
-
+
)}
)