@in-the-loop-labs/pair-review 1.4.4 → 1.5.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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin/skills/review-requests/SKILL.md +54 -0
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/pr.css +1081 -54
  6. package/public/css/repo-settings.css +452 -140
  7. package/public/js/components/AdvancedConfigTab.js +1364 -0
  8. package/public/js/components/AnalysisConfigModal.js +488 -112
  9. package/public/js/components/CouncilProgressModal.js +1416 -0
  10. package/public/js/components/TextInputDialog.js +231 -0
  11. package/public/js/components/TimeoutSelect.js +367 -0
  12. package/public/js/components/VoiceCentricConfigTab.js +1334 -0
  13. package/public/js/local.js +162 -83
  14. package/public/js/modules/analysis-history.js +185 -11
  15. package/public/js/modules/comment-manager.js +13 -0
  16. package/public/js/modules/file-comment-manager.js +28 -0
  17. package/public/js/pr.js +233 -115
  18. package/public/js/repo-settings.js +575 -106
  19. package/public/local.html +11 -1
  20. package/public/pr.html +6 -1
  21. package/public/repo-settings.html +28 -21
  22. package/public/setup.html +8 -2
  23. package/src/ai/analyzer.js +1262 -111
  24. package/src/ai/claude-cli.js +2 -2
  25. package/src/ai/claude-provider.js +6 -6
  26. package/src/ai/codex-provider.js +6 -6
  27. package/src/ai/copilot-provider.js +3 -3
  28. package/src/ai/cursor-agent-provider.js +6 -6
  29. package/src/ai/gemini-provider.js +6 -6
  30. package/src/ai/opencode-provider.js +6 -6
  31. package/src/ai/pi-provider.js +6 -6
  32. package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
  33. package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
  34. package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
  35. package/src/ai/prompts/config.js +1 -1
  36. package/src/ai/prompts/index.js +26 -2
  37. package/src/ai/provider.js +4 -2
  38. package/src/database.js +417 -14
  39. package/src/main.js +1 -1
  40. package/src/routes/analysis.js +495 -10
  41. package/src/routes/config.js +36 -15
  42. package/src/routes/councils.js +351 -0
  43. package/src/routes/local.js +33 -11
  44. package/src/routes/mcp.js +9 -2
  45. package/src/routes/setup.js +12 -2
  46. package/src/routes/shared.js +126 -13
  47. package/src/server.js +34 -4
  48. package/src/utils/stats-calculator.js +2 -0
@@ -0,0 +1,231 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Generic Text Input Dialog Component
4
+ * Displays a modal dialog with a text input field and customizable actions.
5
+ * Returns a Promise that resolves to the trimmed input string, or null if cancelled.
6
+ */
7
+ class TextInputDialog {
8
+ constructor() {
9
+ this.modal = null;
10
+ this.isVisible = false;
11
+ this.resolvePromise = null;
12
+ this.createModal();
13
+ this.setupEventListeners();
14
+ }
15
+
16
+ /**
17
+ * Create the modal DOM structure
18
+ */
19
+ createModal() {
20
+ // Remove existing modal if it exists
21
+ const existing = document.getElementById('text-input-dialog');
22
+ if (existing) {
23
+ existing.remove();
24
+ }
25
+
26
+ // Create modal container
27
+ const modalContainer = document.createElement('div');
28
+ modalContainer.id = 'text-input-dialog';
29
+ modalContainer.className = 'modal-overlay';
30
+ modalContainer.style.display = 'none';
31
+
32
+ modalContainer.innerHTML = `
33
+ <div class="modal-backdrop" data-action="cancel"></div>
34
+ <div class="modal-container confirm-dialog-container" style="width: 400px; height: auto;">
35
+ <div class="modal-header">
36
+ <h3 id="text-input-dialog-title">Input</h3>
37
+ <button class="modal-close-btn" data-action="cancel" title="Close">
38
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
39
+ <path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/>
40
+ </svg>
41
+ </button>
42
+ </div>
43
+
44
+ <div class="modal-body">
45
+ <div class="text-input-dialog-field">
46
+ <label for="text-input-dialog-input" id="text-input-dialog-label"></label>
47
+ <input type="text" id="text-input-dialog-input" class="form-control" placeholder="" />
48
+ </div>
49
+ </div>
50
+
51
+ <div class="modal-footer">
52
+ <button class="btn btn-secondary" data-action="cancel">Cancel</button>
53
+ <button class="btn btn-primary" id="text-input-dialog-btn" data-action="confirm">
54
+ Save
55
+ </button>
56
+ </div>
57
+ </div>
58
+ `;
59
+
60
+ document.body.appendChild(modalContainer);
61
+ this.modal = modalContainer;
62
+ }
63
+
64
+ /**
65
+ * Setup event listeners
66
+ */
67
+ setupEventListeners() {
68
+ // Use event delegation for click events
69
+ this.modal.addEventListener('click', (e) => {
70
+ const action = e.target.closest('[data-action]')?.dataset.action;
71
+ if (action === 'confirm') {
72
+ this.handleConfirm();
73
+ } else if (action === 'cancel') {
74
+ this.handleCancel();
75
+ }
76
+ });
77
+
78
+ // Handle keyboard shortcuts
79
+ this.keyHandler = (e) => {
80
+ if (!this.isVisible) return;
81
+ // Don't intercept keys when another dialog is on top
82
+ if (window.confirmDialog?.isVisible) return;
83
+
84
+ if (e.key === 'Escape') {
85
+ e.preventDefault();
86
+ this.handleCancel();
87
+ } else if (e.key === 'Enter') {
88
+ const input = this.modal.querySelector('#text-input-dialog-input');
89
+ if (input && input.value.trim()) {
90
+ e.preventDefault();
91
+ this.handleConfirm();
92
+ }
93
+ }
94
+ };
95
+ document.addEventListener('keydown', this.keyHandler);
96
+
97
+ // Listen for input changes to enable/disable confirm button
98
+ const input = this.modal.querySelector('#text-input-dialog-input');
99
+ if (input) {
100
+ input.addEventListener('input', () => {
101
+ this._updateConfirmButton(input.value);
102
+ });
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Update the confirm button enabled/disabled state based on input value
108
+ * @param {string} value - Current input value
109
+ */
110
+ _updateConfirmButton(value) {
111
+ const confirmBtn = this.modal.querySelector('#text-input-dialog-btn');
112
+ if (confirmBtn) {
113
+ confirmBtn.disabled = !value.trim();
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Show the text input dialog
119
+ * @param {Object} options - Configuration options
120
+ * @param {string} options.title - Dialog title (default: "Input")
121
+ * @param {string} options.label - Label text above the input (default: "")
122
+ * @param {string} options.placeholder - Input placeholder text (default: "")
123
+ * @param {string} options.value - Initial input value (default: "")
124
+ * @param {string} options.confirmText - Confirm button text (default: "Save")
125
+ * @param {string} options.confirmClass - Confirm button CSS class (default: "btn-primary")
126
+ * @returns {Promise<string|null>} Promise that resolves to trimmed input string, or null if cancelled
127
+ */
128
+ show(options = {}) {
129
+ if (!this.modal) return Promise.resolve(null);
130
+
131
+ return new Promise((resolve) => {
132
+ // Cancel any previous caller's promise to prevent dangling awaits
133
+ if (this.resolvePromise) {
134
+ this.resolvePromise(null);
135
+ }
136
+ this.resolvePromise = resolve;
137
+
138
+ // Set title
139
+ const titleElement = this.modal.querySelector('#text-input-dialog-title');
140
+ if (titleElement) {
141
+ titleElement.textContent = options.title || 'Input';
142
+ }
143
+
144
+ // Set label
145
+ const labelElement = this.modal.querySelector('#text-input-dialog-label');
146
+ if (labelElement) {
147
+ const labelText = options.label || '';
148
+ labelElement.textContent = labelText;
149
+ labelElement.style.display = labelText ? '' : 'none';
150
+ }
151
+
152
+ // Set input value and placeholder
153
+ const input = this.modal.querySelector('#text-input-dialog-input');
154
+ if (input) {
155
+ input.value = options.value || '';
156
+ input.placeholder = options.placeholder || '';
157
+ }
158
+
159
+ // Set confirm button text and style
160
+ const confirmBtn = this.modal.querySelector('#text-input-dialog-btn');
161
+ if (confirmBtn) {
162
+ confirmBtn.textContent = options.confirmText || 'Save';
163
+ // Remove previous style classes and add new one
164
+ confirmBtn.classList.remove('btn-primary', 'btn-secondary', 'btn-danger', 'btn-warning');
165
+ const confirmClass = options.confirmClass || 'btn-primary';
166
+ confirmBtn.classList.add(confirmClass);
167
+ }
168
+
169
+ // Update confirm button state based on initial value
170
+ this._updateConfirmButton(options.value || '');
171
+
172
+ // Show modal
173
+ this.modal.style.display = 'flex';
174
+ this.isVisible = true;
175
+
176
+ // Focus and select all text in input
177
+ if (input) {
178
+ requestAnimationFrame(() => {
179
+ input.focus();
180
+ input.select();
181
+ });
182
+ }
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Handle confirm action
188
+ */
189
+ handleConfirm() {
190
+ const input = this.modal.querySelector('#text-input-dialog-input');
191
+ const value = input ? input.value.trim() : '';
192
+ if (!value) return;
193
+
194
+ if (this.resolvePromise) {
195
+ this.resolvePromise(value);
196
+ }
197
+ this.hide();
198
+ }
199
+
200
+ /**
201
+ * Handle cancel action
202
+ */
203
+ handleCancel() {
204
+ if (this.resolvePromise) {
205
+ this.resolvePromise(null);
206
+ }
207
+ this.hide();
208
+ }
209
+
210
+ /**
211
+ * Hide the modal
212
+ */
213
+ hide() {
214
+ if (!this.modal) return;
215
+
216
+ this.modal.style.display = 'none';
217
+ this.isVisible = false;
218
+ this.resolvePromise = null;
219
+ }
220
+ }
221
+
222
+ // Initialize global instance
223
+ if (typeof window !== 'undefined' && !window.textInputDialog) {
224
+ if (document.readyState === 'loading') {
225
+ document.addEventListener('DOMContentLoaded', () => {
226
+ window.textInputDialog = new TextInputDialog();
227
+ });
228
+ } else {
229
+ window.textInputDialog = new TextInputDialog();
230
+ }
231
+ }
@@ -0,0 +1,367 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * TimeoutSelect - Custom styled dropdown selector for timeout controls.
4
+ *
5
+ * Replaces native <select> elements with a compact pill-style dropdown that
6
+ * supports both light and dark themes. Designed to sit inline with the clock
7
+ * icon toggle button in council configuration tabs.
8
+ *
9
+ * Usage:
10
+ * // Using the static factory (preferred — handles mount-point replacement):
11
+ * const ts = TimeoutSelect.mount(mountSpan, {
12
+ * className: 'adv-timeout',
13
+ * id: 'adv-orchestration-timeout',
14
+ * title: 'Orchestration timeout',
15
+ * });
16
+ *
17
+ * // Using the constructor directly (caller places ts.el in the DOM):
18
+ * const ts = new TimeoutSelect({
19
+ * options: TimeoutSelect.TIMEOUT_OPTIONS,
20
+ * className: 'adv-timeout',
21
+ * datasets: { level: '1', index: '0' },
22
+ * id: 'adv-orchestration-timeout',
23
+ * title: 'Per-reviewer timeout',
24
+ * });
25
+ * someParent.appendChild(ts.el);
26
+ *
27
+ * ts.value; // get current value
28
+ * ts.value = '900000' // set value programmatically
29
+ * ts.show() / ts.hide() / ts.toggle()
30
+ * ts.el // root DOM element
31
+ * ts.destroy() // clean up listeners
32
+ */
33
+ class TimeoutSelect {
34
+ /** Default timeout options shared across all tabs */
35
+ static TIMEOUT_OPTIONS = [
36
+ { value: '300000', label: '5m' },
37
+ { value: '600000', label: '10m', selected: true },
38
+ { value: '900000', label: '15m' },
39
+ { value: '1800000', label: '30m' },
40
+ ];
41
+
42
+ /**
43
+ * Static factory that creates a TimeoutSelect and replaces a mount-point
44
+ * element in the DOM. The mount element's data-* attributes are copied to
45
+ * the new component.
46
+ *
47
+ * @param {HTMLElement} mountEl - The placeholder <span> to replace
48
+ * @param {Object} [opts] - Extra options forwarded to the constructor
49
+ * (className, id, title). `options` defaults to TIMEOUT_OPTIONS and
50
+ * `datasets` is read from mountEl.dataset automatically.
51
+ * @returns {TimeoutSelect} The created instance (already in the DOM)
52
+ */
53
+ static mount(mountEl, opts = {}) {
54
+ const parent = mountEl.parentNode;
55
+ const ts = new TimeoutSelect({
56
+ options: (opts.options || TimeoutSelect.TIMEOUT_OPTIONS).map(o => ({ ...o })),
57
+ className: opts.className || '',
58
+ datasets: { ...mountEl.dataset },
59
+ id: opts.id || '',
60
+ title: opts.title || '',
61
+ });
62
+ parent.insertBefore(ts.el, mountEl);
63
+ parent.removeChild(mountEl);
64
+ return ts;
65
+ }
66
+
67
+ /**
68
+ * @param {Object} opts
69
+ * @param {Array<{value: string, label: string, selected?: boolean}>} [opts.options]
70
+ * Defaults to TimeoutSelect.TIMEOUT_OPTIONS.
71
+ * @param {string} [opts.className] - Extra CSS class(es) for the root element
72
+ * @param {Object} [opts.datasets] - data-* attributes to set on the root element
73
+ * @param {string} [opts.id] - Optional id attribute
74
+ * @param {string} [opts.title] - Optional title (tooltip)
75
+ */
76
+ constructor(opts = {}) {
77
+ this._options = opts.options || TimeoutSelect.TIMEOUT_OPTIONS;
78
+ this._className = opts.className || '';
79
+ this._datasets = opts.datasets || {};
80
+ this._id = opts.id || '';
81
+ this._title = opts.title || '';
82
+
83
+ // Determine initial selected value
84
+ const selectedOpt = this._options.find(o => o.selected) || this._options[0];
85
+ this._value = selectedOpt ? selectedOpt.value : '';
86
+
87
+ // Build DOM
88
+ this._buildDOM();
89
+
90
+ // Set data attributes
91
+ for (const [key, val] of Object.entries(this._datasets)) {
92
+ this.el.dataset[key] = val;
93
+ }
94
+ if (this._id) this.el.id = this._id;
95
+ if (this._title) this.el.title = this._title;
96
+
97
+ // Expose value on the DOM element itself so delegated change handlers
98
+ // can read e.target.value just like a native <select>.
99
+ const self = this;
100
+ Object.defineProperty(this.el, 'value', {
101
+ get() { return self._value; },
102
+ set(v) { self._setValue(String(v)); },
103
+ configurable: true,
104
+ });
105
+
106
+ // Hidden by default (matches old <select style="display:none">)
107
+ this.el.style.display = 'none';
108
+
109
+ // Bind event handlers (stored for cleanup)
110
+ this._onTriggerClick = this._handleTriggerClick.bind(this);
111
+ this._onDocumentClick = this._handleDocumentClick.bind(this);
112
+ this._onKeyDown = this._handleKeyDown.bind(this);
113
+ this._onMenuClick = this._handleMenuClick.bind(this);
114
+
115
+ this._trigger.addEventListener('click', this._onTriggerClick);
116
+ this._menu.addEventListener('click', this._onMenuClick);
117
+ this.el.addEventListener('keydown', this._onKeyDown);
118
+
119
+ this._isOpen = false;
120
+ }
121
+
122
+ /** Build the custom dropdown DOM structure */
123
+ _buildDOM() {
124
+ // Root container
125
+ this.el = document.createElement('div');
126
+ this.el.className = `timeout-select ${this._className}`.trim();
127
+
128
+ // Trigger button (shows current value)
129
+ this._trigger = document.createElement('button');
130
+ this._trigger.type = 'button';
131
+ this._trigger.className = 'timeout-select-trigger';
132
+ this._trigger.setAttribute('aria-haspopup', 'listbox');
133
+ this._trigger.setAttribute('aria-expanded', 'false');
134
+
135
+ this._triggerLabel = document.createElement('span');
136
+ this._triggerLabel.className = 'timeout-select-label';
137
+ this._triggerLabel.textContent = this._getLabelForValue(this._value);
138
+
139
+ this._triggerCaret = document.createElement('svg');
140
+ this._triggerCaret.className = 'timeout-select-caret';
141
+ this._triggerCaret.setAttribute('width', '10');
142
+ this._triggerCaret.setAttribute('height', '10');
143
+ this._triggerCaret.setAttribute('viewBox', '0 0 12 12');
144
+ this._triggerCaret.setAttribute('fill', 'none');
145
+ this._triggerCaret.innerHTML = '<path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>';
146
+
147
+ this._trigger.appendChild(this._triggerLabel);
148
+ this._trigger.appendChild(this._triggerCaret);
149
+
150
+ // Dropdown menu
151
+ this._menu = document.createElement('div');
152
+ this._menu.className = 'timeout-select-menu';
153
+ this._menu.setAttribute('role', 'listbox');
154
+
155
+ for (const opt of this._options) {
156
+ const item = document.createElement('button');
157
+ item.type = 'button';
158
+ item.className = 'timeout-select-option';
159
+ item.setAttribute('role', 'option');
160
+ item.setAttribute('data-value', opt.value);
161
+ item.textContent = opt.label;
162
+ if (opt.value === this._value) {
163
+ item.classList.add('selected');
164
+ item.setAttribute('aria-selected', 'true');
165
+ }
166
+ this._menu.appendChild(item);
167
+ }
168
+
169
+ this.el.appendChild(this._trigger);
170
+ this.el.appendChild(this._menu);
171
+ }
172
+
173
+ /** Get the display label for a value */
174
+ _getLabelForValue(value) {
175
+ const opt = this._options.find(o => o.value === value);
176
+ return opt ? opt.label : value;
177
+ }
178
+
179
+ /** Handle trigger click */
180
+ _handleTriggerClick(e) {
181
+ e.preventDefault();
182
+ e.stopPropagation();
183
+ if (this._isOpen) {
184
+ this._close();
185
+ } else {
186
+ this._open();
187
+ }
188
+ }
189
+
190
+ /** Handle document click to close when clicking outside */
191
+ _handleDocumentClick(e) {
192
+ if (!this.el.contains(e.target)) {
193
+ this._close();
194
+ }
195
+ }
196
+
197
+ /** Handle menu item click */
198
+ _handleMenuClick(e) {
199
+ const item = e.target.closest('.timeout-select-option');
200
+ if (!item) return;
201
+
202
+ e.preventDefault();
203
+ e.stopPropagation();
204
+
205
+ const newValue = item.getAttribute('data-value');
206
+ if (newValue !== this._value) {
207
+ this._setValue(newValue);
208
+
209
+ // Fire a change event that bubbles, so parent listeners can catch it
210
+ const event = new Event('change', { bubbles: true });
211
+ this.el.dispatchEvent(event);
212
+ }
213
+
214
+ this._close();
215
+ }
216
+
217
+ /** Handle keyboard navigation */
218
+ _handleKeyDown(e) {
219
+ if (!this._isOpen) {
220
+ // Open on Enter, Space, ArrowDown, ArrowUp
221
+ if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
222
+ e.preventDefault();
223
+ this._open();
224
+ }
225
+ return;
226
+ }
227
+
228
+ const items = Array.from(this._menu.querySelectorAll('.timeout-select-option'));
229
+ const currentIdx = items.findIndex(item => item.classList.contains('focused'));
230
+
231
+ switch (e.key) {
232
+ case 'ArrowDown': {
233
+ e.preventDefault();
234
+ const nextIdx = currentIdx < items.length - 1 ? currentIdx + 1 : 0;
235
+ this._focusItem(items, nextIdx);
236
+ break;
237
+ }
238
+ case 'ArrowUp': {
239
+ e.preventDefault();
240
+ const prevIdx = currentIdx > 0 ? currentIdx - 1 : items.length - 1;
241
+ this._focusItem(items, prevIdx);
242
+ break;
243
+ }
244
+ case 'Enter':
245
+ case ' ': {
246
+ e.preventDefault();
247
+ if (currentIdx >= 0) {
248
+ items[currentIdx].click();
249
+ }
250
+ break;
251
+ }
252
+ case 'Escape': {
253
+ e.preventDefault();
254
+ this._close();
255
+ this._trigger.focus();
256
+ break;
257
+ }
258
+ }
259
+ }
260
+
261
+ /** Focus a menu item by index */
262
+ _focusItem(items, idx) {
263
+ items.forEach(item => item.classList.remove('focused'));
264
+ if (items[idx]) {
265
+ items[idx].classList.add('focused');
266
+ items[idx].scrollIntoView({ block: 'nearest' });
267
+ }
268
+ }
269
+
270
+ /** Open the dropdown */
271
+ _open() {
272
+ this._isOpen = true;
273
+ this.el.classList.add('open');
274
+ this._trigger.setAttribute('aria-expanded', 'true');
275
+
276
+ // Register document click listener lazily to avoid leaks when the
277
+ // component is removed from the DOM without calling destroy().
278
+ document.addEventListener('click', this._onDocumentClick, true);
279
+
280
+ // Focus the currently selected item
281
+ const items = Array.from(this._menu.querySelectorAll('.timeout-select-option'));
282
+ const selectedIdx = items.findIndex(item => item.classList.contains('selected'));
283
+ this._focusItem(items, selectedIdx >= 0 ? selectedIdx : 0);
284
+ }
285
+
286
+ /** Close the dropdown */
287
+ _close() {
288
+ this._isOpen = false;
289
+ this.el.classList.remove('open');
290
+ this._trigger.setAttribute('aria-expanded', 'false');
291
+
292
+ // Remove the lazily-registered document listener
293
+ document.removeEventListener('click', this._onDocumentClick, true);
294
+
295
+ // Clear focus state
296
+ this._menu.querySelectorAll('.timeout-select-option').forEach(item => {
297
+ item.classList.remove('focused');
298
+ });
299
+ }
300
+
301
+ /** Set value internally and update DOM */
302
+ _setValue(newValue) {
303
+ this._value = newValue;
304
+ this._triggerLabel.textContent = this._getLabelForValue(newValue);
305
+
306
+ // Update selected state in menu
307
+ this._menu.querySelectorAll('.timeout-select-option').forEach(item => {
308
+ const isSelected = item.getAttribute('data-value') === newValue;
309
+ item.classList.toggle('selected', isSelected);
310
+ item.setAttribute('aria-selected', isSelected ? 'true' : 'false');
311
+ });
312
+ }
313
+
314
+ // --- Public API ---
315
+
316
+ /** Get the current value */
317
+ get value() {
318
+ return this._value;
319
+ }
320
+
321
+ /** Set the current value (does NOT fire a change event) */
322
+ set value(newValue) {
323
+ this._setValue(String(newValue));
324
+ }
325
+
326
+ /** Show the component (sets display to inline-flex) */
327
+ show() {
328
+ this.el.style.display = '';
329
+ }
330
+
331
+ /** Hide the component */
332
+ hide() {
333
+ this.el.style.display = 'none';
334
+ if (this._isOpen) this._close();
335
+ }
336
+
337
+ /** Toggle visibility */
338
+ toggle() {
339
+ if (this.el.style.display === 'none') {
340
+ this.show();
341
+ } else {
342
+ this.hide();
343
+ }
344
+ }
345
+
346
+ /** Whether the component is currently visible */
347
+ get isVisible() {
348
+ return this.el.style.display !== 'none';
349
+ }
350
+
351
+ /** Destroy the component and remove listeners */
352
+ destroy() {
353
+ this._trigger.removeEventListener('click', this._onTriggerClick);
354
+ this._menu.removeEventListener('click', this._onMenuClick);
355
+ document.removeEventListener('click', this._onDocumentClick, true);
356
+ this.el.removeEventListener('keydown', this._onKeyDown);
357
+
358
+ if (this.el.parentNode) {
359
+ this.el.parentNode.removeChild(this.el);
360
+ }
361
+ }
362
+ }
363
+
364
+ // Export for use in other modules
365
+ if (typeof window !== 'undefined') {
366
+ window.TimeoutSelect = TimeoutSelect;
367
+ }