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 && ( + - {(tab.tooltip || tab.pinned || titleTruncated) && ( - {tab.tooltip || tab.title} - )} - - {closeable && ( - + )} )