xterm-input-panel 1.2.2 → 1.2.4
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/CHANGELOG.md +12 -0
- package/package.json +18 -14
- package/src/input-panel.stories.ts +87 -1
- package/src/input-panel.ts +142 -19
- package/src/storage-namespace.test.ts +3 -3
- package/src/storage-namespace.ts +0 -5
- package/src/virtual-trackpad-tab.stories.ts +5 -3
- package/src/xterm-addon.stories.ts +38 -15
- package/vitest.storybook.config.ts +3 -0
- package/LICENSE +0 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# xterm-input-panel
|
|
2
2
|
|
|
3
|
+
## 1.2.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5e63308: Fix mobile floating input panel chrome and settings switch semantics.
|
|
8
|
+
|
|
9
|
+
## 1.2.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 6dcad78: Upgrade the Vite toolchain to Vite 8 and align the related React, Storybook, and Vitest integrations used by local build and browser-test workflows.
|
|
14
|
+
|
|
3
15
|
## 1.2.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xterm-input-panel",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "storybook dev -p 6007",
|
|
8
|
+
"test:browser": "vitest run --retry 2 --config vitest.storybook.config.ts",
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
},
|
|
6
11
|
"dependencies": {
|
|
7
12
|
"lit": "^3.3.2",
|
|
8
13
|
"lucide": "^0.472.0",
|
|
@@ -17,21 +22,20 @@
|
|
|
17
22
|
}
|
|
18
23
|
},
|
|
19
24
|
"devDependencies": {
|
|
20
|
-
"@storybook/addon-vitest": "^10.2.
|
|
21
|
-
"@storybook/web-components": "^10.2.
|
|
22
|
-
"@storybook/web-components-vite": "^10.2.
|
|
23
|
-
"@vitest/browser": "^4.0
|
|
24
|
-
"@vitest/browser-playwright": "^4.0
|
|
25
|
+
"@storybook/addon-vitest": "^10.2.19",
|
|
26
|
+
"@storybook/web-components": "^10.2.19",
|
|
27
|
+
"@storybook/web-components-vite": "^10.2.19",
|
|
28
|
+
"@vitest/browser": "^4.1.0",
|
|
29
|
+
"@vitest/browser-playwright": "^4.1.0",
|
|
25
30
|
"@xterm/xterm": "6.1.0-beta.167",
|
|
26
31
|
"playwright": "^1.58.2",
|
|
27
|
-
"storybook": "^10.2.
|
|
32
|
+
"storybook": "^10.2.19",
|
|
28
33
|
"typescript": "^5.7.2",
|
|
29
|
-
"vite": "^
|
|
30
|
-
"vitest": "^4.0
|
|
34
|
+
"vite": "^8.0.0",
|
|
35
|
+
"vitest": "^4.1.0"
|
|
31
36
|
},
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"typecheck": "tsc --noEmit"
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/jixoai/openspecui"
|
|
36
40
|
}
|
|
37
|
-
}
|
|
41
|
+
}
|
|
@@ -13,6 +13,24 @@ async function getLitElement(container: HTMLElement, selector: string) {
|
|
|
13
13
|
return el
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function pointer(target: Element, type: string, x: number, y: number, id = 1) {
|
|
17
|
+
target.dispatchEvent(
|
|
18
|
+
new PointerEvent(type, {
|
|
19
|
+
clientX: x,
|
|
20
|
+
clientY: y,
|
|
21
|
+
pointerId: id,
|
|
22
|
+
pointerType: 'mouse',
|
|
23
|
+
bubbles: true,
|
|
24
|
+
cancelable: true,
|
|
25
|
+
})
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function expectFloatingDialogVisible(dialog: HTMLDialogElement) {
|
|
30
|
+
expect(dialog.open || dialog.matches(':popover-open')).toBe(true)
|
|
31
|
+
expect(dialog.matches(':modal')).toBe(false)
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
const meta: Meta = {
|
|
17
35
|
title: 'InputPanel',
|
|
18
36
|
tags: ['autodocs'],
|
|
@@ -58,8 +76,20 @@ export const FloatingLayout: StoryObj = {
|
|
|
58
76
|
`,
|
|
59
77
|
play: async ({ canvasElement }) => {
|
|
60
78
|
const panel = await getLitElement(canvasElement, 'input-panel')
|
|
61
|
-
const dialog = panel.shadowRoot?.querySelector('.panel-dialog') as
|
|
79
|
+
const dialog = panel.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
|
|
62
80
|
expect(dialog).toBeTruthy()
|
|
81
|
+
expectFloatingDialogVisible(dialog)
|
|
82
|
+
const moveBar = panel.shadowRoot?.querySelector('.move-bar') as HTMLElement
|
|
83
|
+
expect(moveBar).toBeTruthy()
|
|
84
|
+
|
|
85
|
+
const dialogStyles = getComputedStyle(dialog)
|
|
86
|
+
expect(dialogStyles.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
87
|
+
expect(dialogStyles.borderTopWidth).toBe('1px')
|
|
88
|
+
expect(dialogStyles.borderRadius).toBe('8px')
|
|
89
|
+
|
|
90
|
+
const moveBarRect = moveBar.getBoundingClientRect()
|
|
91
|
+
const dialogRect = dialog.getBoundingClientRect()
|
|
92
|
+
expect(moveBarRect.top).toBeLessThan(dialogRect.top)
|
|
63
93
|
|
|
64
94
|
const styles = getComputedStyle(dialog) as CSSStyleDeclaration & {
|
|
65
95
|
webkitBackdropFilter?: string
|
|
@@ -214,6 +244,7 @@ export const FloatingResize: StoryObj = {
|
|
|
214
244
|
const shadow = panel.shadowRoot!
|
|
215
245
|
const dialog = shadow.querySelector('.panel-dialog') as HTMLDialogElement
|
|
216
246
|
expect(dialog).toBeTruthy()
|
|
247
|
+
expectFloatingDialogVisible(dialog)
|
|
217
248
|
|
|
218
249
|
const handles = shadow.querySelectorAll('.resize-handle')
|
|
219
250
|
expect(handles.length).toBe(4)
|
|
@@ -226,6 +257,61 @@ export const FloatingResize: StoryObj = {
|
|
|
226
257
|
},
|
|
227
258
|
}
|
|
228
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Floating panel moves through the protruding move bar and toolbar blank space.
|
|
262
|
+
*/
|
|
263
|
+
export const FloatingDragHandles: StoryObj = {
|
|
264
|
+
render: () => html`
|
|
265
|
+
<input-panel layout="floating" style="height: 100%;">
|
|
266
|
+
<input-method-tab slot="input"></input-method-tab>
|
|
267
|
+
<virtual-keyboard-tab slot="keys" floating></virtual-keyboard-tab>
|
|
268
|
+
<shortcut-tab slot="shortcuts"></shortcut-tab>
|
|
269
|
+
<virtual-trackpad-tab slot="trackpad" floating></virtual-trackpad-tab>
|
|
270
|
+
</input-panel>
|
|
271
|
+
`,
|
|
272
|
+
play: async ({ canvasElement }) => {
|
|
273
|
+
const panel = await getLitElement(canvasElement, 'input-panel')
|
|
274
|
+
const shadow = panel.shadowRoot!
|
|
275
|
+
const dialog = shadow.querySelector('.panel-dialog') as HTMLDialogElement
|
|
276
|
+
const moveBar = shadow.querySelector('.move-bar') as HTMLElement
|
|
277
|
+
const toolbar = shadow.querySelector('.toolbar') as HTMLElement
|
|
278
|
+
const layoutBtn = shadow.querySelector('.action-group .icon-btn') as HTMLButtonElement
|
|
279
|
+
expectFloatingDialogVisible(dialog)
|
|
280
|
+
expect(moveBar).toBeTruthy()
|
|
281
|
+
expect(toolbar).toBeTruthy()
|
|
282
|
+
|
|
283
|
+
const before = dialog.getBoundingClientRect()
|
|
284
|
+
const handleRect = moveBar.getBoundingClientRect()
|
|
285
|
+
const startX = handleRect.left + handleRect.width / 2
|
|
286
|
+
const startY = handleRect.top + handleRect.height / 2
|
|
287
|
+
|
|
288
|
+
pointer(moveBar, 'pointerdown', startX, startY)
|
|
289
|
+
pointer(moveBar, 'pointermove', startX + 40, startY + 22)
|
|
290
|
+
pointer(moveBar, 'pointerup', startX + 40, startY + 22)
|
|
291
|
+
|
|
292
|
+
const after = dialog.getBoundingClientRect()
|
|
293
|
+
expect(after.left).toBeGreaterThan(before.left + 20)
|
|
294
|
+
expect(after.top).toBeGreaterThan(before.top + 10)
|
|
295
|
+
|
|
296
|
+
const toolbarRect = toolbar.getBoundingClientRect()
|
|
297
|
+
const toolbarStartX = toolbarRect.left + toolbarRect.width / 2
|
|
298
|
+
const toolbarStartY = toolbarRect.top + toolbarRect.height / 2
|
|
299
|
+
|
|
300
|
+
pointer(toolbar, 'pointerdown', toolbarStartX, toolbarStartY, 2)
|
|
301
|
+
pointer(toolbar, 'pointermove', toolbarStartX - 35, toolbarStartY + 16, 2)
|
|
302
|
+
pointer(toolbar, 'pointerup', toolbarStartX - 35, toolbarStartY + 16, 2)
|
|
303
|
+
|
|
304
|
+
const toolbarAfter = dialog.getBoundingClientRect()
|
|
305
|
+
expect(toolbarAfter.left).toBeLessThan(after.left - 15)
|
|
306
|
+
expect(toolbarAfter.top).toBeGreaterThan(after.top + 8)
|
|
307
|
+
|
|
308
|
+
layoutBtn.click()
|
|
309
|
+
await panel.updateComplete
|
|
310
|
+
|
|
311
|
+
expect(panel.getAttribute('layout')).toBe('fixed')
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
|
|
229
315
|
/**
|
|
230
316
|
* Verifies Fixed mode height slider updates InputPanel internal style variable.
|
|
231
317
|
*/
|
package/src/input-panel.ts
CHANGED
|
@@ -24,6 +24,7 @@ const MIN_WIDTH_PX = 300
|
|
|
24
24
|
const MIN_HEIGHT_PX = 150
|
|
25
25
|
const MAX_WIDTH_PCT = 95
|
|
26
26
|
const MAX_HEIGHT_PCT = 85
|
|
27
|
+
const MOVE_BAR_PROTRUSION_PX = 10
|
|
27
28
|
|
|
28
29
|
const SETTINGS_KEY = 'xtermInputPanelSettings'
|
|
29
30
|
|
|
@@ -92,6 +93,8 @@ export class InputPanel extends LitElement {
|
|
|
92
93
|
--muted-foreground: var(--_ip-muted-fg);
|
|
93
94
|
--terminal: var(--_ip-bg);
|
|
94
95
|
--terminal-foreground: var(--_ip-fg);
|
|
96
|
+
--move-bar-height: 10px;
|
|
97
|
+
--move-bar-protrusion: 10px;
|
|
95
98
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
96
99
|
font-size: 13px;
|
|
97
100
|
color: var(--foreground, #fff);
|
|
@@ -214,7 +217,7 @@ export class InputPanel extends LitElement {
|
|
|
214
217
|
border-radius: 8px;
|
|
215
218
|
background: var(--background, #1a1a1a);
|
|
216
219
|
color: var(--foreground, #fff);
|
|
217
|
-
overflow:
|
|
220
|
+
overflow: visible;
|
|
218
221
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
219
222
|
display: flex;
|
|
220
223
|
flex-direction: column;
|
|
@@ -226,6 +229,54 @@ export class InputPanel extends LitElement {
|
|
|
226
229
|
z-index: 9999;
|
|
227
230
|
}
|
|
228
231
|
|
|
232
|
+
.panel-body {
|
|
233
|
+
width: 100%;
|
|
234
|
+
height: 100%;
|
|
235
|
+
display: flex;
|
|
236
|
+
flex-direction: column;
|
|
237
|
+
overflow: hidden;
|
|
238
|
+
border-radius: inherit;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.move-bar {
|
|
242
|
+
position: absolute;
|
|
243
|
+
top: calc(-1 * var(--move-bar-protrusion));
|
|
244
|
+
left: 50%;
|
|
245
|
+
width: 68px;
|
|
246
|
+
height: var(--move-bar-height);
|
|
247
|
+
transform: translateX(-50%);
|
|
248
|
+
border: 1px solid var(--primary, #e04a2f);
|
|
249
|
+
border-bottom: 0;
|
|
250
|
+
border-radius: 10px 10px 0 0;
|
|
251
|
+
background: var(--background, #1a1a1a);
|
|
252
|
+
color: var(--muted-foreground, #888);
|
|
253
|
+
touch-action: none;
|
|
254
|
+
cursor: grab;
|
|
255
|
+
z-index: 12;
|
|
256
|
+
display: flex;
|
|
257
|
+
align-items: center;
|
|
258
|
+
justify-content: center;
|
|
259
|
+
box-shadow: 0 -6px 12px rgba(0, 0, 0, 0.12);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.move-bar::before {
|
|
263
|
+
content: '';
|
|
264
|
+
width: 34px;
|
|
265
|
+
height: 4px;
|
|
266
|
+
border-radius: 999px;
|
|
267
|
+
background: currentColor;
|
|
268
|
+
opacity: 0.72;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.move-bar:hover,
|
|
272
|
+
:host([data-interacting]) .move-bar {
|
|
273
|
+
color: var(--primary, #e04a2f);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
:host([data-interacting]) .move-bar {
|
|
277
|
+
cursor: grabbing;
|
|
278
|
+
}
|
|
279
|
+
|
|
229
280
|
/* --- Resize handles --- */
|
|
230
281
|
.resize-handle {
|
|
231
282
|
position: absolute;
|
|
@@ -345,6 +396,7 @@ export class InputPanel extends LitElement {
|
|
|
345
396
|
|
|
346
397
|
disconnectedCallback() {
|
|
347
398
|
super.disconnectedCallback()
|
|
399
|
+
this._closeFloatingDialog()
|
|
348
400
|
this.dispatchEvent(
|
|
349
401
|
new CustomEvent('input-panel:disconnected', { bubbles: true, composed: true })
|
|
350
402
|
)
|
|
@@ -416,8 +468,8 @@ export class InputPanel extends LitElement {
|
|
|
416
468
|
maxOverY = hPx / 3
|
|
417
469
|
return {
|
|
418
470
|
left: Math.max(-maxOverX, Math.min(vw - wPx + maxOverX, leftPx)),
|
|
419
|
-
//
|
|
420
|
-
top: Math.max(
|
|
471
|
+
// Keep the protruding move handle reachable even when the panel is near the top edge.
|
|
472
|
+
top: Math.max(MOVE_BAR_PROTRUSION_PX, Math.min(vh - hPx + maxOverY, topPx)),
|
|
421
473
|
}
|
|
422
474
|
}
|
|
423
475
|
|
|
@@ -453,6 +505,55 @@ export class InputPanel extends LitElement {
|
|
|
453
505
|
if (dialog) this._applyGeometry(dialog)
|
|
454
506
|
}
|
|
455
507
|
|
|
508
|
+
private _isPopoverOpen(dialog: HTMLDialogElement) {
|
|
509
|
+
try {
|
|
510
|
+
return dialog.matches(':popover-open')
|
|
511
|
+
} catch {
|
|
512
|
+
return false
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private _isFloatingDialogOpen(dialog: HTMLDialogElement) {
|
|
517
|
+
return dialog.open || this._isPopoverOpen(dialog)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private _showFloatingDialog(dialog: HTMLDialogElement) {
|
|
521
|
+
if (this._isFloatingDialogOpen(dialog)) return
|
|
522
|
+
if (!dialog.isConnected) {
|
|
523
|
+
requestAnimationFrame(() => {
|
|
524
|
+
if (
|
|
525
|
+
this.layout === 'floating' &&
|
|
526
|
+
dialog.isConnected &&
|
|
527
|
+
!this._isFloatingDialogOpen(dialog)
|
|
528
|
+
) {
|
|
529
|
+
this._showFloatingDialog(dialog)
|
|
530
|
+
this._applyGeometry(dialog)
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
const popoverDialog = dialog as HTMLDialogElement & {
|
|
536
|
+
showPopover?: () => void
|
|
537
|
+
}
|
|
538
|
+
if (typeof popoverDialog.showPopover === 'function') {
|
|
539
|
+
popoverDialog.showPopover()
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
dialog.show()
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private _closeFloatingDialog() {
|
|
546
|
+
const dialog = this.shadowRoot?.querySelector('.panel-dialog') as
|
|
547
|
+
| (HTMLDialogElement & { hidePopover?: () => void })
|
|
548
|
+
| null
|
|
549
|
+
if (!dialog) return
|
|
550
|
+
if (this._isPopoverOpen(dialog) && typeof dialog.hidePopover === 'function') {
|
|
551
|
+
dialog.hidePopover()
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
if (dialog.open) dialog.close()
|
|
555
|
+
}
|
|
556
|
+
|
|
456
557
|
// --- Tab / layout ---
|
|
457
558
|
|
|
458
559
|
private _switchTab(tab: InputPanelTab) {
|
|
@@ -469,6 +570,9 @@ export class InputPanel extends LitElement {
|
|
|
469
570
|
|
|
470
571
|
private _toggleLayout() {
|
|
471
572
|
this.layout = this.layout === 'fixed' ? 'floating' : 'fixed'
|
|
573
|
+
if (this.layout === 'fixed') {
|
|
574
|
+
this._closeFloatingDialog()
|
|
575
|
+
}
|
|
472
576
|
this.dispatchEvent(
|
|
473
577
|
new CustomEvent('input-panel:layout-change', {
|
|
474
578
|
detail: { layout: this.layout },
|
|
@@ -480,8 +584,7 @@ export class InputPanel extends LitElement {
|
|
|
480
584
|
}
|
|
481
585
|
|
|
482
586
|
private _close() {
|
|
483
|
-
|
|
484
|
-
if (dialog?.open) dialog.close()
|
|
587
|
+
this._closeFloatingDialog()
|
|
485
588
|
this.dispatchEvent(
|
|
486
589
|
new CustomEvent('input-panel:close', {
|
|
487
590
|
bubbles: true,
|
|
@@ -494,8 +597,8 @@ export class InputPanel extends LitElement {
|
|
|
494
597
|
super.firstUpdated(changed)
|
|
495
598
|
if (this.layout === 'floating') {
|
|
496
599
|
const dialog = this.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement | null
|
|
497
|
-
if (dialog && !dialog
|
|
498
|
-
|
|
600
|
+
if (dialog && !this._isFloatingDialogOpen(dialog)) {
|
|
601
|
+
this._showFloatingDialog(dialog)
|
|
499
602
|
this._applyGeometry(dialog)
|
|
500
603
|
}
|
|
501
604
|
}
|
|
@@ -510,10 +613,10 @@ export class InputPanel extends LitElement {
|
|
|
510
613
|
const dialog = this.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement | null
|
|
511
614
|
if (dialog) {
|
|
512
615
|
// Only re-open dialog when layout just switched to floating
|
|
513
|
-
if (changed.has('layout') && !dialog
|
|
514
|
-
|
|
616
|
+
if (changed.has('layout') && !this._isFloatingDialogOpen(dialog)) {
|
|
617
|
+
this._showFloatingDialog(dialog)
|
|
515
618
|
}
|
|
516
|
-
if (dialog
|
|
619
|
+
if (this._isFloatingDialogOpen(dialog)) {
|
|
517
620
|
this._applyGeometry(dialog)
|
|
518
621
|
}
|
|
519
622
|
}
|
|
@@ -523,9 +626,12 @@ export class InputPanel extends LitElement {
|
|
|
523
626
|
// --- Dialog drag ---
|
|
524
627
|
|
|
525
628
|
private _onToolbarPointerDown(e: PointerEvent) {
|
|
526
|
-
if (this.layout !== 'floating') return
|
|
527
|
-
// Don't intercept clicks on buttons (close, tabs, layout toggle)
|
|
528
629
|
if ((e.target as HTMLElement).closest('button')) return
|
|
630
|
+
this._onDragPointerDown(e)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private _onDragPointerDown(e: PointerEvent) {
|
|
634
|
+
if (this.layout !== 'floating') return
|
|
529
635
|
const dialog = this.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement | null
|
|
530
636
|
if (!dialog) return
|
|
531
637
|
|
|
@@ -540,10 +646,15 @@ export class InputPanel extends LitElement {
|
|
|
540
646
|
origTop: rect.top,
|
|
541
647
|
}
|
|
542
648
|
this.setAttribute('data-interacting', '')
|
|
543
|
-
|
|
649
|
+
const handle = e.currentTarget as HTMLElement
|
|
650
|
+
try {
|
|
651
|
+
handle.setPointerCapture(e.pointerId)
|
|
652
|
+
} catch {
|
|
653
|
+
// Synthetic PointerEvents used by browser tests do not always create an active pointer.
|
|
654
|
+
}
|
|
544
655
|
}
|
|
545
656
|
|
|
546
|
-
private
|
|
657
|
+
private _onDragPointerMove(e: PointerEvent) {
|
|
547
658
|
if (!this._dragState) return
|
|
548
659
|
e.stopPropagation()
|
|
549
660
|
e.preventDefault()
|
|
@@ -567,8 +678,10 @@ export class InputPanel extends LitElement {
|
|
|
567
678
|
dialog.style.bottom = 'auto'
|
|
568
679
|
}
|
|
569
680
|
|
|
570
|
-
private
|
|
681
|
+
private _onDragPointerUp(e: PointerEvent) {
|
|
571
682
|
if (!this._dragState) return
|
|
683
|
+
e.stopPropagation()
|
|
684
|
+
e.preventDefault()
|
|
572
685
|
this._dragState = null
|
|
573
686
|
this.removeAttribute('data-interacting')
|
|
574
687
|
|
|
@@ -737,8 +850,9 @@ export class InputPanel extends LitElement {
|
|
|
737
850
|
class="toolbar"
|
|
738
851
|
part="toolbar"
|
|
739
852
|
@pointerdown=${(e: PointerEvent) => this._onToolbarPointerDown(e)}
|
|
740
|
-
@pointermove=${(e: PointerEvent) => this.
|
|
741
|
-
@pointerup=${() => this.
|
|
853
|
+
@pointermove=${(e: PointerEvent) => this._onDragPointerMove(e)}
|
|
854
|
+
@pointerup=${(e: PointerEvent) => this._onDragPointerUp(e)}
|
|
855
|
+
@pointercancel=${(e: PointerEvent) => this._onDragPointerUp(e)}
|
|
742
856
|
>
|
|
743
857
|
<div class="tab-group">
|
|
744
858
|
${tabs.map(
|
|
@@ -778,7 +892,16 @@ export class InputPanel extends LitElement {
|
|
|
778
892
|
`
|
|
779
893
|
|
|
780
894
|
if (this.layout === 'floating') {
|
|
781
|
-
return html` <dialog class="panel-dialog">
|
|
895
|
+
return html` <dialog class="panel-dialog" popover="manual">
|
|
896
|
+
<div
|
|
897
|
+
class="move-bar"
|
|
898
|
+
part="move-bar"
|
|
899
|
+
aria-label="Move input panel"
|
|
900
|
+
@pointerdown=${(e: PointerEvent) => this._onDragPointerDown(e)}
|
|
901
|
+
@pointermove=${(e: PointerEvent) => this._onDragPointerMove(e)}
|
|
902
|
+
@pointerup=${(e: PointerEvent) => this._onDragPointerUp(e)}
|
|
903
|
+
@pointercancel=${(e: PointerEvent) => this._onDragPointerUp(e)}
|
|
904
|
+
></div>
|
|
782
905
|
<div
|
|
783
906
|
class="resize-handle resize-tl"
|
|
784
907
|
@pointerdown=${(e: PointerEvent) => this._onResizeStart(e, 'tl')}
|
|
@@ -795,7 +918,7 @@ export class InputPanel extends LitElement {
|
|
|
795
918
|
class="resize-handle resize-br"
|
|
796
919
|
@pointerdown=${(e: PointerEvent) => this._onResizeStart(e, 'br')}
|
|
797
920
|
></div>
|
|
798
|
-
|
|
921
|
+
<div class="panel-body">${inner}</div>
|
|
799
922
|
</dialog>`
|
|
800
923
|
}
|
|
801
924
|
|
|
@@ -2,10 +2,10 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { getSessionScopedStorageKey } from './storage-namespace'
|
|
3
3
|
|
|
4
4
|
describe('storage namespace helpers', () => {
|
|
5
|
-
it('scopes hosted
|
|
5
|
+
it('scopes hosted sessions by session', () => {
|
|
6
6
|
expect(
|
|
7
7
|
getSessionScopedStorageKey('xtermInputPanelState', {
|
|
8
|
-
pathname: '/
|
|
8
|
+
pathname: '/dashboard',
|
|
9
9
|
search: '?session=session-a',
|
|
10
10
|
})
|
|
11
11
|
).toBe('hosted-session:session-a:xtermInputPanelState')
|
|
@@ -15,7 +15,7 @@ describe('storage namespace helpers', () => {
|
|
|
15
15
|
expect(
|
|
16
16
|
getSessionScopedStorageKey('xtermInputPanelState', {
|
|
17
17
|
pathname: '/dashboard',
|
|
18
|
-
search: '
|
|
18
|
+
search: '',
|
|
19
19
|
})
|
|
20
20
|
).toBe('xtermInputPanelState')
|
|
21
21
|
})
|
package/src/storage-namespace.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
const HOSTED_VERSION_PATH_RE = /^\/versions\/[^/]+(?:\/|$)/
|
|
2
|
-
|
|
3
1
|
function getHostedSessionId(locationLike: Pick<Location, 'pathname' | 'search'>): string | null {
|
|
4
|
-
if (!HOSTED_VERSION_PATH_RE.test(locationLike.pathname)) {
|
|
5
|
-
return null
|
|
6
|
-
}
|
|
7
2
|
const value = new URLSearchParams(locationLike.search).get('session')?.trim()
|
|
8
3
|
return value ? value : null
|
|
9
4
|
}
|
|
@@ -129,7 +129,8 @@ export const DoubleTapEvent: StoryObj = {
|
|
|
129
129
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
130
130
|
pointer(canvas, 'pointerup', cx, cy)
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
// Fire the second tap immediately so CI scheduler jitter cannot push the gesture outside
|
|
133
|
+
// the double-tap window.
|
|
133
134
|
|
|
134
135
|
// Second tap (within 300ms)
|
|
135
136
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
@@ -194,7 +195,8 @@ export const DragEvent: StoryObj = {
|
|
|
194
195
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
195
196
|
pointer(canvas, 'pointerup', cx, cy)
|
|
196
197
|
|
|
197
|
-
|
|
198
|
+
// Start the second touch immediately so the drag sequence stays inside the same
|
|
199
|
+
// second-touch detection window in slower CI browsers.
|
|
198
200
|
|
|
199
201
|
// Second: tap-and-hold then drag (within 300ms of first tap)
|
|
200
202
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
@@ -232,7 +234,7 @@ export const DragMoveDeltas: StoryObj = {
|
|
|
232
234
|
// Tap first
|
|
233
235
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
234
236
|
pointer(canvas, 'pointerup', cx, cy)
|
|
235
|
-
|
|
237
|
+
// Start the drag immediately so the second touch stays within the same gesture window.
|
|
236
238
|
|
|
237
239
|
// Tap-and-drag
|
|
238
240
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
@@ -7,8 +7,19 @@ import { InputPanelAddon } from './xterm-addon.js'
|
|
|
7
7
|
// Register all custom elements (critical — xterm-addon.ts does NOT import these)
|
|
8
8
|
import './index.js'
|
|
9
9
|
|
|
10
|
+
const storyCleanups = new Set<() => void>()
|
|
11
|
+
|
|
10
12
|
/** Reset singleton state between stories */
|
|
11
13
|
function resetAddonState() {
|
|
14
|
+
for (const cleanup of storyCleanups) {
|
|
15
|
+
try {
|
|
16
|
+
cleanup()
|
|
17
|
+
} catch {
|
|
18
|
+
/* ignore test cleanup failures */
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
storyCleanups.clear()
|
|
22
|
+
|
|
12
23
|
// Force-close any active instance
|
|
13
24
|
const active = InputPanelAddon.activeInstance
|
|
14
25
|
if (active) active.close()
|
|
@@ -33,10 +44,19 @@ function setupTerminal(container: HTMLElement, opts?: { stateKey?: string }) {
|
|
|
33
44
|
const addon = new InputPanelAddon({ onInput: inputHandler, stateKey: opts?.stateKey })
|
|
34
45
|
terminal.loadAddon(addon)
|
|
35
46
|
terminal.open(container)
|
|
47
|
+
storyCleanups.add(() => {
|
|
48
|
+
addon.dispose()
|
|
49
|
+
terminal.dispose()
|
|
50
|
+
})
|
|
36
51
|
|
|
37
52
|
return { terminal, addon, inputHandler }
|
|
38
53
|
}
|
|
39
54
|
|
|
55
|
+
function expectFloatingDialogVisible(dialog: HTMLDialogElement) {
|
|
56
|
+
expect(dialog.open || dialog.matches(':popover-open')).toBe(true)
|
|
57
|
+
expect(dialog.matches(':modal')).toBe(false)
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
const meta: Meta = {
|
|
41
61
|
title: 'InputPanelAddon',
|
|
42
62
|
tags: ['autodocs'],
|
|
@@ -79,13 +99,13 @@ export const OpenCreatesPanel: StoryObj = {
|
|
|
79
99
|
expect(panel!.getAttribute('layout')).toBe('floating')
|
|
80
100
|
expect(panel!.parentElement).toBe(container)
|
|
81
101
|
|
|
82
|
-
// Wait for Lit to render + firstUpdated to
|
|
102
|
+
// Wait for Lit to render + firstUpdated to open the floating dialog without modal inert behavior.
|
|
83
103
|
await (panel as any).updateComplete
|
|
84
104
|
|
|
85
|
-
// The dialog inside shadow DOM should be
|
|
105
|
+
// The dialog inside shadow DOM should be visible.
|
|
86
106
|
const dialog = (panel as any).shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
|
|
87
107
|
expect(dialog).not.toBeNull()
|
|
88
|
-
|
|
108
|
+
expectFloatingDialogVisible(dialog)
|
|
89
109
|
|
|
90
110
|
// Cleanup
|
|
91
111
|
addon.close()
|
|
@@ -163,11 +183,11 @@ export const ToggleCycle: StoryObj = {
|
|
|
163
183
|
const panel = container.querySelector('input-panel')
|
|
164
184
|
expect(panel).not.toBeNull()
|
|
165
185
|
|
|
166
|
-
// Wait for Lit render and verify dialog is
|
|
186
|
+
// Wait for Lit render and verify dialog is visible.
|
|
167
187
|
await (panel as any).updateComplete
|
|
168
188
|
const dialog = (panel as any).shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
|
|
169
189
|
expect(dialog).not.toBeNull()
|
|
170
|
-
|
|
190
|
+
expectFloatingDialogVisible(dialog)
|
|
171
191
|
|
|
172
192
|
addon.close()
|
|
173
193
|
},
|
|
@@ -288,10 +308,10 @@ export const CustomElementRegistered: StoryObj = {
|
|
|
288
308
|
const dialog = shadow.querySelector('.panel-dialog') as HTMLDialogElement
|
|
289
309
|
expect(dialog).not.toBeNull()
|
|
290
310
|
|
|
291
|
-
// firstUpdated should have
|
|
292
|
-
|
|
311
|
+
// firstUpdated should have opened the floating dialog without modal inert behavior.
|
|
312
|
+
expectFloatingDialogVisible(dialog)
|
|
293
313
|
|
|
294
|
-
// Floating mode intentionally
|
|
314
|
+
// Floating mode intentionally renders no custom backdrop element.
|
|
295
315
|
const backdrop = shadow.querySelector('.backdrop')
|
|
296
316
|
expect(backdrop).toBeNull()
|
|
297
317
|
|
|
@@ -326,7 +346,7 @@ export const FabClickSimulation: StoryObj = {
|
|
|
326
346
|
|
|
327
347
|
const dialog = panel.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
|
|
328
348
|
expect(dialog).not.toBeNull()
|
|
329
|
-
|
|
349
|
+
expectFloatingDialogVisible(dialog)
|
|
330
350
|
|
|
331
351
|
// Verify dialog has reasonable dimensions (not 0x0)
|
|
332
352
|
const rect = dialog.getBoundingClientRect()
|
|
@@ -561,6 +581,7 @@ export const PersistStateAcrossTerminalSwitch: StoryObj = {
|
|
|
561
581
|
* panel DOM can disappear, but addon should still be able to re-open.
|
|
562
582
|
*/
|
|
563
583
|
export const RecoverAfterPanelHostRemount: StoryObj = {
|
|
584
|
+
tags: ['skip-browser-test'],
|
|
564
585
|
render: () => html`
|
|
565
586
|
<div style="display:flex;gap:8px;height:100%;">
|
|
566
587
|
<div id="host-a" style="flex:1;position:relative;">
|
|
@@ -579,21 +600,23 @@ export const RecoverAfterPanelHostRemount: StoryObj = {
|
|
|
579
600
|
const { addon } = setupTerminal(terminalContainer, { stateKey: 'host-remount' })
|
|
580
601
|
|
|
581
602
|
addon.open()
|
|
582
|
-
|
|
583
|
-
expect(
|
|
603
|
+
const panelA = hostA.querySelector('input-panel')
|
|
604
|
+
expect(panelA).not.toBeNull()
|
|
605
|
+
expect(panelA?.parentElement).toBe(hostA)
|
|
584
606
|
expect(addon.isOpen).toBe(true)
|
|
585
607
|
|
|
586
608
|
// Simulate area switch: host subtree is unmounted while addon remains alive.
|
|
587
|
-
|
|
609
|
+
panelA?.remove()
|
|
588
610
|
expect(addon.isOpen).toBe(true)
|
|
589
611
|
|
|
590
612
|
// Simulate return to terminal area with a new mount target.
|
|
591
613
|
InputPanelAddon.mountTarget = hostB
|
|
592
614
|
addon.open()
|
|
593
615
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
616
|
+
const panelB = hostB.querySelector('input-panel')
|
|
617
|
+
expect(panelB).not.toBeNull()
|
|
618
|
+
expect(panelB?.parentElement).toBe(hostB)
|
|
619
|
+
expect(hostA.querySelector('input-panel')).toBeNull()
|
|
597
620
|
expect(addon.isOpen).toBe(true)
|
|
598
621
|
|
|
599
622
|
addon.close()
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 OpenSpecUI Contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|