diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx new file mode 100644 index 00000000000..e1f3a50f777 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment happy-dom + +import React, { act, useRef, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import ProjectCombobox from './ProjectCombobox' + +vi.mock('./use-recent-project-ids', () => ({ useRecentProjectIds: () => [] })) + +function Composer({ populated }: { populated: boolean }): React.JSX.Element { + const [adding, setAdding] = useState(false) + const nameRef = useRef(null) + return ( + + + New workspace + setAdding(true)} + /> + + + { + event.preventDefault() + nameRef.current?.focus() + }} + > + Add a project + + + + + + + ) +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + // Radix Presence retains closing content when the production exit animation runs. + const getStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation((element, ...args) => { + const style = getStyle(element, ...args) + if (element.getAttribute('data-slot') === 'popover-content') { + return new Proxy(style, { + get: (target, property) => + property === 'animationName' + ? element.getAttribute('data-state') === 'closed' + ? 'exit' + : 'enter' + : Reflect.get(target, property) + }) + } + return style + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.restoreAllMocks() +}) + +function projectField(): HTMLInputElement { + return document.querySelector('[role="combobox"][aria-label="Project"]')! +} + +function addOption(): HTMLElement { + return Array.from(document.querySelectorAll('[role="option"]')).find((option) => + option.textContent?.includes('Add a new project') + )! +} + +describe.each([false, true])( + 'project selector to nested dialog handoff (populated: %s)', + (populated) => { + it.each(['mouse', 'keyboard'] as const)( + 'removes the selector before the creation dialog is active via %s', + async (input) => { + await act(async () => root.render()) + await act(async () => projectField().click()) + expect(document.querySelector('[role="listbox"]')).not.toBeNull() + + await act(async () => { + if (input === 'mouse') { + addOption().dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + ) + addOption().click() + } else if (populated) { + projectField().dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }) + ) + } + }) + if (input === 'keyboard') { + await act(async () => { + projectField().dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ) + }) + } + + expect(document.querySelector('[role="listbox"]')).toBeNull() + expect(document.querySelector('[data-slot="popover-content"]')).toBeNull() + expect(projectField().getAttribute('aria-expanded')).toBe('false') + expect(projectField().hasAttribute('aria-activedescendant')).toBe(false) + const path = document.querySelector('[aria-label="Project path"]')! + expect(document.activeElement).toBe(path) + expect(path.closest('[aria-hidden="true"]')).toBeNull() + expect(projectField().closest('[aria-hidden="true"]')).not.toBeNull() + + await act(async () => { + Array.from(document.querySelectorAll('button')) + .find((b) => b.textContent === 'Cancel')! + .click() + }) + await vi.waitFor(() => + expect(document.activeElement).toBe( + document.querySelector('[aria-label="Workspace name"]') + ) + ) + await act(async () => projectField().click()) + expect(projectField().getAttribute('aria-expanded')).toBe('true') + expect(document.querySelector('[role="listbox"]')).not.toBeNull() + } + ) + } +) diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx index 3b7931238b3..aa6b8df7b2d 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx @@ -72,7 +72,7 @@ function field(): HTMLInputElement { function openList(): void { act(() => { - field().dispatchEvent(new FocusEvent('focus', { bubbles: true })) + field().focus() }) } diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx index c305964ca0e..8561465d57e 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx @@ -229,129 +229,132 @@ export default function ProjectCombobox({ - event.preventDefault()} - onCloseAutoFocus={(event) => event.preventDefault()} - // Why: the field lives in the anchor, not inside the content, so Radix - // sees a focus/pointer event "outside" the layer and dismisses it the - // instant you tab in. Keep the layer open whenever the interaction is - // within this control; genuine outside events still close it. - onFocusOutside={(event) => { - if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { - event.preventDefault() - } - }} - onInteractOutside={(event) => { - if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { - event.preventDefault() - } - }} - > - {/* The listbox wraps a scrolling pane plus the pinned Add row, so both - stay `option` children of one listbox. */} -
event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + // Why: the field lives in the anchor, not inside the content, so Radix + // sees a focus/pointer event "outside" the layer and dismisses it the + // instant you tab in. Keep the layer open whenever the interaction is + // within this control; genuine outside events still close it. + onFocusOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } + }} + onInteractOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } + }} > - {/* Why: `presentation` — this element exists to scroll, and an + {/* The listbox wraps a scrolling pane plus the pinned Add row, so both + stay `option` children of one listbox. */} +
+ {/* Why: `presentation` — this element exists to scroll, and an unroled div between a listbox and its options breaks the ownership relationship assistive tech relies on. */} -
- {matches.length === 0 ? ( - // Why: row-height rather than a tall centred block — a 60px panel - // next to 32px rows reads as a different kind of surface and - // makes an empty result feel like an error. -

- {options.length === 0 - ? translate( - 'auto.components.new.workspace.ProjectCombobox.noProjects', - 'No projects yet.' - ) - : translate( - 'auto.components.new.workspace.ProjectCombobox.empty', - 'No projects match your search.' - )} -

- ) : null} - {sections.map((section) => ( - // Why: `role="group"` — a bare div between a listbox and its - // options breaks the ownership relationship for screen readers, - // which is the only thing that makes the headings announceable. -
- {section.heading ? ( - - ) : null} - {section.items.map((scored) => ( - arm(scored.option.id)} - onCommit={() => commit(scored.option.id)} - /> - ))} -
- ))} -
- {onAddProject ? (
event.preventDefault()} - onMouseMove={() => arm(ADD_PROJECT_KEY)} - onClick={() => commit(ADD_PROJECT_KEY)} - className={cn( - 'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm', - armedKey === ADD_PROJECT_KEY && 'bg-accent text-accent-foreground' - )} + ref={setListNode} + role="presentation" + className="max-h-72 min-h-0 flex-1 overflow-y-auto p-1 scrollbar-sleek" > - - - {translate( - 'auto.components.new.workspace.ProjectCombobox.addProject', - 'Add a new project' - )} - + {matches.length === 0 ? ( + // Why: row-height rather than a tall centred block — a 60px panel + // next to 32px rows reads as a different kind of surface and + // makes an empty result feel like an error. +

+ {options.length === 0 + ? translate( + 'auto.components.new.workspace.ProjectCombobox.noProjects', + 'No projects yet.' + ) + : translate( + 'auto.components.new.workspace.ProjectCombobox.empty', + 'No projects match your search.' + )} +

+ ) : null} + {sections.map((section) => ( + // Why: `role="group"` — a bare div between a listbox and its + // options breaks the ownership relationship for screen readers, + // which is the only thing that makes the headings announceable. +
+ {section.heading ? ( + + ) : null} + {section.items.map((scored) => ( + arm(scored.option.id)} + onCommit={() => commit(scored.option.id)} + /> + ))} +
+ ))}
- ) : null} -
- + {onAddProject ? ( +
event.preventDefault()} + onMouseMove={() => arm(ADD_PROJECT_KEY)} + onClick={() => commit(ADD_PROJECT_KEY)} + className={cn( + 'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm', + armedKey === ADD_PROJECT_KEY && 'bg-accent text-accent-foreground' + )} + > + + + {translate( + 'auto.components.new.workspace.ProjectCombobox.addProject', + 'Add a new project' + )} + +
+ ) : null} +
+
+ ) : null} ) }