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.
- package/.storybook/vitest.setup.ts +2 -2
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/package.json +3 -3
- package/src/input-method-tab.stories.ts +12 -8
- package/src/input-method-tab.ts +20 -8
- package/src/input-panel.stories.ts +14 -0
- package/src/input-panel.ts +3 -12
- package/src/pixi-theme.test.ts +2 -2
- package/src/platform.ts +3 -3
- package/src/shortcut-pages.ts +431 -61
- package/src/shortcut-tab.ts +45 -24
- package/src/virtual-keyboard-tab.stories.ts +10 -8
- package/src/virtual-trackpad-tab.stories.ts +51 -40
- package/src/xterm-addon.stories.ts +195 -6
- package/src/xterm-addon.ts +246 -19
- package/vitest.storybook.config.ts +1 -1
|
@@ -9,7 +9,9 @@ const meta: Meta = {
|
|
|
9
9
|
tags: ['autodocs'],
|
|
10
10
|
decorators: [
|
|
11
11
|
(story) => html`
|
|
12
|
-
<div
|
|
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 & {
|
|
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(
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
199
|
-
expect(dragStartHandler).toHaveBeenCalledTimes(1)
|
|
200
|
-
expect(dragMoveHandler).toHaveBeenCalled()
|
|
201
|
-
expect(dragEndHandler).toHaveBeenCalledTimes(1)
|
|
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 +
|
|
231
|
-
pointer(canvas, 'pointermove', cx +
|
|
232
|
-
pointer(canvas, 'pointerup', cx +
|
|
233
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
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(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
332
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
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
|
|
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(
|
|
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,
|
|
408
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
398
409
|
|
|
399
|
-
// Should have interval-emitted events
|
|
400
|
-
expect(handler.mock.calls.length).toBeGreaterThanOrEqual(
|
|
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
|
+
}
|