@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.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +54 -0
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +1081 -54
- package/public/css/repo-settings.css +452 -140
- package/public/js/components/AdvancedConfigTab.js +1364 -0
- package/public/js/components/AnalysisConfigModal.js +488 -112
- package/public/js/components/CouncilProgressModal.js +1416 -0
- package/public/js/components/TextInputDialog.js +231 -0
- package/public/js/components/TimeoutSelect.js +367 -0
- package/public/js/components/VoiceCentricConfigTab.js +1334 -0
- package/public/js/local.js +162 -83
- package/public/js/modules/analysis-history.js +185 -11
- package/public/js/modules/comment-manager.js +13 -0
- package/public/js/modules/file-comment-manager.js +28 -0
- package/public/js/pr.js +233 -115
- package/public/js/repo-settings.js +575 -106
- package/public/local.html +11 -1
- package/public/pr.html +6 -1
- package/public/repo-settings.html +28 -21
- package/public/setup.html +8 -2
- package/src/ai/analyzer.js +1262 -111
- package/src/ai/claude-cli.js +2 -2
- package/src/ai/claude-provider.js +6 -6
- package/src/ai/codex-provider.js +6 -6
- package/src/ai/copilot-provider.js +3 -3
- package/src/ai/cursor-agent-provider.js +6 -6
- package/src/ai/gemini-provider.js +6 -6
- package/src/ai/opencode-provider.js +6 -6
- package/src/ai/pi-provider.js +6 -6
- package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
- package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
- package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
- package/src/ai/prompts/config.js +1 -1
- package/src/ai/prompts/index.js +26 -2
- package/src/ai/provider.js +4 -2
- package/src/database.js +417 -14
- package/src/main.js +1 -1
- package/src/routes/analysis.js +495 -10
- package/src/routes/config.js +36 -15
- package/src/routes/councils.js +351 -0
- package/src/routes/local.js +33 -11
- package/src/routes/mcp.js +9 -2
- package/src/routes/setup.js +12 -2
- package/src/routes/shared.js +126 -13
- package/src/server.js +34 -4
- 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
|
+
}
|