Files
ComfyUI_frontend/src/platform/assets/composables/useAssetGridSelection.test.ts
CodeJuggernaut 98700cfcc7 feat: marquee select and Ctrl/Cmd+A in the Media Assets panel (#13323)
## Summary

Adds marquee (rubber-band) multi-select and Ctrl/Cmd+A select-all to the
Media Assets panel, clips the canvas drag-selection rectangle to the
canvas panel, and turns on live (real-time) node-graph rubber-band
selection by default.

## Changes

- **Marquee select** — rubber-band drag from empty grid space selects
the covered cards; hold Ctrl/Cmd to start a marquee from over a card;
Ctrl/Cmd or Shift alone makes the marquee additive to the current
selection, Ctrl/Cmd+Shift subtracts the covered cards from it
(designer-approved), and no modifier replaces it. Cards update their
selected state live during the drag.
- **Ctrl/Cmd+A** — selects all loaded assets when the pointer is over
the panel, otherwise falls through to the canvas (select all nodes). It
`stopImmediatePropagation`s so a panel select-all never also fires the
global node select-all, and it yields while an `aria-modal` dialog is
open or a text input is focused.
- **Select-all recovers after "deselect all"** — the shortcut was gated
only on `useElementHover`, which latched stale when the floating
selection bar under the cursor unmounted on deselect. It now also checks
the live pointer position against the panel rect, so a second Ctrl/Cmd+A
right after deselecting no longer falls through to the browser's native
page select-all.
- **Canvas rectangle clip** — the canvas drag-selection rectangle is
clamped to the canvas panel bounds (`SelectionRectangle.vue`,
display-only).
- **Graph live selection on by default** — flips the existing
`Comfy.Graph.LiveSelection` setting's default to on, so node-graph
rubber-band selection updates in real time during the drag (matching the
assets panel) instead of committing only on mouse-up. The behavior was
already implemented behind the setting; this changes only the default,
and users with an explicit value keep it.
- **Robustness/UX** — the pointer is captured on drag-engage rather than
on press (so a Ctrl/Cmd-click on a card isn't hijacked); no global
`document.body.userSelect` mutation (replaced by a panel-scoped
`selectstart` guard); the marquee overlay uses the semantic
`primary-background` token; post-drag click-suppression auto-resets so a
cancelled drag can't swallow a later click; `setPointerCapture` is
wrapped in try/catch; a Ctrl/Cmd-held card `dragstart` is cancelled so
no native ghost-drag image appears.
- **Breaking**: none — `useAssetSelection` is extended additively (new
`setSelectedIds` helper, nothing removed or altered) and the new
composable exposes only `{ marqueeStyle }`.
- **Dependencies**: none.

## Review Focus

- **`SelectionRectangle.vue`** is shared canvas code; the change is
display-only (clamps the rectangle to the panel; no node-selection
behavior change).
- **`coreSettings.ts`** — a one-line `Comfy.Graph.LiveSelection` default
flip is the only change that affects graph behavior; the live-select
code path itself is pre-existing.
- **`useAssetGridSelection.ts`** — listener lifecycle/teardown, the
panel-scoped `selectstart` guard, the click-suppression timer, the
capture-on-drag-engage logic, the pointer-position select-all fallback,
and the subtractive-mode snapshot at pointerdown.
- **Ctrl/Cmd+A routing** — panel hover (or a live pointer inside the
panel) gates select-all vs. the canvas, and `stopImmediatePropagation`
prevents double-handling.
- Pure geometry/selection logic is extracted into
`marqueeSelectionUtil.ts` and unit-tested in isolation (`RectEdges` is
`Pick<DOMRect, ...>`, the DOM edge subset); `MediaAssetCard.dragStart`
keeps `main`'s `display_name` payload.

Relates to Linear **FE-910**.

## Testing

- **Unit:** `useAssetGridSelection` (39 cases — marquee selection,
additive/replace, subtractive Ctrl/Cmd+Shift (incl. the macOS Cmd
variant and a shrink-restore drag), interactive-element + list-view
guards, `selectstart` scoping, click-suppression auto-reset,
pointer-capture-throw and capture-on-drag-not-press, modal-aware
Ctrl/Cmd+A, non-propagation, and the deselect-recovery pointer-in-panel
path), plus `MediaAssetCard`, `marqueeSelectionUtil` (11 cases incl.
subtractive, and a 5-case fast-check property suite pinning the
additive/subtractive set invariants), `SelectionRectangle`,
`useAssetSelection`, and `mathUtil`.
- **E2E (`assetsSidebarTab.spec.ts`):** 10 Playwright scenarios running
in CI — Ctrl/Cmd+A hover vs. canvas; a marquee from the panel header; a
modifier-held additive marquee; a Ctrl/Cmd+Shift subtractive marquee;
Ctrl/Cmd-drag from a card and within a single card; Ctrl/Cmd+A ignored
in a focused search box and under an aria-modal dialog; and a drag from
the search box not marquee-selecting. The empty-space marquee path is
covered by the panel-header scenario plus the unit suite (a dedicated
empty-space e2e could not run headless without a local backend and was
dropped as redundant).

## Future work

- **Escape key** — not handled by the marquee/select-all flow yet (the
composable handles only Ctrl/Cmd+A). Follow-up: press Escape to cancel
an in-progress marquee drag (abort the rubber-band and restore the
pre-drag selection) and to clear the current selection while the panel
has focus.
- **Ctrl+A across pagination** — select-all covers the loaded assets
only (confirmed as the intended behavior with design); a
load-all-then-select variant can follow if needed.

## Demo


https://github.com/user-attachments/assets/3841bf3c-db75-4229-a5e7-fb363b4882d6
2026-07-15 09:20:31 +00:00

801 lines
26 KiB
TypeScript

import { render, screen } from '@testing-library/vue'
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetGridSelection } from './useAssetGridSelection'
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] },
{ id: 'c', name: 'c.png', tags: [] }
]
const cardBoxes: Record<string, { left: number; right: number }> = {
a: { left: 0, right: 50 },
b: { left: 60, right: 110 },
c: { left: 120, right: 170 }
}
function pointer(type: string, init: PointerEventInit = {}) {
return new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
pointerId: 1,
isPrimary: true,
...init
})
}
function createCallbacks(overrides: Record<string, unknown> = {}) {
return {
getAssets: () => assets,
getSelectedIds: vi.fn(() => [] as string[]),
setSelectedIds: vi.fn(),
selectAll: vi.fn(),
...overrides
}
}
async function renderHarness(callbacks: ReturnType<typeof createCallbacks>) {
const Harness = defineComponent({
setup() {
const gridContainerRef = ref<HTMLElement>()
const hoverTargetRef = ref<HTMLElement>()
const { marqueeStyle } = useAssetGridSelection({
marqueeContainerRef: gridContainerRef,
hoverTargetRef,
getAssets: callbacks.getAssets,
getSelectedIds: callbacks.getSelectedIds,
setSelectedIds: callbacks.setSelectedIds,
selectAll: callbacks.selectAll
})
return { gridContainerRef, hoverTargetRef, marqueeStyle }
},
template: `
<div ref="hoverTargetRef" data-testid="panel">
<input data-testid="search" />
<textarea data-testid="textarea"></textarea>
<div contenteditable="true" data-testid="editable"></div>
<div ref="gridContainerRef" data-testid="grid">
<button data-testid="grid-button">x</button>
<div data-asset-id="a" data-testid="card-a"></div>
<div data-asset-id="b" data-testid="card-b"></div>
<div data-asset-id="c" data-testid="card-c"></div>
</div>
<div
v-if="marqueeStyle"
data-testid="marquee"
:style="marqueeStyle"
></div>
</div>
`
})
render(Harness)
await nextTick()
vi.spyOn(screen.getByTestId('grid'), 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({ left: 0, top: 0, right: 1000, bottom: 1000 })
)
for (const id of Object.keys(cardBoxes)) {
vi.spyOn(
screen.getByTestId(`card-${id}`),
'getBoundingClientRect'
).mockReturnValue(
fromPartial<DOMRect>({
left: cardBoxes[id].left,
right: cardBoxes[id].right,
top: 0,
bottom: 50
})
)
}
}
const grid = () => screen.getByTestId('grid')
const panel = () => screen.getByTestId('panel')
const card = (id: string) => screen.getByTestId(`card-${id}`)
describe('useAssetGridSelection', () => {
afterEach(() => {
vi.restoreAllMocks()
})
describe('marquee', () => {
it('selects intersecting cards when dragging from empty space', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith(
['a', 'b'],
assets
)
})
it('unions with the current selection when a modifier is held', async () => {
const callbacks = createCallbacks({ getSelectedIds: vi.fn(() => ['c']) })
await renderHarness(callbacks)
grid().dispatchEvent(
pointer('pointerdown', { clientX: 0, clientY: 0, shiftKey: true })
)
window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 }))
expect(
[...callbacks.setSelectedIds.mock.lastCall![0]].sort(
(a: string, b: string) => a.localeCompare(b)
)
).toEqual(['a', 'c'])
})
it('removes covered cards from the selection when Ctrl+Shift is held (subtractive)', async () => {
const callbacks = createCallbacks({
getSelectedIds: vi.fn(() => ['a', 'b'])
})
await renderHarness(callbacks)
grid().dispatchEvent(
pointer('pointerdown', {
clientX: 0,
clientY: 0,
ctrlKey: true,
shiftKey: true
})
)
window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 }))
expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b'])
})
it('removes covered cards when Cmd+Shift is held (macOS subtractive)', async () => {
const callbacks = createCallbacks({
getSelectedIds: vi.fn(() => ['a', 'b'])
})
await renderHarness(callbacks)
grid().dispatchEvent(
pointer('pointerdown', {
clientX: 0,
clientY: 0,
metaKey: true,
shiftKey: true
})
)
window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 }))
expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b'])
})
it('restores cards when the subtractive marquee shrinks back off them', async () => {
const callbacks = createCallbacks({
getSelectedIds: vi.fn(() => ['a', 'b'])
})
await renderHarness(callbacks)
grid().dispatchEvent(
pointer('pointerdown', {
clientX: 55,
clientY: 55,
ctrlKey: true,
shiftKey: true
})
)
window.dispatchEvent(pointer('pointermove', { clientX: 5, clientY: 45 }))
expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b'])
window.dispatchEvent(pointer('pointermove', { clientX: 54, clientY: 54 }))
expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['a', 'b'])
})
it('ignores movement below the drag threshold', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 2, clientY: 2 }))
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
})
it('does not start a marquee on a plain pointer-down on a card', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
card('a').dispatchEvent(
pointer('pointerdown', { clientX: 5, clientY: 5 })
)
window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
})
it('starts a marquee on a card when Ctrl is held and blocks native drag', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
card('a').dispatchEvent(
pointer('pointerdown', { clientX: 5, clientY: 5, ctrlKey: true })
)
const dragEvent = new DragEvent('dragstart', {
bubbles: true,
cancelable: true
})
card('a').dispatchEvent(dragEvent)
window.dispatchEvent(pointer('pointermove', { clientX: 65, clientY: 40 }))
expect(dragEvent.defaultPrevented).toBe(true)
expect(callbacks.setSelectedIds).toHaveBeenCalled()
})
it('does not block native drag when no marquee is tracking', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
const dragEvent = new DragEvent('dragstart', {
bubbles: true,
cancelable: true
})
card('a').dispatchEvent(dragEvent)
expect(dragEvent.defaultPrevented).toBe(false)
})
it('shows a marquee overlay while dragging and removes it on release', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
await nextTick()
const overlay = screen.getByTestId('marquee')
expect(overlay.style.width).toBe('30px')
expect(overlay.style.height).toBe('40px')
window.dispatchEvent(pointer('pointerup', { clientX: 30, clientY: 40 }))
await nextTick()
expect(screen.queryByTestId('marquee')).toBeNull()
})
it('clips the overlay to the grid container when dragging past its edge', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 10, clientY: 10 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 2000, clientY: 2000 })
)
await nextTick()
const overlay = screen.getByTestId('marquee')
expect(overlay.style.left).toBe('10px')
expect(overlay.style.top).toBe('10px')
expect(overlay.style.width).toBe('990px')
expect(overlay.style.height).toBe('990px')
})
it('clears the overlay on dragend when a native drag swallows pointerup', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
await nextTick()
expect(screen.getByTestId('marquee')).toBeTruthy()
window.dispatchEvent(new DragEvent('dragend', { bubbles: true }))
await nextTick()
expect(screen.queryByTestId('marquee')).toBeNull()
})
it('suppresses the click that trails a drag, but not a plain click', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
window.dispatchEvent(pointer('pointerup', { clientX: 80, clientY: 40 }))
const trailing = new MouseEvent('click', {
bubbles: true,
cancelable: true
})
window.dispatchEvent(trailing)
expect(trailing.defaultPrevented).toBe(true)
const next = new MouseEvent('click', { bubbles: true, cancelable: true })
window.dispatchEvent(next)
expect(next.defaultPrevented).toBe(false)
})
it('does not start a marquee for a non-primary mouse button', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(
pointer('pointerdown', { clientX: 0, clientY: 0, button: 2 })
)
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
})
it('does not start a marquee when the press lands on an interactive control', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
screen
.getByTestId('grid-button')
.dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
})
it('starts a marquee on a card when Cmd/Meta is held', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
card('a').dispatchEvent(
pointer('pointerdown', { clientX: 5, clientY: 5, metaKey: true })
)
window.dispatchEvent(pointer('pointermove', { clientX: 65, clientY: 40 }))
expect(callbacks.setSelectedIds).toHaveBeenCalled()
})
it('clears the selection when a no-modifier marquee covers no card', async () => {
const callbacks = createCallbacks({ getSelectedIds: vi.fn(() => ['a']) })
await renderHarness(callbacks)
grid().dispatchEvent(
pointer('pointerdown', { clientX: 300, clientY: 300 })
)
window.dispatchEvent(
pointer('pointermove', { clientX: 400, clientY: 400 })
)
expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith([], assets)
})
it('prevents text selection during a marquee and releases it on pointercancel', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
await nextTick()
expect(screen.getByTestId('marquee')).toBeTruthy()
const duringDrag = new Event('selectstart', {
bubbles: true,
cancelable: true
})
grid().dispatchEvent(duringDrag)
expect(duringDrag.defaultPrevented).toBe(true)
window.dispatchEvent(
pointer('pointercancel', { clientX: 30, clientY: 40 })
)
await nextTick()
expect(screen.queryByTestId('marquee')).toBeNull()
const afterEnd = new Event('selectstart', {
bubbles: true,
cancelable: true
})
grid().dispatchEvent(afterEnd)
expect(afterEnd.defaultPrevented).toBe(false)
})
it('auto-resets click suppression when a drag ends without a trailing click', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
window.dispatchEvent(
pointer('pointercancel', { clientX: 80, clientY: 40 })
)
await new Promise((resolve) => setTimeout(resolve))
const laterClick = new MouseEvent('click', {
bubbles: true,
cancelable: true
})
window.dispatchEvent(laterClick)
expect(laterClick.defaultPrevented).toBe(false)
})
it('only blocks text selection inside the grid container during a marquee', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
await nextTick()
const insideGrid = new Event('selectstart', {
bubbles: true,
cancelable: true
})
card('a').dispatchEvent(insideGrid)
expect(insideGrid.defaultPrevented).toBe(true)
const outsideGrid = new Event('selectstart', {
bubbles: true,
cancelable: true
})
screen.getByTestId('search').dispatchEvent(outsideGrid)
expect(outsideGrid.defaultPrevented).toBe(false)
})
it('stops blocking text selection after a normal marquee release', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
const duringDrag = new Event('selectstart', {
bubbles: true,
cancelable: true
})
card('a').dispatchEvent(duringDrag)
expect(duringDrag.defaultPrevented).toBe(true)
window.dispatchEvent(pointer('pointerup', { clientX: 30, clientY: 40 }))
const afterRelease = new Event('selectstart', {
bubbles: true,
cancelable: true
})
card('a').dispatchEvent(afterRelease)
expect(afterRelease.defaultPrevented).toBe(false)
})
it('does not suppress the click after a sub-threshold press', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(pointer('pointermove', { clientX: 2, clientY: 2 }))
window.dispatchEvent(pointer('pointerup', { clientX: 2, clientY: 2 }))
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
window.dispatchEvent(click)
expect(click.defaultPrevented).toBe(false)
})
it('does not marquee or clear selection when the container has no cards', async () => {
const setSelectedIds = vi.fn()
const Harness = defineComponent({
setup() {
const containerRef = ref<HTMLElement>()
useAssetGridSelection({
marqueeContainerRef: containerRef,
hoverTargetRef: containerRef,
getAssets: () => assets,
getSelectedIds: () => ['a'],
setSelectedIds,
selectAll: vi.fn()
})
return { containerRef }
},
template: `
<div ref="containerRef" data-testid="list">
<div data-testid="row">a.png</div>
</div>
`
})
render(Harness)
await nextTick()
screen
.getByTestId('list')
.dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(setSelectedIds).not.toHaveBeenCalled()
})
it('does not start a marquee when disabled (e.g. list view)', async () => {
const setSelectedIds = vi.fn()
const Harness = defineComponent({
setup() {
const containerRef = ref<HTMLElement>()
useAssetGridSelection({
marqueeContainerRef: containerRef,
hoverTargetRef: containerRef,
getAssets: () => assets,
getSelectedIds: () => [],
setSelectedIds,
selectAll: vi.fn(),
isEnabled: () => false
})
return { containerRef }
},
template: `
<div ref="containerRef" data-testid="disabled-grid">
<div data-asset-id="a"></div>
<div data-asset-id="b"></div>
</div>
`
})
render(Harness)
await nextTick()
screen
.getByTestId('disabled-grid')
.dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(setSelectedIds).not.toHaveBeenCalled()
})
it('ignores a reentrant pointer-down and does not leave text selection blocked', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
grid().dispatchEvent(pointer('pointerdown', { clientX: 5, clientY: 5 }))
window.dispatchEvent(pointer('pointerup', { clientX: 5, clientY: 5 }))
const selection = new Event('selectstart', {
bubbles: true,
cancelable: true
})
grid().dispatchEvent(selection)
expect(selection.defaultPrevented).toBe(false)
})
it('captures the pointer once a marquee drag starts, not on press', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
const capture = vi.spyOn(grid(), 'setPointerCapture')
card('a').dispatchEvent(
pointer('pointerdown', { clientX: 5, clientY: 5 })
)
expect(capture).not.toHaveBeenCalled()
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
expect(capture).not.toHaveBeenCalled()
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(capture).toHaveBeenCalledWith(1)
})
it('does not capture the pointer on a Ctrl/Cmd-click of a card', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
const capture = vi.spyOn(grid(), 'setPointerCapture')
card('a').dispatchEvent(
pointer('pointerdown', { clientX: 5, clientY: 5, ctrlKey: true })
)
window.dispatchEvent(pointer('pointerup', { clientX: 5, clientY: 5 }))
expect(capture).not.toHaveBeenCalled()
})
it('still tracks a marquee when setPointerCapture throws', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
vi.spyOn(grid(), 'setPointerCapture').mockImplementation(() => {
throw new Error('stale pointer id')
})
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
window.dispatchEvent(
pointer('pointermove', { clientX: 110, clientY: 50 })
)
expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith(
['a', 'b'],
assets
)
window.dispatchEvent(pointer('pointerup', { clientX: 110, clientY: 50 }))
await nextTick()
expect(screen.queryByTestId('marquee')).toBeNull()
})
})
describe('ctrl/cmd + A', () => {
function pressSelectAll(init: KeyboardEventInit = {}) {
const event = new KeyboardEvent('keydown', {
key: 'a',
ctrlKey: true,
bubbles: true,
cancelable: true,
...init
})
window.dispatchEvent(event)
return event
}
it('selects all visible assets and blocks the event when hovered', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
const event = pressSelectAll()
expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
expect(event.defaultPrevented).toBe(true)
})
it('selects all with the Cmd/Meta key while hovered', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
pressSelectAll({ ctrlKey: false, metaKey: true })
expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
})
it('does nothing when the panel is not hovered', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
})
it('still selects all when hover desyncs but the pointer stays inside the panel', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
vi.spyOn(panel(), 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({
left: 0,
top: 0,
right: 500,
bottom: 500,
width: 500,
height: 500
})
)
panel().dispatchEvent(new MouseEvent('mouseenter'))
window.dispatchEvent(
pointer('pointermove', { clientX: 100, clientY: 100 })
)
// The selection bar under the cursor unmounts on "deselect all", which
// latches useElementHover false while the pointer is still inside.
panel().dispatchEvent(new MouseEvent('mouseleave'))
const event = pressSelectAll()
expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
expect(event.defaultPrevented).toBe(true)
})
it('does not select all when the live pointer is outside the panel rect', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
vi.spyOn(panel(), 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({
left: 0,
top: 0,
right: 500,
bottom: 500,
width: 500,
height: 500
})
)
window.dispatchEvent(
pointer('pointermove', { clientX: 900, clientY: 900 })
)
panel().dispatchEvent(new MouseEvent('mouseleave'))
const event = pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
it('ignores other keys and the unmodified A while hovered', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
pressSelectAll({ ctrlKey: false })
pressSelectAll({ key: 'b' })
expect(callbacks.selectAll).not.toHaveBeenCalled()
})
it('does not hijack select-all while typing in a field', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
screen.getByTestId<HTMLInputElement>('search').focus()
pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
})
it('does not hijack select-all while focused in a textarea', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
screen.getByTestId<HTMLTextAreaElement>('textarea').focus()
pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
})
it('does not hijack select-all while focused in a contenteditable element', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
screen.getByTestId('editable').focus()
pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
})
it('does not hijack select-all while an aria-modal dialog is open', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
panel().dispatchEvent(new MouseEvent('mouseenter'))
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
document.body.appendChild(dialog)
const event = pressSelectAll()
expect(callbacks.selectAll).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
dialog.remove()
})
it('stops the select-all keystroke from reaching other handlers when hovered', async () => {
const callbacks = createCallbacks()
await renderHarness(callbacks)
const downstream = vi.fn()
window.addEventListener('keydown', downstream)
panel().dispatchEvent(new MouseEvent('mouseenter'))
pressSelectAll()
expect(callbacks.selectAll).toHaveBeenCalledTimes(1)
expect(downstream).not.toHaveBeenCalled()
panel().dispatchEvent(new MouseEvent('mouseleave'))
pressSelectAll()
expect(callbacks.selectAll).toHaveBeenCalledTimes(1)
expect(downstream).toHaveBeenCalledTimes(1)
window.removeEventListener('keydown', downstream)
})
})
})