diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx new file mode 100644 index 00000000000..a315e751edd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx @@ -0,0 +1,237 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockNavigateToSettings } = vi.hoisted(() => ({ mockNavigateToSettings: vi.fn() })) + +const onWorkspaceSwitch = vi.fn() + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }), +})) +vi.mock('@/lib/auth/auth-client', () => ({ useActiveOrganization: () => ({ data: null }) })) +vi.mock('@/hooks/use-settings-navigation', () => ({ + useSettingsNavigation: () => ({ navigateToSettings: mockNavigateToSettings }), +})) +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ isInvitationsDisabled: false }), +})) +vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ + useWorkspacePermissionsContext: () => ({ + userPermissions: { canAdmin: true, canEdit: true, canRead: true }, + }), +})) +vi.mock('@/hooks/queries/invitations', () => ({ invitationKeys: { all: ['invitations'] } })) +vi.mock('@/hooks/queries/workspace', () => ({ workspaceKeys: { all: ['workspaces'] } })) + +/** Modal/menu siblings are irrelevant to the highlight and drag in heavy trees. */ +vi.mock('@/app/workspace/[workspaceId]/components/invite-modal', () => ({ + InviteModal: () => null, +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu', + () => ({ ContextMenu: () => null }) +) +vi.mock( + '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal', + () => ({ DeleteModal: () => null }) +) +vi.mock( + '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal', + () => ({ CreateWorkspaceModal: () => null }) +) +vi.mock( + '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item', + () => ({ ViewInvitationsMenuItem: () => null }) +) +vi.mock( + '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal', + () => ({ ViewInvitationsModal: () => null }) +) + +import { WorkspaceHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header' + +/** + * `@sim/emcn` is deliberately NOT mocked: the assertion is about the background class + * `chipVariants` produces, so a stubbed chip would only assert the stub. + */ +const ACTIVE_BG = 'bg-[var(--surface-active)]' + +/** + * Above `WORKSPACE_SEARCH_THRESHOLD` (3), so the searchable/keyboard list renders. + * The current workspace is deliberately NOT first: the highlight is seeded to row 0 on + * open, so a current workspace sitting at row 0 would mask the double-mark this guards. + */ +const WORKSPACES = [ + { id: 'ws-rvt', name: 'RVT' }, + { id: 'ws-emir', name: "Emir's Workspace" }, + { id: 'ws-acme', name: 'Acme' }, + { id: 'ws-globex', name: 'Globex' }, +] as unknown as Parameters[0]['workspaces'] + +let container: HTMLDivElement +let root: Root + +function render() { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root.render( + {}} + onWorkspaceSwitch={onWorkspaceSwitch} + onCreateWorkspace={async () => {}} + onRenameWorkspace={async () => {}} + onDeleteWorkspace={async () => {}} + isDeletingWorkspace={false} + onUploadLogo={() => {}} + onLeaveWorkspace={async () => {}} + isLeavingWorkspace={false} + /> + ) + }) +} + +function row(name: string): HTMLElement { + const found = [...document.querySelectorAll('[data-workspace-row-idx]')].find((el) => + el.textContent?.includes(name) + ) + if (!found) throw new Error(`No workspace row rendered for "${name}"`) + return found as HTMLElement +} + +/** + * Whether a row is painted with the persistent active fill. + * + * Matches an exact class token, never a substring: the inactive chip carries + * `hover-hover:bg-[var(--surface-active)]`, which *contains* the active class, so a + * substring check reports every row as marked. + */ +function isMarked(name: string): boolean { + return [...row(name).querySelectorAll('*')].some((el) => + el.classList.contains(ACTIVE_BG) + ) +} + +/** + * Types into a React-controlled input. Assigning `.value` directly is ignored: React + * tracks the previous value on the node, so the change must go through the native + * setter for its synthetic `onChange` to fire. + */ +function typeInto(input: HTMLInputElement, value: string) { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setValue?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +beforeEach(() => { + vi.clearAllMocks() + // jsdom implements neither; the component scrolls the active row into view. + Element.prototype.scrollIntoView = vi.fn() +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('WorkspaceHeader workspace switcher highlight', () => { + it('leaves Enter unarmed until a cursor is on screen', () => { + render() + + const search = document.querySelector('input[placeholder="Search workspaces..."]') + act(() => { + search?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + + // The search field is focused on open, so acting on the seeded row here would + // switch workspace with nothing marked. + expect(onWorkspaceSwitch).not.toHaveBeenCalled() + }) + + it('arms Enter on the top result once the user types', () => { + render() + + const search = document.querySelector( + 'input[placeholder="Search workspaces..."]' + ) as HTMLInputElement | null + act(() => { + if (search) typeInto(search, 'Acme') + }) + // Typing counts as keyboard intent, so the target is visible before Enter fires. + expect(isMarked('Acme')).toBe(true) + + act(() => { + search?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + expect(onWorkspaceSwitch).toHaveBeenCalledWith(expect.objectContaining({ id: 'ws-acme' })) + }) + + it('marks only the current workspace when the menu opens', () => { + render() + + expect(isMarked("Emir's Workspace")).toBe(true) + // Regression: the keyboard cursor used to be seeded to row 0 on open, so a second + // row was marked in the same colour as hover before any interaction. + expect(isMarked('RVT')).toBe(false) + }) + + it('does not leave a highlight behind when the pointer moves across a row', () => { + render() + + act(() => { + row('RVT').dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) + }) + + // The pointer has moved on; nothing should be painted as if still hovered. + // Real CSS :hover handles the row actually under the cursor and leaves with it. + expect(isMarked('RVT')).toBe(false) + expect(isMarked("Emir's Workspace")).toBe(true) + }) + + it('shows the cursor once the user navigates by keyboard', () => { + render() + + const search = document.querySelector('input[placeholder="Search workspaces..."]') + expect(search).not.toBeNull() + act(() => { + search?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })) + }) + + // ArrowUp from the seeded first row wraps to the last. Asserting on Globex, not on + // the current workspace: the current one carries its own `isActive` fill, so it + // stays marked either way and would prove nothing about the cursor being painted. + expect(isMarked('Globex')).toBe(true) + }) + + it('drops the keyboard cursor again as soon as the pointer moves', () => { + render() + + const search = document.querySelector('input[placeholder="Search workspaces..."]') + act(() => { + search?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })) + }) + expect(isMarked('Globex')).toBe(true) + + act(() => { + row('Acme').dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) + }) + + // Back in pointer mode: the cursor is gone and the row just crossed is unmarked, + // leaving only the current workspace's own fill. + expect(isMarked('Globex')).toBe(false) + expect(isMarked('Acme')).toBe(false) + expect(isMarked("Emir's Workspace")).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 8a14799949d..2357a7a4e42 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -169,6 +169,19 @@ function WorkspaceHeaderImpl({ const [workspaceSearch, setWorkspaceSearch] = useState('') const [highlightedId, setHighlightedId] = useState(null) + /** + * Which input the user is currently driving the list with. The highlight is only + * painted in keyboard mode, because it renders in `--surface-active` — the same + * token hover uses — so a highlight left behind by the pointer is indistinguishable + * from a stuck hover, and sits alongside the equally-`--surface-active` current + * workspace as a second phantom-hovered row. + * + * `highlightedId` itself still tracks the pointer, so Enter always targets the row + * the user last touched; only whether it is *drawn* depends on the mode. Mirrors + * `isKeyboardNav` in emcn's popover ("prevent dual highlights") and the single + * modality-driven focus marker Headless UI's Combobox exposes. + */ + const [isKeyboardNav, setIsKeyboardNav] = useState(false) const showSearch = workspaces.length > WORKSPACE_SEARCH_THRESHOLD const searchQuery = workspaceSearch.trim().toLowerCase() @@ -228,6 +241,7 @@ function WorkspaceHeaderImpl({ if (isWorkspaceMenuOpen) return setWorkspaceSearch('') setHighlightedId(null) + setIsKeyboardNav(false) }, [isWorkspaceMenuOpen]) const [isMounted, setIsMounted] = useState(false) @@ -526,21 +540,33 @@ function WorkspaceHeaderImpl({ icon={Search} placeholder='Search workspaces...' value={workspaceSearch} - onChange={(e) => setWorkspaceSearch(e.target.value)} + onChange={(e) => { + // Typing is keyboard intent, so the cursor appears on the top + // result and Enter has a visible target. + setIsKeyboardNav(true) + setWorkspaceSearch(e.target.value) + }} onKeyDown={(e) => { e.stopPropagation() if (e.nativeEvent.isComposing) return if (filteredWorkspaces.length === 0) return if (e.key === 'ArrowDown') { e.preventDefault() + setIsKeyboardNav(true) const next = (activeIndex + 1) % filteredWorkspaces.length setHighlightedId(filteredWorkspaces[next].id) } else if (e.key === 'ArrowUp') { e.preventDefault() + setIsKeyboardNav(true) const next = (activeIndex - 1 + filteredWorkspaces.length) % filteredWorkspaces.length setHighlightedId(filteredWorkspaces[next].id) } else if (e.key === 'Enter') { + // Only armed once a cursor is actually on screen. The search + // field is focused on open, so acting on the seeded row here + // would switch workspace with nothing marked — emcn's popover + // likewise holds its selection at -1 until keyboard nav starts. + if (!isKeyboardNav) return e.preventDefault() const target = filteredWorkspaces[activeIndex] if (target) onWorkspaceSwitch(target) @@ -562,7 +588,7 @@ function WorkspaceHeaderImpl({ const initial = getWorkspaceInitial(workspace.name) const isActive = workspace.id === workspaceId const isMenuOpen = menuOpenWorkspaceId === workspace.id - const isKeyboardHighlighted = showSearch && idx === activeIndex + const isKeyboardHighlighted = showSearch && isKeyboardNav && idx === activeIndex /** * Hover-highlight is wired to `onMouseMove`, not `onMouseEnter`: a @@ -575,7 +601,14 @@ function WorkspaceHeaderImpl({
setHighlightedId(workspace.id) : undefined} + onMouseMove={ + showSearch + ? () => { + setIsKeyboardNav(false) + setHighlightedId(workspace.id) + } + : undefined + } > {editingWorkspaceId === workspace.id ? (