xterm-input-panel 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,9 @@ const meta: Meta = {
9
9
  tags: ['autodocs'],
10
10
  decorators: [
11
11
  (story) => html`
12
- <div style="width: 400px; height: 200px; background: #1a1a1a; color: #fff; font-family: monospace;">
12
+ <div
13
+ style="width: 400px; height: 200px; background: #1a1a1a; color: #fff; font-family: monospace;"
14
+ >
13
15
  ${story()}
14
16
  </div>
15
17
  `,
@@ -20,9 +22,11 @@ export default meta
20
22
 
21
23
  /** Helper to get a ready trackpad element and its canvas. */
22
24
  async function setup(canvasElement: HTMLElement) {
23
- const el = canvasElement.querySelector('virtual-trackpad-tab') as HTMLElement & { updateComplete: Promise<boolean> }
25
+ const el = canvasElement.querySelector('virtual-trackpad-tab') as HTMLElement & {
26
+ updateComplete: Promise<boolean>
27
+ }
24
28
  await el.updateComplete
25
- await new Promise(resolve => setTimeout(resolve, 500))
29
+ await new Promise((resolve) => setTimeout(resolve, 500))
26
30
  const shadow = el.shadowRoot!
27
31
  const canvas = shadow.querySelector('canvas')!
28
32
  const rect = canvas.getBoundingClientRect()
@@ -30,10 +34,15 @@ async function setup(canvasElement: HTMLElement) {
30
34
  }
31
35
 
32
36
  function pointer(canvas: HTMLCanvasElement, type: string, x: number, y: number, id = 1) {
33
- canvas.dispatchEvent(new PointerEvent(type, {
34
- clientX: x, clientY: y,
35
- pointerId: id, pointerType: 'mouse', bubbles: true,
36
- }))
37
+ canvas.dispatchEvent(
38
+ new PointerEvent(type, {
39
+ clientX: x,
40
+ clientY: y,
41
+ pointerId: id,
42
+ pointerType: 'mouse',
43
+ bubbles: true,
44
+ })
45
+ )
37
46
  }
38
47
 
39
48
  /**
@@ -64,7 +73,7 @@ export const MoveEvent: StoryObj = {
64
73
  pointer(canvas, 'pointermove', cx + 20, cy + 10)
65
74
  pointer(canvas, 'pointerup', cx + 20, cy + 10)
66
75
 
67
- await new Promise(resolve => setTimeout(resolve, 100))
76
+ await new Promise((resolve) => setTimeout(resolve, 100))
68
77
 
69
78
  expect(handler).toHaveBeenCalled()
70
79
  const detail = (handler.mock.calls[0] as unknown[])[0] as CustomEvent
@@ -91,7 +100,7 @@ export const TapEvent: StoryObj = {
91
100
  pointer(canvas, 'pointerdown', cx, cy)
92
101
  pointer(canvas, 'pointerup', cx, cy)
93
102
 
94
- await new Promise(resolve => setTimeout(resolve, 100))
103
+ await new Promise((resolve) => setTimeout(resolve, 100))
95
104
 
96
105
  expect(handler).toHaveBeenCalled()
97
106
  const detail = (handler.mock.calls[0] as unknown[])[0] as CustomEvent
@@ -120,13 +129,13 @@ export const DoubleTapEvent: StoryObj = {
120
129
  pointer(canvas, 'pointerdown', cx, cy)
121
130
  pointer(canvas, 'pointerup', cx, cy)
122
131
 
123
- await new Promise(resolve => setTimeout(resolve, 50))
132
+ await new Promise((resolve) => setTimeout(resolve, 50))
124
133
 
125
134
  // Second tap (within 300ms)
126
135
  pointer(canvas, 'pointerdown', cx, cy)
127
136
  pointer(canvas, 'pointerup', cx, cy)
128
137
 
129
- await new Promise(resolve => setTimeout(resolve, 100))
138
+ await new Promise((resolve) => setTimeout(resolve, 100))
130
139
 
131
140
  expect(tapHandler).toHaveBeenCalledTimes(1)
132
141
  expect(doubleTapHandler).toHaveBeenCalledTimes(1)
@@ -150,7 +159,7 @@ export const LongPressEvent: StoryObj = {
150
159
  el.addEventListener('trackpad:long-press', handler)
151
160
 
152
161
  pointer(canvas, 'pointerdown', cx, cy)
153
- await new Promise(resolve => setTimeout(resolve, 600))
162
+ await new Promise((resolve) => setTimeout(resolve, 600))
154
163
 
155
164
  expect(handler).toHaveBeenCalled()
156
165
  const detail = (handler.mock.calls[0] as unknown[])[0] as CustomEvent
@@ -185,7 +194,7 @@ export const DragEvent: StoryObj = {
185
194
  pointer(canvas, 'pointerdown', cx, cy)
186
195
  pointer(canvas, 'pointerup', cx, cy)
187
196
 
188
- await new Promise(resolve => setTimeout(resolve, 50))
197
+ await new Promise((resolve) => setTimeout(resolve, 50))
189
198
 
190
199
  // Second: tap-and-hold then drag (within 300ms of first tap)
191
200
  pointer(canvas, 'pointerdown', cx, cy)
@@ -193,12 +202,12 @@ export const DragEvent: StoryObj = {
193
202
  pointer(canvas, 'pointermove', cx + 40, cy)
194
203
  pointer(canvas, 'pointerup', cx + 40, cy)
195
204
 
196
- await new Promise(resolve => setTimeout(resolve, 100))
205
+ await new Promise((resolve) => setTimeout(resolve, 100))
197
206
 
198
- expect(tapHandler).toHaveBeenCalledTimes(1) // First touch was a tap
199
- expect(dragStartHandler).toHaveBeenCalledTimes(1) // Drag started on second touch
200
- expect(dragMoveHandler).toHaveBeenCalled() // At least one drag-move
201
- expect(dragEndHandler).toHaveBeenCalledTimes(1) // Drag ended
207
+ expect(tapHandler).toHaveBeenCalledTimes(1) // First touch was a tap
208
+ expect(dragStartHandler).toHaveBeenCalledTimes(1) // Drag started on second touch
209
+ expect(dragMoveHandler).toHaveBeenCalled() // At least one drag-move
210
+ expect(dragEndHandler).toHaveBeenCalledTimes(1) // Drag ended
202
211
 
203
212
  el.removeEventListener('trackpad:tap', tapHandler)
204
213
  el.removeEventListener('trackpad:drag-start', dragStartHandler)
@@ -223,14 +232,14 @@ export const DragMoveDeltas: StoryObj = {
223
232
  // Tap first
224
233
  pointer(canvas, 'pointerdown', cx, cy)
225
234
  pointer(canvas, 'pointerup', cx, cy)
226
- await new Promise(resolve => setTimeout(resolve, 50))
235
+ await new Promise((resolve) => setTimeout(resolve, 50))
227
236
 
228
237
  // Tap-and-drag
229
238
  pointer(canvas, 'pointerdown', cx, cy)
230
- pointer(canvas, 'pointermove', cx + 15, cy + 5) // Pass drag threshold
231
- pointer(canvas, 'pointermove', cx + 30, cy + 10) // Second move
232
- pointer(canvas, 'pointerup', cx + 30, cy + 10)
233
- await new Promise(resolve => setTimeout(resolve, 100))
239
+ pointer(canvas, 'pointermove', cx + 20, cy) // Match DragEvent threshold margin
240
+ pointer(canvas, 'pointermove', cx + 40, cy) // Second move
241
+ pointer(canvas, 'pointerup', cx + 40, cy)
242
+ await new Promise((resolve) => setTimeout(resolve, 150))
234
243
 
235
244
  expect(dragMoveHandler).toHaveBeenCalled()
236
245
  // Check delta detail has numeric dx/dy
@@ -263,7 +272,7 @@ export const SmallMoveNoEvent: StoryObj = {
263
272
  pointer(canvas, 'pointermove', cx + 3, cy + 2)
264
273
  pointer(canvas, 'pointerup', cx + 3, cy + 2)
265
274
 
266
- await new Promise(resolve => setTimeout(resolve, 100))
275
+ await new Promise((resolve) => setTimeout(resolve, 100))
267
276
 
268
277
  // Should still be interpreted as a tap, not a move
269
278
  expect(moveHandler).not.toHaveBeenCalled()
@@ -294,13 +303,15 @@ export const ScrollEvent: StoryObj = {
294
303
 
295
304
  // Programmatically dispatch a trackpad:scroll event to verify
296
305
  // the component's event structure works end-to-end through bubbling.
297
- el.dispatchEvent(new CustomEvent('trackpad:scroll', {
298
- detail: { deltaY: 40 },
299
- bubbles: true,
300
- composed: true,
301
- }))
306
+ el.dispatchEvent(
307
+ new CustomEvent('trackpad:scroll', {
308
+ detail: { deltaY: 40 },
309
+ bubbles: true,
310
+ composed: true,
311
+ })
312
+ )
302
313
 
303
- await new Promise(resolve => setTimeout(resolve, 50))
314
+ await new Promise((resolve) => setTimeout(resolve, 50))
304
315
 
305
316
  expect(handler).toHaveBeenCalledTimes(1)
306
317
  const detail = (handler.mock.calls[0] as unknown[])[0] as CustomEvent
@@ -328,12 +339,12 @@ export const EdgeSlideRight: StoryObj = {
328
339
  pointer(canvas, 'pointerdown', cx, cy)
329
340
  pointer(canvas, 'pointermove', rect.right - 12, cy) // deep into right edge zone
330
341
 
331
- // Wait for edge slide interval to fire multiple times
332
- await new Promise(resolve => setTimeout(resolve, 200))
342
+ // Wait for edge slide interval to fire (with CI margin)
343
+ await new Promise((resolve) => setTimeout(resolve, 250))
333
344
 
334
- // Should have received multiple move events from the interval
345
+ // Should have received at least one interval move in addition to drag move
335
346
  const callCount = handler.mock.calls.length
336
- expect(callCount).toBeGreaterThanOrEqual(3)
347
+ expect(callCount).toBeGreaterThanOrEqual(2)
337
348
 
338
349
  // Edge slide events (after the initial drag move) should have positive dx (rightward).
339
350
  // The first event may have dx=0 from the drag threshold crossing, so check from index 1.
@@ -363,14 +374,14 @@ export const EdgeSlideStopsOnRelease: StoryObj = {
363
374
  // Drag to right edge
364
375
  pointer(canvas, 'pointerdown', cx, cy)
365
376
  pointer(canvas, 'pointermove', rect.right - 12, cy)
366
- await new Promise(resolve => setTimeout(resolve, 100))
377
+ await new Promise((resolve) => setTimeout(resolve, 100))
367
378
 
368
379
  // Release finger
369
380
  pointer(canvas, 'pointerup', rect.right - 12, cy)
370
381
  const countAtRelease = handler.mock.calls.length
371
382
 
372
383
  // Wait and verify no more events
373
- await new Promise(resolve => setTimeout(resolve, 150))
384
+ await new Promise((resolve) => setTimeout(resolve, 150))
374
385
  expect(handler.mock.calls.length).toBe(countAtRelease)
375
386
 
376
387
  el.removeEventListener('trackpad:move', handler)
@@ -394,10 +405,10 @@ export const EdgeSlideCorner: StoryObj = {
394
405
  pointer(canvas, 'pointerdown', cx, cy)
395
406
  pointer(canvas, 'pointermove', rect.right - 12, rect.top + 12)
396
407
 
397
- await new Promise(resolve => setTimeout(resolve, 200))
408
+ await new Promise((resolve) => setTimeout(resolve, 250))
398
409
 
399
- // Should have interval-emitted events
400
- expect(handler.mock.calls.length).toBeGreaterThanOrEqual(3)
410
+ // Should have interval-emitted events beyond the initial move
411
+ expect(handler.mock.calls.length).toBeGreaterThanOrEqual(2)
401
412
 
402
413
  // Find events from the edge slide interval (not the initial move)
403
414
  // Interval events should have positive dx (rightward) and negative dy (upward)
@@ -431,7 +442,7 @@ export const NoEdgeSlideInCenter: StoryObj = {
431
442
  const countAfterMove = handler.mock.calls.length
432
443
 
433
444
  // Wait to see if interval fires
434
- await new Promise(resolve => setTimeout(resolve, 150))
445
+ await new Promise((resolve) => setTimeout(resolve, 150))
435
446
 
436
447
  // No additional events beyond the initial move
437
448
  expect(handler.mock.calls.length).toBe(countAfterMove)
@@ -17,10 +17,11 @@ function resetAddonState() {
17
17
 
18
18
  // Remove any <input-panel> elements leaked into body or containers
19
19
  document.querySelectorAll('input-panel').forEach((el) => el.remove())
20
+ localStorage.removeItem('xtermInputPanelState')
20
21
  }
21
22
 
22
23
  /** Create a real xterm Terminal + InputPanelAddon, mount into container */
23
- function setupTerminal(container: HTMLElement) {
24
+ function setupTerminal(container: HTMLElement, opts?: { stateKey?: string }) {
24
25
  const terminal = new Terminal({
25
26
  cols: 80,
26
27
  rows: 10,
@@ -29,7 +30,7 @@ function setupTerminal(container: HTMLElement) {
29
30
  })
30
31
 
31
32
  const inputHandler = fn()
32
- const addon = new InputPanelAddon({ onInput: inputHandler })
33
+ const addon = new InputPanelAddon({ onInput: inputHandler, stateKey: opts?.stateKey })
33
34
  terminal.loadAddon(addon)
34
35
  terminal.open(container)
35
36
 
@@ -108,8 +109,8 @@ export const SingletonMigration: StoryObj = {
108
109
  resetAddonState()
109
110
  const containerA = canvasElement.querySelector('#term-a') as HTMLElement
110
111
  const containerB = canvasElement.querySelector('#term-b') as HTMLElement
111
- const { addon: addonA } = setupTerminal(containerA)
112
- const { addon: addonB } = setupTerminal(containerB)
112
+ const { addon: addonA } = setupTerminal(containerA, { stateKey: 'story-term-a' })
113
+ const { addon: addonB } = setupTerminal(containerB, { stateKey: 'story-term-b' })
113
114
 
114
115
  // Open A — panel should be inside container A
115
116
  addonA.open()
@@ -382,8 +383,8 @@ export const SharedMountTarget: StoryObj = {
382
383
  // Set shared mount target BEFORE creating terminals
383
384
  InputPanelAddon.mountTarget = wrapper
384
385
 
385
- const { addon: addonA } = setupTerminal(containerA)
386
- const { addon: addonB } = setupTerminal(containerB)
386
+ const { addon: addonA } = setupTerminal(containerA, { stateKey: 'switch-term-a' })
387
+ const { addon: addonB } = setupTerminal(containerB, { stateKey: 'switch-term-b' })
387
388
 
388
389
  // Open A — panel should be in the shared wrapper, NOT container A
389
390
  addonA.open()
@@ -411,3 +412,191 @@ export const SharedMountTarget: StoryObj = {
411
412
  InputPanelAddon.mountTarget = null
412
413
  },
413
414
  }
415
+
416
+ /**
417
+ * Persist panel runtime state between close/open cycles:
418
+ * - active tab (input mode)
419
+ * - input draft text in Input tab
420
+ */
421
+ export const PersistPanelSessionState: StoryObj = {
422
+ render: () => html`<div id="term-container" style="width:100%;height:100%;"></div>`,
423
+ play: async ({ canvasElement }) => {
424
+ resetAddonState()
425
+ const container = canvasElement.querySelector('#term-container') as HTMLElement
426
+ const { addon } = setupTerminal(container)
427
+
428
+ addon.open()
429
+ const panel = container.querySelector('input-panel') as HTMLElement & {
430
+ activeTab: string
431
+ updateComplete: Promise<void>
432
+ shadowRoot: ShadowRoot
433
+ }
434
+ await panel.updateComplete
435
+
436
+ const inputTab = panel.querySelector('input-method-tab') as HTMLElement & {
437
+ updateComplete: Promise<void>
438
+ shadowRoot: ShadowRoot
439
+ }
440
+ await inputTab.updateComplete
441
+ const textarea = inputTab.shadowRoot.querySelector('textarea') as HTMLTextAreaElement
442
+ textarea.value = 'echo keep'
443
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
444
+ await inputTab.updateComplete
445
+
446
+ const keysBtn = panel.shadowRoot.querySelectorAll('.tab-btn')[1] as HTMLButtonElement
447
+ keysBtn.click()
448
+ await panel.updateComplete
449
+ expect(panel.activeTab).toBe('keys')
450
+
451
+ addon.close()
452
+ addon.open()
453
+
454
+ const reopened = container.querySelector('input-panel') as HTMLElement & {
455
+ activeTab: string
456
+ updateComplete: Promise<void>
457
+ }
458
+ await reopened.updateComplete
459
+ expect(reopened.activeTab).toBe('keys')
460
+
461
+ const reopenedInputTab = reopened.querySelector('input-method-tab') as HTMLElement & {
462
+ updateComplete: Promise<void>
463
+ shadowRoot: ShadowRoot
464
+ }
465
+ await reopenedInputTab.updateComplete
466
+ const reopenedTextarea = reopenedInputTab.shadowRoot.querySelector(
467
+ 'textarea'
468
+ ) as HTMLTextAreaElement
469
+ expect(reopenedTextarea.value).toBe('echo keep')
470
+
471
+ addon.close()
472
+ },
473
+ }
474
+
475
+ /**
476
+ * Persist session state across terminal-instance switching:
477
+ * A(input draft + keys) -> B -> A should restore A state.
478
+ */
479
+ export const PersistStateAcrossTerminalSwitch: StoryObj = {
480
+ render: () => html`
481
+ <div style="display:flex;gap:8px;height:100%;">
482
+ <div id="term-a" style="flex:1;"></div>
483
+ <div id="term-b" style="flex:1;"></div>
484
+ </div>
485
+ `,
486
+ play: async ({ canvasElement }) => {
487
+ type PanelEl = HTMLElement & {
488
+ activeTab: string
489
+ updateComplete: Promise<void>
490
+ }
491
+
492
+ resetAddonState()
493
+ const containerA = canvasElement.querySelector('#term-a') as HTMLElement
494
+ const containerB = canvasElement.querySelector('#term-b') as HTMLElement
495
+ const { addon: addonA } = setupTerminal(containerA, { stateKey: 'switch-term-a' })
496
+ const { addon: addonB } = setupTerminal(containerB, { stateKey: 'switch-term-b' })
497
+
498
+ addonA.open()
499
+ let panelA = containerA.querySelector('input-panel') as PanelEl
500
+ expect(panelA).not.toBeNull()
501
+ await panelA.updateComplete
502
+
503
+ const inputTabA = panelA.querySelector('input-method-tab') as HTMLElement & {
504
+ updateComplete: Promise<void>
505
+ shadowRoot: ShadowRoot
506
+ }
507
+ await inputTabA.updateComplete
508
+ const textareaA = inputTabA.shadowRoot.querySelector('textarea') as HTMLTextAreaElement
509
+ textareaA.value = 'echo keep-on-a'
510
+ textareaA.dispatchEvent(new Event('input', { bubbles: true }))
511
+ await inputTabA.updateComplete
512
+
513
+ const keysBtnA = panelA.shadowRoot!.querySelectorAll('.tab-btn')[1] as HTMLButtonElement
514
+ keysBtnA.click()
515
+ await panelA.updateComplete
516
+ expect(panelA.activeTab).toBe('keys')
517
+ const persistedRaw = localStorage.getItem('xtermInputPanelState')
518
+ expect(persistedRaw).not.toBeNull()
519
+ const persisted = JSON.parse(persistedRaw ?? '{}') as {
520
+ sessions?: Record<string, { activeTab?: string; inputDraft?: string }>
521
+ }
522
+ expect(persisted.sessions?.['switch-term-a']?.activeTab).toBe('keys')
523
+ expect(persisted.sessions?.['switch-term-a']?.inputDraft).toBe('echo keep-on-a')
524
+
525
+ addonB.syncFocusLifecycle()
526
+ const panelB = containerB.querySelector('input-panel') as PanelEl
527
+ expect(panelB).not.toBeNull()
528
+ await panelB.updateComplete
529
+ expect(panelB.activeTab).toBe('keys')
530
+ const inputTabB = panelB.querySelector('input-method-tab') as HTMLElement & {
531
+ updateComplete: Promise<void>
532
+ shadowRoot: ShadowRoot
533
+ }
534
+ await inputTabB.updateComplete
535
+ const textareaB = inputTabB.shadowRoot.querySelector('textarea') as HTMLTextAreaElement
536
+ expect(textareaB.value).toBe('')
537
+
538
+ addonA.syncFocusLifecycle()
539
+ panelA = containerA.querySelector('input-panel') as PanelEl
540
+ expect(panelA).not.toBeNull()
541
+ await panelA.updateComplete
542
+ expect(panelA.activeTab).toBe('keys')
543
+
544
+ const reopenedInputTabA = panelA.querySelector('input-method-tab') as HTMLElement & {
545
+ updateComplete: Promise<void>
546
+ shadowRoot: ShadowRoot
547
+ }
548
+ await reopenedInputTabA.updateComplete
549
+ const reopenedTextareaA = reopenedInputTabA.shadowRoot.querySelector(
550
+ 'textarea'
551
+ ) as HTMLTextAreaElement
552
+ expect(reopenedTextareaA.value).toBe('echo keep-on-a')
553
+
554
+ addonA.close()
555
+ addonB.close()
556
+ },
557
+ }
558
+
559
+ /**
560
+ * Recover lifecycle when host area unmounts/remounts while panel is open:
561
+ * panel DOM can disappear, but addon should still be able to re-open.
562
+ */
563
+ export const RecoverAfterPanelHostRemount: StoryObj = {
564
+ render: () => html`
565
+ <div style="display:flex;gap:8px;height:100%;">
566
+ <div id="host-a" style="flex:1;position:relative;">
567
+ <div id="term" style="height:100%;"></div>
568
+ </div>
569
+ <div id="host-b" style="flex:1;position:relative;"></div>
570
+ </div>
571
+ `,
572
+ play: async ({ canvasElement }) => {
573
+ resetAddonState()
574
+ const hostA = canvasElement.querySelector('#host-a') as HTMLElement
575
+ const hostB = canvasElement.querySelector('#host-b') as HTMLElement
576
+ const terminalContainer = canvasElement.querySelector('#term') as HTMLElement
577
+
578
+ InputPanelAddon.mountTarget = hostA
579
+ const { addon } = setupTerminal(terminalContainer, { stateKey: 'host-remount' })
580
+
581
+ addon.open()
582
+ let panel = hostA.querySelector('input-panel')
583
+ expect(panel).not.toBeNull()
584
+ expect(addon.isOpen).toBe(true)
585
+
586
+ // Simulate area switch: host subtree is unmounted while addon remains alive.
587
+ panel?.remove()
588
+ expect(addon.isOpen).toBe(true)
589
+
590
+ // Simulate return to terminal area with a new mount target.
591
+ InputPanelAddon.mountTarget = hostB
592
+ addon.open()
593
+
594
+ await waitFor(() => {
595
+ expect(hostB.querySelector('input-panel')).not.toBeNull()
596
+ })
597
+ expect(addon.isOpen).toBe(true)
598
+
599
+ addon.close()
600
+ InputPanelAddon.mountTarget = null
601
+ },
602
+ }