xterm-input-panel 1.0.0

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.
@@ -0,0 +1,245 @@
1
+ import { LitElement, css, html } from 'lit'
2
+
3
+ const SETTINGS_KEY = 'xtermInputPanelSettings'
4
+
5
+ function mergeSettings(updates: Record<string, unknown>) {
6
+ try {
7
+ const existing = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}')
8
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...existing, ...updates }))
9
+ } catch {
10
+ /* ignore */
11
+ }
12
+ }
13
+
14
+ /**
15
+ * InputPanel settings — height/width sliders and vibration intensity.
16
+ *
17
+ * Dispatches `input-panel:settings-change` CustomEvent with updated values.
18
+ */
19
+ export class InputPanelSettings extends LitElement {
20
+ static get properties() {
21
+ return {
22
+ fixedHeight: { type: Number, attribute: 'fixed-height' },
23
+ floatingWidth: { type: Number, attribute: 'floating-width' },
24
+ floatingHeight: { type: Number, attribute: 'floating-height' },
25
+ vibrationIntensity: { type: Number, attribute: 'vibration-intensity' },
26
+ historyLimit: { type: Number, attribute: 'history-limit' },
27
+ visible: { type: Boolean },
28
+ }
29
+ }
30
+
31
+ static styles = css`
32
+ :host {
33
+ display: block;
34
+ height: 100%;
35
+ min-height: 0;
36
+ }
37
+
38
+ .overlay {
39
+ display: none;
40
+ position: relative;
41
+ height: 100%;
42
+ min-height: 0;
43
+ background: var(--terminal, #1a1a1a);
44
+ flex-direction: column;
45
+ padding: 12px;
46
+ gap: 16px;
47
+ overflow-y: auto;
48
+ }
49
+
50
+ :host([visible]) .overlay {
51
+ display: flex;
52
+ }
53
+
54
+ .setting {
55
+ display: flex;
56
+ flex-direction: column;
57
+ gap: 6px;
58
+ }
59
+
60
+ .setting-label {
61
+ font-size: 11px;
62
+ color: var(--muted-foreground, #888);
63
+ display: flex;
64
+ justify-content: space-between;
65
+ }
66
+
67
+ .setting-value {
68
+ color: var(--foreground, #fff);
69
+ font-weight: 600;
70
+ }
71
+
72
+ input[type='range'] {
73
+ width: 100%;
74
+ accent-color: var(--primary, #e04a2f);
75
+ }
76
+ `
77
+
78
+ declare fixedHeight: number
79
+ declare floatingWidth: number
80
+ declare floatingHeight: number
81
+ declare vibrationIntensity: number
82
+ declare historyLimit: number
83
+ declare visible: boolean
84
+
85
+ constructor() {
86
+ super()
87
+ this.fixedHeight = 250
88
+ this.floatingWidth = 60
89
+ this.floatingHeight = 30
90
+ this.vibrationIntensity = 50
91
+ this.historyLimit = 50
92
+ this.visible = false
93
+ }
94
+
95
+ connectedCallback() {
96
+ super.connectedCallback()
97
+ try {
98
+ const raw = localStorage.getItem(SETTINGS_KEY)
99
+ if (raw) {
100
+ const data = JSON.parse(raw)
101
+ if (typeof data.fixedHeight === 'number') this.fixedHeight = data.fixedHeight
102
+ if (typeof data.floatingWidth === 'number') this.floatingWidth = data.floatingWidth
103
+ if (typeof data.floatingHeight === 'number') {
104
+ // Backward compat: if > 100, treat as px and convert to vh%
105
+ if (data.floatingHeight > 100) {
106
+ this.floatingHeight = Math.round((data.floatingHeight / window.innerHeight) * 100)
107
+ } else {
108
+ this.floatingHeight = data.floatingHeight
109
+ }
110
+ }
111
+ if (typeof data.vibrationIntensity === 'number')
112
+ this.vibrationIntensity = data.vibrationIntensity
113
+ if (typeof data.historyLimit === 'number') this.historyLimit = data.historyLimit
114
+ }
115
+ } catch {
116
+ /* ignore */
117
+ }
118
+ }
119
+
120
+ private _emit() {
121
+ mergeSettings({
122
+ fixedHeight: this.fixedHeight,
123
+ floatingWidth: this.floatingWidth,
124
+ floatingHeight: this.floatingHeight,
125
+ vibrationIntensity: this.vibrationIntensity,
126
+ historyLimit: this.historyLimit,
127
+ })
128
+ this.dispatchEvent(
129
+ new CustomEvent('input-panel:settings-change', {
130
+ detail: {
131
+ fixedHeight: this.fixedHeight,
132
+ floatingWidth: this.floatingWidth,
133
+ floatingHeight: this.floatingHeight,
134
+ vibrationIntensity: this.vibrationIntensity,
135
+ historyLimit: this.historyLimit,
136
+ },
137
+ bubbles: true,
138
+ composed: true,
139
+ })
140
+ )
141
+ }
142
+
143
+ private _onFixedHeight(e: Event) {
144
+ this.fixedHeight = Number((e.target as HTMLInputElement).value)
145
+ this._emit()
146
+ }
147
+
148
+ private _onFloatingWidth(e: Event) {
149
+ this.floatingWidth = Number((e.target as HTMLInputElement).value)
150
+ this._emit()
151
+ }
152
+
153
+ private _onFloatingHeight(e: Event) {
154
+ this.floatingHeight = Number((e.target as HTMLInputElement).value)
155
+ this._emit()
156
+ }
157
+
158
+ private _onVibration(e: Event) {
159
+ this.vibrationIntensity = Number((e.target as HTMLInputElement).value)
160
+ this._emit()
161
+ }
162
+
163
+ private _onHistoryLimit(e: Event) {
164
+ this.historyLimit = Number((e.target as HTMLInputElement).value)
165
+ this._emit()
166
+ }
167
+
168
+ render() {
169
+ return html`
170
+ <div class="overlay" part="settings-overlay">
171
+ <div class="setting">
172
+ <label class="setting-label">
173
+ Fixed mode height
174
+ <span class="setting-value">${this.fixedHeight}px</span>
175
+ </label>
176
+ <input
177
+ type="range"
178
+ min="150"
179
+ max="500"
180
+ .value=${String(this.fixedHeight)}
181
+ @input=${this._onFixedHeight}
182
+ />
183
+ </div>
184
+
185
+ <div class="setting">
186
+ <label class="setting-label">
187
+ Floating mode width
188
+ <span class="setting-value">${this.floatingWidth}%</span>
189
+ </label>
190
+ <input
191
+ type="range"
192
+ min="20"
193
+ max="95"
194
+ .value=${String(this.floatingWidth)}
195
+ @input=${this._onFloatingWidth}
196
+ />
197
+ </div>
198
+
199
+ <div class="setting">
200
+ <label class="setting-label">
201
+ Floating mode height
202
+ <span class="setting-value">${this.floatingHeight}%</span>
203
+ </label>
204
+ <input
205
+ type="range"
206
+ min="15"
207
+ max="85"
208
+ .value=${String(this.floatingHeight)}
209
+ @input=${this._onFloatingHeight}
210
+ />
211
+ </div>
212
+
213
+ <div class="setting">
214
+ <label class="setting-label">
215
+ Vibration intensity
216
+ <span class="setting-value">${this.vibrationIntensity}%</span>
217
+ </label>
218
+ <input
219
+ type="range"
220
+ min="0"
221
+ max="100"
222
+ .value=${String(this.vibrationIntensity)}
223
+ @input=${this._onVibration}
224
+ />
225
+ </div>
226
+
227
+ <div class="setting">
228
+ <label class="setting-label">
229
+ History limit
230
+ <span class="setting-value">${this.historyLimit}</span>
231
+ </label>
232
+ <input
233
+ type="range"
234
+ min="1"
235
+ max="1000"
236
+ .value=${String(this.historyLimit)}
237
+ @input=${this._onHistoryLimit}
238
+ />
239
+ </div>
240
+ </div>
241
+ `
242
+ }
243
+ }
244
+
245
+ customElements.define('input-panel-settings', InputPanelSettings)
@@ -0,0 +1,241 @@
1
+ import type { Meta, StoryObj } from '@storybook/web-components-vite'
2
+ import type { LitElement } from 'lit'
3
+ import { html } from 'lit'
4
+ import { expect, fn } from 'storybook/test'
5
+
6
+ // Register all custom elements
7
+ import './index.js'
8
+
9
+ /** Helper to get a Lit element and wait for it to be ready */
10
+ async function getLitElement(container: HTMLElement, selector: string) {
11
+ const el = container.querySelector(selector) as LitElement
12
+ await el.updateComplete
13
+ return el
14
+ }
15
+
16
+ const meta: Meta = {
17
+ title: 'InputPanel',
18
+ tags: ['autodocs'],
19
+ decorators: [
20
+ (story) => html`
21
+ <div
22
+ style="width: 400px; height: 300px; background: #1a1a1a; color: #fff; font-family: monospace;"
23
+ >
24
+ ${story()}
25
+ </div>
26
+ `,
27
+ ],
28
+ }
29
+
30
+ export default meta
31
+
32
+ /**
33
+ * The main InputPanel container with toolbar tabs and content area.
34
+ * Default tab is "input" (Input Method).
35
+ */
36
+ export const Default: StoryObj = {
37
+ render: () => html`
38
+ <input-panel layout="fixed" style="height: 100%;">
39
+ <input-method-tab slot="input"></input-method-tab>
40
+ <virtual-keyboard-tab slot="keys"></virtual-keyboard-tab>
41
+ <shortcut-tab slot="shortcuts"></shortcut-tab>
42
+ <virtual-trackpad-tab slot="trackpad"></virtual-trackpad-tab>
43
+ </input-panel>
44
+ `,
45
+ }
46
+
47
+ /**
48
+ * InputPanel in floating layout mode (renders as dialog).
49
+ */
50
+ export const FloatingLayout: StoryObj = {
51
+ render: () => html`
52
+ <input-panel layout="floating" style="height: 100%;">
53
+ <input-method-tab slot="input"></input-method-tab>
54
+ <virtual-keyboard-tab slot="keys" floating></virtual-keyboard-tab>
55
+ <shortcut-tab slot="shortcuts"></shortcut-tab>
56
+ <virtual-trackpad-tab slot="trackpad" floating></virtual-trackpad-tab>
57
+ </input-panel>
58
+ `,
59
+ }
60
+
61
+ /**
62
+ * InputPanel starts on the "keys" tab (Virtual Keyboard).
63
+ */
64
+ export const KeysTab: StoryObj = {
65
+ render: () => html`
66
+ <input-panel layout="fixed" active-tab="keys" style="height: 100%;">
67
+ <input-method-tab slot="input"></input-method-tab>
68
+ <virtual-keyboard-tab slot="keys"></virtual-keyboard-tab>
69
+ <shortcut-tab slot="shortcuts"></shortcut-tab>
70
+ <virtual-trackpad-tab slot="trackpad"></virtual-trackpad-tab>
71
+ </input-panel>
72
+ `,
73
+ }
74
+
75
+ /**
76
+ * InputPanel starts on the "trackpad" tab (Virtual Trackpad).
77
+ */
78
+ export const TrackpadTab: StoryObj = {
79
+ render: () => html`
80
+ <input-panel layout="fixed" active-tab="trackpad" style="height: 100%;">
81
+ <input-method-tab slot="input"></input-method-tab>
82
+ <virtual-keyboard-tab slot="keys"></virtual-keyboard-tab>
83
+ <shortcut-tab slot="shortcuts"></shortcut-tab>
84
+ <virtual-trackpad-tab slot="trackpad"></virtual-trackpad-tab>
85
+ </input-panel>
86
+ `,
87
+ }
88
+
89
+ /**
90
+ * InputPanel starts on the "settings" tab.
91
+ */
92
+ export const SettingsTab: StoryObj = {
93
+ render: () => html`
94
+ <input-panel layout="fixed" active-tab="settings" style="height: 100%;">
95
+ <input-method-tab slot="input"></input-method-tab>
96
+ <virtual-keyboard-tab slot="keys"></virtual-keyboard-tab>
97
+ <shortcut-tab slot="shortcuts"></shortcut-tab>
98
+ <virtual-trackpad-tab slot="trackpad"></virtual-trackpad-tab>
99
+ </input-panel>
100
+ `,
101
+ }
102
+
103
+ /**
104
+ * Verifies that tab switching works by clicking the "Keys" tab button.
105
+ * Now expects 5 tab buttons (Input, Keys, Shortcuts, Trackpad, Settings).
106
+ */
107
+ export const TabSwitching: StoryObj = {
108
+ render: () => html`
109
+ <input-panel layout="fixed" style="height: 100%;">
110
+ <input-method-tab slot="input"></input-method-tab>
111
+ <virtual-keyboard-tab slot="keys"></virtual-keyboard-tab>
112
+ <shortcut-tab slot="shortcuts"></shortcut-tab>
113
+ <virtual-trackpad-tab slot="trackpad"></virtual-trackpad-tab>
114
+ </input-panel>
115
+ `,
116
+ play: async ({ canvasElement }) => {
117
+ const panel = await getLitElement(canvasElement, 'input-panel')
118
+
119
+ const shadow = panel.shadowRoot!
120
+ const tabButtons = shadow.querySelectorAll('.tab-btn')
121
+ expect(tabButtons.length).toBe(5)
122
+
123
+ // Click "Keys" tab
124
+ const keysTab = tabButtons[1] as HTMLButtonElement
125
+ keysTab.click()
126
+ await panel.updateComplete
127
+
128
+ // Verify the active attribute changed
129
+ expect(keysTab.hasAttribute('data-active')).toBe(true)
130
+ },
131
+ }
132
+
133
+ /**
134
+ * Verifies that the close button dispatches the `input-panel:close` event.
135
+ */
136
+ export const CloseEvent: StoryObj = {
137
+ render: () => html`
138
+ <input-panel layout="fixed" style="height: 100%;">
139
+ <input-method-tab slot="input"></input-method-tab>
140
+ </input-panel>
141
+ `,
142
+ play: async ({ canvasElement }) => {
143
+ const panel = await getLitElement(canvasElement, 'input-panel')
144
+
145
+ const closeHandler = fn()
146
+ panel.addEventListener('input-panel:close', closeHandler)
147
+
148
+ const shadow = panel.shadowRoot!
149
+ const closeBtn = shadow.querySelector('.icon-btn:last-child') as HTMLButtonElement
150
+ closeBtn.click()
151
+
152
+ expect(closeHandler).toHaveBeenCalledTimes(1)
153
+ panel.removeEventListener('input-panel:close', closeHandler)
154
+ },
155
+ }
156
+
157
+ /**
158
+ * Verifies that the layout toggle dispatches the `input-panel:layout-change` event.
159
+ * Pin/float button is now icon-only (no text label).
160
+ */
161
+ export const LayoutToggle: StoryObj = {
162
+ render: () => html`
163
+ <input-panel layout="fixed" style="height: 100%;">
164
+ <input-method-tab slot="input"></input-method-tab>
165
+ </input-panel>
166
+ `,
167
+ play: async ({ canvasElement }) => {
168
+ const panel = await getLitElement(canvasElement, 'input-panel')
169
+
170
+ let receivedLayout = ''
171
+ panel.addEventListener('input-panel:layout-change', ((e: CustomEvent) => {
172
+ receivedLayout = e.detail.layout
173
+ }) as EventListener)
174
+
175
+ const shadow = panel.shadowRoot!
176
+ // Pin/float is first icon-btn in action-group
177
+ const layoutBtn = shadow.querySelector('.action-group .icon-btn') as HTMLButtonElement
178
+
179
+ layoutBtn.click()
180
+ await panel.updateComplete
181
+
182
+ expect(receivedLayout).toBe('floating')
183
+ },
184
+ }
185
+
186
+ /**
187
+ * Floating layout with resize handles visible at the four corners.
188
+ */
189
+ export const FloatingResize: StoryObj = {
190
+ render: () => html`
191
+ <input-panel layout="floating" style="height: 100%;">
192
+ <input-method-tab slot="input"></input-method-tab>
193
+ <virtual-keyboard-tab slot="keys" floating></virtual-keyboard-tab>
194
+ <shortcut-tab slot="shortcuts"></shortcut-tab>
195
+ <virtual-trackpad-tab slot="trackpad" floating></virtual-trackpad-tab>
196
+ </input-panel>
197
+ `,
198
+ play: async ({ canvasElement }) => {
199
+ const panel = await getLitElement(canvasElement, 'input-panel')
200
+ const shadow = panel.shadowRoot!
201
+ const dialog = shadow.querySelector('.panel-dialog') as HTMLDialogElement
202
+ expect(dialog).toBeTruthy()
203
+
204
+ const handles = shadow.querySelectorAll('.resize-handle')
205
+ expect(handles.length).toBe(4)
206
+
207
+ // Verify each corner class exists
208
+ expect(shadow.querySelector('.resize-tl')).toBeTruthy()
209
+ expect(shadow.querySelector('.resize-tr')).toBeTruthy()
210
+ expect(shadow.querySelector('.resize-bl')).toBeTruthy()
211
+ expect(shadow.querySelector('.resize-br')).toBeTruthy()
212
+ },
213
+ }
214
+
215
+ /**
216
+ * Verifies Fixed mode height slider updates InputPanel internal style variable.
217
+ */
218
+ export const FixedHeightSync: StoryObj = {
219
+ render: () => html`
220
+ <input-panel layout="fixed" active-tab="settings">
221
+ <input-method-tab slot="input"></input-method-tab>
222
+ <virtual-keyboard-tab slot="keys"></virtual-keyboard-tab>
223
+ <shortcut-tab slot="shortcuts"></shortcut-tab>
224
+ <virtual-trackpad-tab slot="trackpad"></virtual-trackpad-tab>
225
+ </input-panel>
226
+ `,
227
+ play: async ({ canvasElement }) => {
228
+ const panel = await getLitElement(canvasElement, 'input-panel')
229
+ const settings = panel.shadowRoot?.querySelector('input-panel-settings') as LitElement
230
+ await settings.updateComplete
231
+
232
+ const slider = settings.shadowRoot?.querySelector('input[type="range"]') as HTMLInputElement
233
+ slider.value = '320'
234
+ slider.dispatchEvent(new Event('input', { bubbles: true }))
235
+
236
+ await settings.updateComplete
237
+ await panel.updateComplete
238
+
239
+ expect(panel.style.getPropertyValue('--input-panel-fixed-height').trim()).toBe('320px')
240
+ },
241
+ }