| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | import { observeSelectionActions, resolveSelectionActionPosition } from "./selectionActions"; |
| 4 | |
| 5 | function event(type: string, values: Record<string, unknown> = {}) { |
| 6 | return Object.assign(new Event(type, { cancelable: true }), values); |
| 7 | } |
| 8 | |
| 9 | function createSelectionSurface({ interactiveActions = false } = {}) { |
| 10 | const frames = new Map<number, FrameRequestCallback>(); |
| 11 | let frameId = 0; |
| 12 | const view = Object.assign(new EventTarget(), { |
| 13 | setTimeout: (callback: () => void, delay: number) => setTimeout(callback, delay), |
| 14 | clearTimeout: (id: ReturnType<typeof setTimeout>) => clearTimeout(id), |
| 15 | requestAnimationFrame: (callback: FrameRequestCallback) => { |
| 16 | frames.set(++frameId, callback); |
| 17 | return frameId; |
| 18 | }, |
| 19 | cancelAnimationFrame: (id: number) => frames.delete(id), |
| 20 | }); |
| 21 | const document = Object.assign(new EventTarget(), { |
| 22 | defaultView: view, |
| 23 | activeElement: null as EventTarget | null, |
| 24 | }); |
| 25 | const element = Object.assign(new EventTarget(), { |
| 26 | ownerDocument: document, |
| 27 | contains: (target: unknown): boolean => target === element || target === buttonIcon, |
| 28 | closest: () => null, |
| 29 | }); |
| 30 | const button = new EventTarget(); |
| 31 | const buttonIcon = Object.assign(new EventTarget(), { |
| 32 | closest: (selector: string) => (selector === "button, [role=button]" ? button : null), |
| 33 | }); |
| 34 | const onSelection = vi.fn(); |
| 35 | const onDismiss = vi.fn(); |
| 36 | const field = new EventTarget(); |
| 37 | const actionElement = Object.assign(new EventTarget(), { |
| 38 | contains: (target: unknown) => target === actionElement || target === field, |
| 39 | }); |
| 40 | const actions = observeSelectionActions({ |
| 41 | element: element as unknown as HTMLElement, |
| 42 | ...(interactiveActions |
| 43 | ? { getActionElement: () => actionElement as unknown as HTMLElement } |
| 44 | : {}), |
| 45 | onSelection, |
| 46 | onDismiss, |
| 47 | }); |
| 48 | document.addEventListener("selectionchange", actions.selectionChanged); |
| 49 | const change = () => document.dispatchEvent(event("selectionchange")); |
| 50 | const down = ({ |
| 51 | inside = true, |
| 52 | button = 0, |
| 53 | consumed = false, |
| 54 | isPrimary = true, |
| 55 | target = inside ? element : document, |
| 56 | }: { |
| 57 | inside?: boolean; |
| 58 | button?: number; |
| 59 | consumed?: boolean; |
| 60 | isPrimary?: boolean; |
| 61 | target?: EventTarget; |
| 62 | } = {}) => { |
| 63 | const press = event("pointerdown", { button, isPrimary }); |
| 64 | Object.defineProperty(press, "target", { value: target }); |
| 65 | document.dispatchEvent(press); |
| 66 | if (element.contains(target) && !consumed) element.dispatchEvent(press); |
| 67 | }; |
| 68 | const focus = (target: EventTarget) => { |
| 69 | document.activeElement = target; |
| 70 | const focusEvent = event("focusin"); |
| 71 | Object.defineProperty(focusEvent, "target", { value: target }); |
| 72 | document.dispatchEvent(focusEvent); |
| 73 | }; |
| 74 | const key = (key: string, target: EventTarget = document) => { |
| 75 | const keyEvent = event("keydown", { key }); |
| 76 | Object.defineProperty(keyEvent, "target", { value: target }); |
| 77 | document.dispatchEvent(keyEvent); |
| 78 | }; |
| 79 | const pointerUp = (button = 0) => { |
| 80 | view.dispatchEvent(event("pointerup", { isPrimary: true, button })); |
| 81 | }; |
| 82 | const up = ({ x = 300, y = 200, detail = 1, button = 0 } = {}) => { |
| 83 | pointerUp(button); |
| 84 | view.dispatchEvent(event("mouseup", { clientX: x, clientY: y, detail, button })); |
| 85 | }; |
| 86 | const flushFrame = () => { |
| 87 | const callbacks = [...frames.values()]; |
| 88 | frames.clear(); |
| 89 | for (const callback of callbacks) callback(0); |
| 90 | }; |
| 91 | const flush = (milliseconds = 0) => { |
| 92 | vi.advanceTimersByTime(milliseconds); |
| 93 | flushFrame(); |
| 94 | }; |
| 95 | return { |
| 96 | actions, |
| 97 | view, |
| 98 | document, |
| 99 | element, |
| 100 | buttonIcon, |
| 101 | actionElement, |
| 102 | field, |
| 103 | onSelection, |
| 104 | onDismiss, |
| 105 | down, |
| 106 | focus, |
| 107 | key, |
| 108 | up, |
| 109 | pointerUp, |
| 110 | change, |
| 111 | flush, |
| 112 | flushFrame, |
| 113 | }; |
| 114 | } |
| 115 | |
| 116 | beforeEach(() => vi.useFakeTimers()); |
| 117 | afterEach(() => vi.useRealTimers()); |
| 118 | |
| 119 | describe("selection action gestures", () => { |
| 120 | it("stays hidden through selection changes while dragging and uses the release point", () => { |
| 121 | const surface = createSelectionSurface(); |
| 122 | surface.down(); |
| 123 | surface.change(); |
| 124 | surface.flush(1000); |
| 125 | surface.change(); |
| 126 | surface.flush(1000); |
| 127 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 128 | surface.up({ x: 420, y: 310 }); |
| 129 | surface.change(); |
| 130 | surface.flush(); |
| 131 | expect(surface.onSelection.mock.calls).toEqual([[{ x: 420, y: 310 }]]); |
| 132 | }); |
| 133 | |
| 134 | it("preserves the release anchor if the final selectionchange arrives after the menu opens", () => { |
| 135 | const surface = createSelectionSurface(); |
| 136 | surface.down(); |
| 137 | surface.up({ x: 180, y: 120 }); |
| 138 | surface.flush(); |
| 139 | surface.change(); |
| 140 | surface.flush(); |
| 141 | expect(surface.onSelection).toHaveBeenLastCalledWith({ x: 180, y: 120 }); |
| 142 | }); |
| 143 | |
| 144 | it("lets a third click supersede the double-click popup before it opens", () => { |
| 145 | const surface = createSelectionSurface(); |
| 146 | surface.down(); |
| 147 | surface.up({ detail: 2 }); |
| 148 | surface.change(); |
| 149 | surface.flush(400); |
| 150 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 151 | surface.down(); |
| 152 | surface.change(); |
| 153 | surface.flush(500); |
| 154 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 155 | surface.up({ detail: 3, x: 310, y: 210 }); |
| 156 | surface.change(); |
| 157 | surface.flush(499); |
| 158 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 159 | surface.flush(1); |
| 160 | expect(surface.onSelection.mock.calls).toEqual([[{ x: 310, y: 210 }]]); |
| 161 | }); |
| 162 | |
| 163 | it("accepts release outside the surface but never a gesture that started outside", () => { |
| 164 | const surface = createSelectionSurface(); |
| 165 | surface.down({ inside: false }); |
| 166 | surface.change(); |
| 167 | surface.up(); |
| 168 | surface.change(); |
| 169 | surface.flush(); |
| 170 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 171 | surface.down(); |
| 172 | surface.up({ x: 1800, y: -10 }); |
| 173 | surface.flush(); |
| 174 | expect(surface.onSelection).toHaveBeenCalledWith({ x: 1800, y: -10 }); |
| 175 | }); |
| 176 | |
| 177 | it("does not treat a consumed link or terminal mouse-reporting press as selection", () => { |
| 178 | const surface = createSelectionSurface(); |
| 179 | surface.down({ consumed: true }); |
| 180 | surface.up(); |
| 181 | surface.change(); |
| 182 | surface.flush(); |
| 183 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 184 | }); |
| 185 | |
| 186 | it.each([1, 2])( |
| 187 | "dismisses selection actions when a button is pressed after %i clicks", |
| 188 | (detail) => { |
| 189 | const surface = createSelectionSurface(); |
| 190 | surface.down(); |
| 191 | surface.up({ detail }); |
| 192 | surface.flush(); |
| 193 | surface.onSelection.mockClear(); |
| 194 | surface.onDismiss.mockClear(); |
| 195 | |
| 196 | surface.down({ target: surface.buttonIcon }); |
| 197 | surface.up(); |
| 198 | surface.change(); |
| 199 | surface.flush(1000); |
| 200 | expect(surface.onDismiss).toHaveBeenCalledWith("interaction"); |
| 201 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 202 | |
| 203 | surface.down(); |
| 204 | surface.change(); |
| 205 | surface.up(); |
| 206 | surface.flush(); |
| 207 | expect(surface.onSelection).toHaveBeenCalledOnce(); |
| 208 | }, |
| 209 | ); |
| 210 | |
| 211 | it("ignores nonprimary pointers and non-left mouseup without ending a left drag", () => { |
| 212 | const surface = createSelectionSurface(); |
| 213 | surface.down({ isPrimary: false }); |
| 214 | surface.up(); |
| 215 | surface.flush(); |
| 216 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 217 | surface.down(); |
| 218 | surface.up({ button: 1 }); |
| 219 | surface.change(); |
| 220 | surface.flush(); |
| 221 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 222 | surface.up(); |
| 223 | surface.flush(); |
| 224 | expect(surface.onSelection).toHaveBeenCalledOnce(); |
| 225 | }); |
| 226 | |
| 227 | it("uses the text fallback after keyboard selection rather than a stale pointer", () => { |
| 228 | const surface = createSelectionSurface(); |
| 229 | surface.down(); |
| 230 | surface.up(); |
| 231 | surface.flush(); |
| 232 | surface.document.dispatchEvent(event("keydown", { key: "ArrowRight", shiftKey: true })); |
| 233 | surface.change(); |
| 234 | surface.flush(); |
| 235 | expect(surface.onSelection).toHaveBeenLastCalledWith(null); |
| 236 | }); |
| 237 | |
| 238 | it("allows keyboard selection after a toolbar press suppresses compatibility mouseup", () => { |
| 239 | const surface = createSelectionSurface(); |
| 240 | surface.down(); |
| 241 | surface.up(); |
| 242 | surface.flush(); |
| 243 | surface.down({ inside: false }); |
| 244 | surface.pointerUp(); |
| 245 | surface.document.dispatchEvent(event("keydown", { key: "ArrowRight", shiftKey: true })); |
| 246 | surface.change(); |
| 247 | surface.flush(); |
| 248 | expect(surface.onSelection).toHaveBeenLastCalledWith(null); |
| 249 | }); |
| 250 | |
| 251 | it("waits for mouseup's click count even if selectionchange lands after pointerup", () => { |
| 252 | const surface = createSelectionSurface(); |
| 253 | surface.down(); |
| 254 | surface.pointerUp(); |
| 255 | surface.change(); |
| 256 | surface.flush(); |
| 257 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 258 | surface.up({ detail: 2 }); |
| 259 | surface.flush(499); |
| 260 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 261 | surface.flush(1); |
| 262 | expect(surface.onSelection).toHaveBeenCalledOnce(); |
| 263 | }); |
| 264 | |
| 265 | it.each(["Escape", "contextmenu", "pointercancel", "blur", "resize", "scroll"])( |
| 266 | "%s dismisses pending actions and ignores a late selectionchange", |
| 267 | (reason) => { |
| 268 | const surface = createSelectionSurface(); |
| 269 | surface.down(); |
| 270 | surface.up({ detail: 2 }); |
| 271 | if (reason === "Escape") surface.document.dispatchEvent(event("keydown", { key: reason })); |
| 272 | else if (reason === "contextmenu" || reason === "scroll") |
| 273 | surface.element.dispatchEvent(event(reason)); |
| 274 | else surface.view.dispatchEvent(event(reason)); |
| 275 | surface.change(); |
| 276 | surface.flush(1000); |
| 277 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 278 | surface.down(); |
| 279 | surface.up(); |
| 280 | surface.flush(); |
| 281 | expect(surface.onSelection).toHaveBeenCalledOnce(); |
| 282 | }, |
| 283 | ); |
| 284 | |
| 285 | it("keeps an autoscrolling drag eligible to open on release", () => { |
| 286 | const surface = createSelectionSurface(); |
| 287 | surface.down(); |
| 288 | surface.element.dispatchEvent(event("scroll")); |
| 289 | surface.change(); |
| 290 | surface.flush(1000); |
| 291 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 292 | surface.up(); |
| 293 | surface.flush(); |
| 294 | expect(surface.onSelection).toHaveBeenCalledOnce(); |
| 295 | }); |
| 296 | |
| 297 | it("cancels a queued animation frame when a new press supersedes the release", () => { |
| 298 | const surface = createSelectionSurface(); |
| 299 | surface.down(); |
| 300 | surface.up(); |
| 301 | vi.advanceTimersByTime(0); |
| 302 | expect(surface.actions.pending).toBe(true); |
| 303 | surface.down(); |
| 304 | surface.flushFrame(); |
| 305 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 306 | surface.up(); |
| 307 | surface.flush(); |
| 308 | expect(surface.onSelection).toHaveBeenCalledOnce(); |
| 309 | }); |
| 310 | |
| 311 | it("teardown cancels deferred work and detaches gesture listeners", () => { |
| 312 | const surface = createSelectionSurface(); |
| 313 | surface.down(); |
| 314 | surface.up(); |
| 315 | vi.advanceTimersByTime(0); |
| 316 | surface.actions.dispose(); |
| 317 | surface.down(); |
| 318 | surface.up(); |
| 319 | surface.change(); |
| 320 | surface.flush(1000); |
| 321 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 322 | }); |
| 323 | }); |
| 324 | |
| 325 | describe("interactive selection actions", () => { |
| 326 | it("keeps the captured selection while clicking, typing, and selecting in a comment field", () => { |
| 327 | const surface = createSelectionSurface({ interactiveActions: true }); |
| 328 | surface.down(); |
| 329 | surface.up(); |
| 330 | surface.flush(); |
| 331 | surface.onDismiss.mockClear(); |
| 332 | surface.down({ target: surface.field }); |
| 333 | surface.focus(surface.field); |
| 334 | surface.change(); |
| 335 | surface.up(); |
| 336 | surface.key("a", surface.field); |
| 337 | surface.change(); |
| 338 | surface.key("ArrowLeft", surface.field); |
| 339 | surface.change(); |
| 340 | surface.flush(1000); |
| 341 | expect(surface.onDismiss).not.toHaveBeenCalled(); |
| 342 | expect(surface.onSelection).toHaveBeenCalledOnce(); |
| 343 | }); |
| 344 | |
| 345 | it("cancels pending source reads when keyboard focus enters the actions", () => { |
| 346 | const surface = createSelectionSurface({ interactiveActions: true }); |
| 347 | surface.key("Tab"); |
| 348 | surface.change(); |
| 349 | vi.advanceTimersByTime(0); |
| 350 | expect(surface.actions.pending).toBe(true); |
| 351 | surface.focus(surface.field); |
| 352 | surface.change(); |
| 353 | surface.flushFrame(); |
| 354 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 355 | surface.focus(surface.actionElement); |
| 356 | expect(surface.onDismiss).not.toHaveBeenCalled(); |
| 357 | }); |
| 358 | |
| 359 | it.each(["outside press", "outside focus", "Escape", "scroll"])( |
| 360 | "%s dismisses an interactive popover without recapturing its text", |
| 361 | (reason) => { |
| 362 | const surface = createSelectionSurface({ interactiveActions: true }); |
| 363 | surface.focus(surface.field); |
| 364 | if (reason === "outside press") surface.down({ inside: false }); |
| 365 | else if (reason === "outside focus") surface.focus(surface.element); |
| 366 | else if (reason === "Escape") surface.key("Escape", surface.field); |
| 367 | else surface.element.dispatchEvent(event("scroll")); |
| 368 | surface.change(); |
| 369 | surface.flush(1000); |
| 370 | expect(surface.onDismiss).toHaveBeenCalledWith("cancel"); |
| 371 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 372 | }, |
| 373 | ); |
| 374 | |
| 375 | it("allows a new source gesture to replace a comment selection", () => { |
| 376 | const surface = createSelectionSurface({ interactiveActions: true }); |
| 377 | surface.focus(surface.field); |
| 378 | surface.down(); |
| 379 | surface.focus(surface.element); |
| 380 | surface.change(); |
| 381 | surface.flush(); |
| 382 | expect(surface.onSelection).not.toHaveBeenCalled(); |
| 383 | expect(surface.onDismiss).toHaveBeenCalledWith("interaction"); |
| 384 | surface.up({ x: 520, y: 320 }); |
| 385 | surface.flush(); |
| 386 | expect(surface.onSelection).toHaveBeenCalledWith({ x: 520, y: 320 }); |
| 387 | }); |
| 388 | }); |
| 389 | |
| 390 | describe("selection action positioning", () => { |
| 391 | const bounds = { left: 100, top: 50, width: 500, height: 220 }; |
| 392 | const viewport = { width: 1024, height: 768 }; |
| 393 | const selectionRect = { right: 260, bottom: 140 }; |
| 394 | |
| 395 | it("prefers the cursor release over the selection's bounding box", () => { |
| 396 | expect( |
| 397 | resolveSelectionActionPosition({ |
| 398 | bounds, |
| 399 | selectionRect, |
| 400 | pointer: { x: 520, y: 200 }, |
| 401 | viewport, |
| 402 | }), |
| 403 | ).toEqual({ x: 520, y: 200 }); |
| 404 | }); |
| 405 | |
| 406 | it("anchors keyboard selections just below the text end", () => { |
| 407 | expect( |
| 408 | resolveSelectionActionPosition({ bounds, selectionRect, pointer: null, viewport }), |
| 409 | ).toEqual({ x: 260, y: 144 }); |
| 410 | }); |
| 411 | |
| 412 | it.each([ |
| 413 | [ |
| 414 | { x: 720, y: 340 }, |
| 415 | { x: 600, y: 270 }, |
| 416 | ], |
| 417 | [ |
| 418 | { x: 40, y: 20 }, |
| 419 | { x: 100, y: 50 }, |
| 420 | ], |
| 421 | ])("keeps an outside release %j within the surface", (pointer, expected) => { |
| 422 | expect(resolveSelectionActionPosition({ bounds, selectionRect, pointer, viewport })).toEqual( |
| 423 | expected, |
| 424 | ); |
| 425 | }); |
| 426 | |
| 427 | it("keeps the anchor inside the browser even if the surface extends beyond it", () => { |
| 428 | expect( |
| 429 | resolveSelectionActionPosition({ |
| 430 | bounds: { left: -100, top: -100, width: 2000, height: 2000 }, |
| 431 | selectionRect, |
| 432 | pointer: { x: 1800, y: -50 }, |
| 433 | viewport, |
| 434 | }), |
| 435 | ).toEqual({ x: 1016, y: 8 }); |
| 436 | }); |
| 437 | }); |