secure-ui-components 0.2.3 → 0.2.5
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/dist/components/secure-card/secure-card.js +1 -766
- package/dist/components/secure-datetime/secure-datetime.js +1 -570
- package/dist/components/secure-file-upload/secure-file-upload.js +1 -868
- package/dist/components/secure-form/secure-form.js +1 -797
- package/dist/components/secure-input/secure-input.js +1 -867
- package/dist/components/secure-password-confirm/secure-password-confirm.js +1 -329
- package/dist/components/secure-select/secure-select.js +1 -589
- package/dist/components/secure-submit-button/secure-submit-button.js +1 -378
- package/dist/components/secure-table/secure-table.js +33 -528
- package/dist/components/secure-telemetry-provider/secure-telemetry-provider.js +1 -201
- package/dist/components/secure-textarea/secure-textarea.js +1 -491
- package/dist/core/base-component.js +1 -500
- package/dist/core/security-config.js +1 -242
- package/dist/core/types.js +0 -2
- package/dist/index.js +1 -17
- package/dist/package.json +4 -2
- package/package.json +4 -2
|
@@ -35,592 +35,4 @@
|
|
|
35
35
|
*
|
|
36
36
|
* @module secure-select
|
|
37
37
|
* @license MIT
|
|
38
|
-
*/
|
|
39
|
-
import { SecureBaseComponent } from '../../core/base-component.js';
|
|
40
|
-
/**
|
|
41
|
-
* Secure Select Web Component
|
|
42
|
-
*
|
|
43
|
-
* Provides a security-hardened select dropdown with progressive enhancement.
|
|
44
|
-
* The component works as a standard form select without JavaScript and
|
|
45
|
-
* enhances with security features when JavaScript is available.
|
|
46
|
-
*
|
|
47
|
-
* @extends SecureBaseComponent
|
|
48
|
-
*/
|
|
49
|
-
export class SecureSelect extends SecureBaseComponent {
|
|
50
|
-
/**
|
|
51
|
-
* Select element reference
|
|
52
|
-
* @private
|
|
53
|
-
*/
|
|
54
|
-
#selectElement = null;
|
|
55
|
-
/**
|
|
56
|
-
* Label element reference
|
|
57
|
-
* @private
|
|
58
|
-
*/
|
|
59
|
-
#labelElement = null;
|
|
60
|
-
/**
|
|
61
|
-
* Error container element reference
|
|
62
|
-
* @private
|
|
63
|
-
*/
|
|
64
|
-
#errorContainer = null;
|
|
65
|
-
/**
|
|
66
|
-
* Unique ID for this select instance
|
|
67
|
-
* @private
|
|
68
|
-
*/
|
|
69
|
-
#instanceId = `secure-select-${Math.random().toString(36).substring(2, 11)}`;
|
|
70
|
-
/**
|
|
71
|
-
* Valid option values
|
|
72
|
-
* @private
|
|
73
|
-
*/
|
|
74
|
-
#validOptions = new Set();
|
|
75
|
-
/**
|
|
76
|
-
* Flag to track if options have been transferred from light DOM
|
|
77
|
-
* @private
|
|
78
|
-
*/
|
|
79
|
-
#optionsTransferred = false;
|
|
80
|
-
/**
|
|
81
|
-
* Whether this is a multi-select instance
|
|
82
|
-
* @private
|
|
83
|
-
*/
|
|
84
|
-
#isMultiple = false;
|
|
85
|
-
/**
|
|
86
|
-
* Observed attributes for this component
|
|
87
|
-
*
|
|
88
|
-
* @static
|
|
89
|
-
*/
|
|
90
|
-
static get observedAttributes() {
|
|
91
|
-
return [
|
|
92
|
-
...super.observedAttributes,
|
|
93
|
-
'name',
|
|
94
|
-
'label',
|
|
95
|
-
'required',
|
|
96
|
-
'multiple',
|
|
97
|
-
'size',
|
|
98
|
-
'value'
|
|
99
|
-
];
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Constructor
|
|
103
|
-
*/
|
|
104
|
-
constructor() {
|
|
105
|
-
super();
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Render the select component
|
|
109
|
-
*
|
|
110
|
-
* Security Note: We use a native <select> element wrapped in our web component
|
|
111
|
-
* to ensure progressive enhancement. The native select works without JavaScript,
|
|
112
|
-
* and we enhance it with security features when JS is available.
|
|
113
|
-
*
|
|
114
|
-
* @protected
|
|
115
|
-
*/
|
|
116
|
-
render() {
|
|
117
|
-
const fragment = document.createDocumentFragment();
|
|
118
|
-
const container = document.createElement('div');
|
|
119
|
-
container.className = 'select-container';
|
|
120
|
-
container.setAttribute('part', 'container');
|
|
121
|
-
// Check if this is a multi-select
|
|
122
|
-
this.#isMultiple = this.hasAttribute('multiple');
|
|
123
|
-
// Create label
|
|
124
|
-
const label = this.getAttribute('label');
|
|
125
|
-
if (label) {
|
|
126
|
-
this.#labelElement = document.createElement('label');
|
|
127
|
-
this.#labelElement.htmlFor = this.#instanceId;
|
|
128
|
-
this.#labelElement.textContent = this.sanitizeValue(label);
|
|
129
|
-
this.#labelElement.setAttribute('part', 'label');
|
|
130
|
-
container.appendChild(this.#labelElement);
|
|
131
|
-
}
|
|
132
|
-
// Create select wrapper for progressive enhancement
|
|
133
|
-
const selectWrapper = document.createElement('div');
|
|
134
|
-
selectWrapper.className = 'select-wrapper';
|
|
135
|
-
selectWrapper.setAttribute('part', 'wrapper');
|
|
136
|
-
// Create the actual select element
|
|
137
|
-
this.#selectElement = document.createElement('select');
|
|
138
|
-
this.#selectElement.id = this.#instanceId;
|
|
139
|
-
this.#selectElement.className = 'select-field';
|
|
140
|
-
this.#selectElement.setAttribute('part', 'select');
|
|
141
|
-
// Apply attributes from web component to native select
|
|
142
|
-
this.#applySelectAttributes();
|
|
143
|
-
// Set up event listeners
|
|
144
|
-
this.#attachEventListeners();
|
|
145
|
-
// Defer transferring options to allow light DOM to be fully parsed
|
|
146
|
-
// This handles the case where the component is created before its children
|
|
147
|
-
queueMicrotask(() => {
|
|
148
|
-
this.#transferOptions();
|
|
149
|
-
});
|
|
150
|
-
selectWrapper.appendChild(this.#selectElement);
|
|
151
|
-
container.appendChild(selectWrapper);
|
|
152
|
-
// Create error container
|
|
153
|
-
// role="alert" already implies aria-live="assertive" — do not override with polite
|
|
154
|
-
this.#errorContainer = document.createElement('div');
|
|
155
|
-
this.#errorContainer.className = 'error-container hidden';
|
|
156
|
-
this.#errorContainer.setAttribute('role', 'alert');
|
|
157
|
-
this.#errorContainer.setAttribute('part', 'error');
|
|
158
|
-
this.#errorContainer.id = `${this.#instanceId}-error`;
|
|
159
|
-
container.appendChild(this.#errorContainer);
|
|
160
|
-
// Add component styles (CSP-compliant via adoptedStyleSheets)
|
|
161
|
-
this.addComponentStyles(this.#getComponentStyles());
|
|
162
|
-
fragment.appendChild(container);
|
|
163
|
-
return fragment;
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Apply attributes from the web component to the native select
|
|
167
|
-
*
|
|
168
|
-
* Security Note: This is where we enforce tier-specific security controls
|
|
169
|
-
* like validation rules.
|
|
170
|
-
*
|
|
171
|
-
* @private
|
|
172
|
-
*/
|
|
173
|
-
#applySelectAttributes() {
|
|
174
|
-
const config = this.config;
|
|
175
|
-
// Name attribute (required for form submission)
|
|
176
|
-
const name = this.getAttribute('name');
|
|
177
|
-
if (name) {
|
|
178
|
-
this.#selectElement.name = this.sanitizeValue(name);
|
|
179
|
-
}
|
|
180
|
-
// Accessible name fallback when no visible label is provided
|
|
181
|
-
if (!this.getAttribute('label') && name) {
|
|
182
|
-
this.#selectElement.setAttribute('aria-label', this.sanitizeValue(name));
|
|
183
|
-
}
|
|
184
|
-
// Link select to its error container for screen readers
|
|
185
|
-
this.#selectElement.setAttribute('aria-describedby', `${this.#instanceId}-error`);
|
|
186
|
-
// Required attribute
|
|
187
|
-
if (this.hasAttribute('required') || config.validation.required) {
|
|
188
|
-
this.#selectElement.required = true;
|
|
189
|
-
this.#selectElement.setAttribute('aria-required', 'true');
|
|
190
|
-
}
|
|
191
|
-
// Multiple selection
|
|
192
|
-
if (this.hasAttribute('multiple')) {
|
|
193
|
-
this.#selectElement.multiple = true;
|
|
194
|
-
}
|
|
195
|
-
// Size attribute
|
|
196
|
-
const size = this.getAttribute('size');
|
|
197
|
-
if (size) {
|
|
198
|
-
this.#selectElement.size = parseInt(size, 10);
|
|
199
|
-
}
|
|
200
|
-
// Disabled state
|
|
201
|
-
if (this.hasAttribute('disabled')) {
|
|
202
|
-
this.#selectElement.disabled = true;
|
|
203
|
-
}
|
|
204
|
-
// Autocomplete control
|
|
205
|
-
if (!config.storage.allowAutocomplete) {
|
|
206
|
-
this.#selectElement.autocomplete = 'off';
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
/**
|
|
210
|
-
* Transfer option elements from light DOM to select element
|
|
211
|
-
*
|
|
212
|
-
* Security Note: We sanitize all option values and text to prevent XSS
|
|
213
|
-
*
|
|
214
|
-
* @private
|
|
215
|
-
*/
|
|
216
|
-
#transferOptions() {
|
|
217
|
-
// Only transfer once to avoid clearing programmatically-added options
|
|
218
|
-
if (this.#optionsTransferred)
|
|
219
|
-
return;
|
|
220
|
-
this.#optionsTransferred = true;
|
|
221
|
-
// Get option elements from light DOM (original content)
|
|
222
|
-
const options = Array.from(this.querySelectorAll('option'));
|
|
223
|
-
// If no light DOM options, nothing to transfer
|
|
224
|
-
if (options.length === 0)
|
|
225
|
-
return;
|
|
226
|
-
// Track selected values (supports multiple selected attributes)
|
|
227
|
-
const selectedValues = [];
|
|
228
|
-
// Transfer each option to the select element
|
|
229
|
-
options.forEach((option) => {
|
|
230
|
-
const newOption = document.createElement('option');
|
|
231
|
-
// Sanitize value
|
|
232
|
-
const value = option.getAttribute('value') || '';
|
|
233
|
-
newOption.value = this.sanitizeValue(value);
|
|
234
|
-
this.#validOptions.add(newOption.value);
|
|
235
|
-
// Sanitize text content
|
|
236
|
-
newOption.textContent = this.sanitizeValue(option.textContent || '');
|
|
237
|
-
// Copy other attributes
|
|
238
|
-
if (option.hasAttribute('selected')) {
|
|
239
|
-
newOption.selected = true;
|
|
240
|
-
selectedValues.push(newOption.value);
|
|
241
|
-
}
|
|
242
|
-
if (option.hasAttribute('disabled')) {
|
|
243
|
-
newOption.disabled = true;
|
|
244
|
-
}
|
|
245
|
-
this.#selectElement.appendChild(newOption);
|
|
246
|
-
});
|
|
247
|
-
// Set initial value - attribute takes precedence over selected option
|
|
248
|
-
if (!this.#isMultiple) {
|
|
249
|
-
const initialValue = this.getAttribute('value');
|
|
250
|
-
if (initialValue) {
|
|
251
|
-
this.#selectElement.value = initialValue;
|
|
252
|
-
}
|
|
253
|
-
else if (selectedValues.length > 0) {
|
|
254
|
-
this.#selectElement.value = selectedValues[0];
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
// For multiple, the selected attributes on individual options already applied
|
|
258
|
-
}
|
|
259
|
-
/**
|
|
260
|
-
* Attach event listeners to the select
|
|
261
|
-
*
|
|
262
|
-
* @private
|
|
263
|
-
*/
|
|
264
|
-
#attachEventListeners() {
|
|
265
|
-
// Focus event - audit logging + telemetry
|
|
266
|
-
this.#selectElement.addEventListener('focus', () => {
|
|
267
|
-
this.recordTelemetryFocus();
|
|
268
|
-
this.audit('select_focused', {
|
|
269
|
-
name: this.#selectElement.name
|
|
270
|
-
});
|
|
271
|
-
});
|
|
272
|
-
// Change event - validation, audit logging + telemetry
|
|
273
|
-
this.#selectElement.addEventListener('change', (e) => {
|
|
274
|
-
this.recordTelemetryInput(e);
|
|
275
|
-
this.#handleChange(e);
|
|
276
|
-
});
|
|
277
|
-
// Blur event - final validation + telemetry
|
|
278
|
-
this.#selectElement.addEventListener('blur', () => {
|
|
279
|
-
this.recordTelemetryBlur();
|
|
280
|
-
this.#validateAndShowErrors();
|
|
281
|
-
this.audit('select_blurred', {
|
|
282
|
-
name: this.#selectElement.name,
|
|
283
|
-
hasValue: this.#isMultiple
|
|
284
|
-
? this.#selectElement.selectedOptions.length > 0
|
|
285
|
-
: this.#selectElement.value.length > 0
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* Handle change events
|
|
291
|
-
*
|
|
292
|
-
* Security Note: We validate that the selected value is in the list of valid options
|
|
293
|
-
* to prevent value injection attacks.
|
|
294
|
-
*
|
|
295
|
-
* @private
|
|
296
|
-
*/
|
|
297
|
-
#handleChange(_event) {
|
|
298
|
-
if (this.#isMultiple) {
|
|
299
|
-
// Multi-select: validate all selected values
|
|
300
|
-
const selectedValues = Array.from(this.#selectElement.selectedOptions).map(opt => opt.value);
|
|
301
|
-
const invalidValues = selectedValues.filter(v => v && !this.#validOptions.has(v));
|
|
302
|
-
if (invalidValues.length > 0) {
|
|
303
|
-
this.#showError('Invalid option selected');
|
|
304
|
-
this.audit('invalid_option_detected', {
|
|
305
|
-
name: this.#selectElement.name,
|
|
306
|
-
attemptedValues: invalidValues
|
|
307
|
-
});
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
// Clear previous errors
|
|
311
|
-
this.#clearErrors();
|
|
312
|
-
// Log the change
|
|
313
|
-
this.audit('select_changed', {
|
|
314
|
-
name: this.#selectElement.name,
|
|
315
|
-
values: selectedValues
|
|
316
|
-
});
|
|
317
|
-
// Dispatch custom event for parent forms
|
|
318
|
-
this.dispatchEvent(new CustomEvent('secure-select', {
|
|
319
|
-
detail: {
|
|
320
|
-
name: this.#selectElement.name,
|
|
321
|
-
value: selectedValues,
|
|
322
|
-
tier: this.securityTier
|
|
323
|
-
},
|
|
324
|
-
bubbles: true,
|
|
325
|
-
composed: true
|
|
326
|
-
}));
|
|
327
|
-
}
|
|
328
|
-
else {
|
|
329
|
-
// Single select: validate the selected value
|
|
330
|
-
const selectedValue = this.#selectElement.value;
|
|
331
|
-
if (selectedValue && !this.#validOptions.has(selectedValue)) {
|
|
332
|
-
this.#showError('Invalid option selected');
|
|
333
|
-
this.audit('invalid_option_detected', {
|
|
334
|
-
name: this.#selectElement.name,
|
|
335
|
-
attemptedValue: selectedValue
|
|
336
|
-
});
|
|
337
|
-
// Reset to empty value
|
|
338
|
-
this.#selectElement.value = '';
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
// Clear previous errors
|
|
342
|
-
this.#clearErrors();
|
|
343
|
-
// Log the change
|
|
344
|
-
this.audit('select_changed', {
|
|
345
|
-
name: this.#selectElement.name,
|
|
346
|
-
value: selectedValue
|
|
347
|
-
});
|
|
348
|
-
// Dispatch custom event for parent forms
|
|
349
|
-
this.dispatchEvent(new CustomEvent('secure-select', {
|
|
350
|
-
detail: {
|
|
351
|
-
name: this.#selectElement.name,
|
|
352
|
-
value: selectedValue,
|
|
353
|
-
tier: this.securityTier
|
|
354
|
-
},
|
|
355
|
-
bubbles: true,
|
|
356
|
-
composed: true
|
|
357
|
-
}));
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
/**
|
|
361
|
-
* Validate the select and show error messages
|
|
362
|
-
*
|
|
363
|
-
* @private
|
|
364
|
-
*/
|
|
365
|
-
#validateAndShowErrors() {
|
|
366
|
-
// Check rate limit first
|
|
367
|
-
const rateLimitCheck = this.checkRateLimit();
|
|
368
|
-
if (!rateLimitCheck.allowed) {
|
|
369
|
-
this.#showError(`Too many attempts. Please wait ${Math.ceil(rateLimitCheck.retryAfter / 1000)} seconds.`);
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
const required = this.hasAttribute('required') || this.config.validation.required;
|
|
373
|
-
if (this.#isMultiple) {
|
|
374
|
-
// Multi-select: check if at least one option is selected
|
|
375
|
-
const selectedValues = Array.from(this.#selectElement.selectedOptions)
|
|
376
|
-
.map(opt => opt.value)
|
|
377
|
-
.filter(v => v !== '');
|
|
378
|
-
if (required && selectedValues.length === 0) {
|
|
379
|
-
this.#showError('Please select at least one option');
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
// Validate all selected values are in valid options
|
|
383
|
-
const invalidValues = selectedValues.filter(v => !this.#validOptions.has(v));
|
|
384
|
-
if (invalidValues.length > 0) {
|
|
385
|
-
this.#showError('Invalid option selected');
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
else {
|
|
390
|
-
// Single select: check required and valid option
|
|
391
|
-
if (required && !this.#selectElement.value) {
|
|
392
|
-
this.#showError('Please select an option');
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
const selectedValue = this.#selectElement.value;
|
|
396
|
-
if (selectedValue && !this.#validOptions.has(selectedValue)) {
|
|
397
|
-
this.#showError('Invalid option selected');
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* Show error message
|
|
404
|
-
*
|
|
405
|
-
* @private
|
|
406
|
-
*/
|
|
407
|
-
#showError(message) {
|
|
408
|
-
this.#errorContainer.textContent = message;
|
|
409
|
-
// Force reflow so browser registers the hidden state with content,
|
|
410
|
-
// then remove hidden to trigger the CSS transition
|
|
411
|
-
void this.#errorContainer.offsetHeight;
|
|
412
|
-
this.#errorContainer.classList.remove('hidden');
|
|
413
|
-
this.#selectElement.classList.add('error');
|
|
414
|
-
this.#selectElement.setAttribute('aria-invalid', 'true');
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* Clear error messages
|
|
418
|
-
*
|
|
419
|
-
* @private
|
|
420
|
-
*/
|
|
421
|
-
#clearErrors() {
|
|
422
|
-
// Start the hide animation first, clear text only after transition ends
|
|
423
|
-
this.#errorContainer.classList.add('hidden');
|
|
424
|
-
this.#errorContainer.addEventListener('transitionend', () => {
|
|
425
|
-
if (this.#errorContainer.classList.contains('hidden')) {
|
|
426
|
-
this.#errorContainer.textContent = '';
|
|
427
|
-
}
|
|
428
|
-
}, { once: true });
|
|
429
|
-
this.#selectElement.classList.remove('error');
|
|
430
|
-
this.#selectElement.removeAttribute('aria-invalid');
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* Get component-specific styles
|
|
434
|
-
*
|
|
435
|
-
* @private
|
|
436
|
-
*/
|
|
437
|
-
#getComponentStyles() {
|
|
438
|
-
return new URL('./secure-select.css', import.meta.url).href;
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* Handle attribute changes
|
|
442
|
-
*
|
|
443
|
-
* @protected
|
|
444
|
-
*/
|
|
445
|
-
handleAttributeChange(name, _oldValue, newValue) {
|
|
446
|
-
if (!this.#selectElement)
|
|
447
|
-
return;
|
|
448
|
-
switch (name) {
|
|
449
|
-
case 'disabled':
|
|
450
|
-
this.#selectElement.disabled = this.hasAttribute('disabled');
|
|
451
|
-
break;
|
|
452
|
-
case 'value':
|
|
453
|
-
if (newValue !== this.#selectElement.value) {
|
|
454
|
-
this.#selectElement.value = newValue || '';
|
|
455
|
-
}
|
|
456
|
-
break;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
/**
|
|
460
|
-
* Get the current value
|
|
461
|
-
*
|
|
462
|
-
* @public
|
|
463
|
-
*/
|
|
464
|
-
get value() {
|
|
465
|
-
if (!this.#selectElement)
|
|
466
|
-
return '';
|
|
467
|
-
// Multi-select: return comma-separated selected values
|
|
468
|
-
if (this.#isMultiple) {
|
|
469
|
-
return Array.from(this.#selectElement.selectedOptions)
|
|
470
|
-
.map(opt => opt.value)
|
|
471
|
-
.filter(v => v !== '')
|
|
472
|
-
.join(', ');
|
|
473
|
-
}
|
|
474
|
-
return this.#selectElement.value;
|
|
475
|
-
}
|
|
476
|
-
/**
|
|
477
|
-
* Set the value
|
|
478
|
-
*
|
|
479
|
-
* @public
|
|
480
|
-
*/
|
|
481
|
-
set value(value) {
|
|
482
|
-
if (!this.#selectElement)
|
|
483
|
-
return;
|
|
484
|
-
if (this.#isMultiple) {
|
|
485
|
-
// Multi-select: accept comma-separated values
|
|
486
|
-
const values = value.split(',').map(v => v.trim()).filter(v => v !== '');
|
|
487
|
-
// Deselect all first
|
|
488
|
-
Array.from(this.#selectElement.options).forEach(opt => { opt.selected = false; });
|
|
489
|
-
// Select matching valid options
|
|
490
|
-
values.forEach(v => {
|
|
491
|
-
if (this.#validOptions.has(v)) {
|
|
492
|
-
const opt = Array.from(this.#selectElement.options).find(o => o.value === v);
|
|
493
|
-
if (opt)
|
|
494
|
-
opt.selected = true;
|
|
495
|
-
}
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
else {
|
|
499
|
-
if (this.#validOptions.has(value)) {
|
|
500
|
-
this.#selectElement.value = value;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
/**
|
|
505
|
-
* Get the select name
|
|
506
|
-
*
|
|
507
|
-
* @public
|
|
508
|
-
*/
|
|
509
|
-
get name() {
|
|
510
|
-
return this.#selectElement ? this.#selectElement.name : '';
|
|
511
|
-
}
|
|
512
|
-
/**
|
|
513
|
-
* Get selected options (for multiple select)
|
|
514
|
-
*
|
|
515
|
-
* @public
|
|
516
|
-
*/
|
|
517
|
-
get selectedOptions() {
|
|
518
|
-
if (!this.#selectElement)
|
|
519
|
-
return [];
|
|
520
|
-
return Array.from(this.#selectElement.selectedOptions).map(opt => opt.value);
|
|
521
|
-
}
|
|
522
|
-
/**
|
|
523
|
-
* Check if the select is valid
|
|
524
|
-
*
|
|
525
|
-
* @public
|
|
526
|
-
*/
|
|
527
|
-
get valid() {
|
|
528
|
-
const required = this.hasAttribute('required') || this.config.validation.required;
|
|
529
|
-
if (this.#isMultiple) {
|
|
530
|
-
const selectedValues = Array.from(this.#selectElement.selectedOptions)
|
|
531
|
-
.map(opt => opt.value)
|
|
532
|
-
.filter(v => v !== '');
|
|
533
|
-
if (required && selectedValues.length === 0) {
|
|
534
|
-
return false;
|
|
535
|
-
}
|
|
536
|
-
// All selected values must be valid
|
|
537
|
-
return selectedValues.every(v => this.#validOptions.has(v));
|
|
538
|
-
}
|
|
539
|
-
// Single select
|
|
540
|
-
if (required && !this.#selectElement.value) {
|
|
541
|
-
return false;
|
|
542
|
-
}
|
|
543
|
-
const selectedValue = this.#selectElement.value;
|
|
544
|
-
if (selectedValue && !this.#validOptions.has(selectedValue)) {
|
|
545
|
-
return false;
|
|
546
|
-
}
|
|
547
|
-
return true;
|
|
548
|
-
}
|
|
549
|
-
/**
|
|
550
|
-
* Focus the select
|
|
551
|
-
*
|
|
552
|
-
* @public
|
|
553
|
-
*/
|
|
554
|
-
focus() {
|
|
555
|
-
if (this.#selectElement) {
|
|
556
|
-
this.#selectElement.focus();
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
/**
|
|
560
|
-
* Blur the select
|
|
561
|
-
*
|
|
562
|
-
* @public
|
|
563
|
-
*/
|
|
564
|
-
blur() {
|
|
565
|
-
if (this.#selectElement) {
|
|
566
|
-
this.#selectElement.blur();
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
/**
|
|
570
|
-
* Add an option programmatically
|
|
571
|
-
*
|
|
572
|
-
* @public
|
|
573
|
-
*/
|
|
574
|
-
addOption(value, text, selected = false) {
|
|
575
|
-
if (!this.#selectElement)
|
|
576
|
-
return;
|
|
577
|
-
const option = document.createElement('option');
|
|
578
|
-
option.value = this.sanitizeValue(value);
|
|
579
|
-
option.textContent = this.sanitizeValue(text);
|
|
580
|
-
option.selected = selected;
|
|
581
|
-
this.#validOptions.add(option.value);
|
|
582
|
-
this.#selectElement.appendChild(option);
|
|
583
|
-
}
|
|
584
|
-
/**
|
|
585
|
-
* Remove an option by value
|
|
586
|
-
*
|
|
587
|
-
* @public
|
|
588
|
-
*/
|
|
589
|
-
removeOption(value) {
|
|
590
|
-
if (!this.#selectElement)
|
|
591
|
-
return;
|
|
592
|
-
const options = Array.from(this.#selectElement.options);
|
|
593
|
-
const option = options.find(opt => opt.value === value);
|
|
594
|
-
if (option) {
|
|
595
|
-
this.#selectElement.removeChild(option);
|
|
596
|
-
this.#validOptions.delete(value);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
/**
|
|
600
|
-
* Clear all options
|
|
601
|
-
*
|
|
602
|
-
* @public
|
|
603
|
-
*/
|
|
604
|
-
clearOptions() {
|
|
605
|
-
if (!this.#selectElement)
|
|
606
|
-
return;
|
|
607
|
-
this.#selectElement.innerHTML = '';
|
|
608
|
-
this.#validOptions.clear();
|
|
609
|
-
}
|
|
610
|
-
/**
|
|
611
|
-
* Cleanup on disconnect
|
|
612
|
-
*
|
|
613
|
-
* Note: We intentionally do NOT clear #validOptions here.
|
|
614
|
-
* When <secure-select> is inside a <secure-form>, the form moves its children
|
|
615
|
-
* into a <form> element, which triggers disconnect/reconnect. Clearing
|
|
616
|
-
* #validOptions on disconnect would leave the set empty after reconnect,
|
|
617
|
-
* causing all subsequent selections to be rejected as "invalid option".
|
|
618
|
-
*/
|
|
619
|
-
disconnectedCallback() {
|
|
620
|
-
super.disconnectedCallback();
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
// Define the custom element
|
|
624
|
-
customElements.define('secure-select', SecureSelect);
|
|
625
|
-
export default SecureSelect;
|
|
626
|
-
//# sourceMappingURL=secure-select.js.map
|
|
38
|
+
*/import{SecureBaseComponent as n}from"../../core/base-component.js";class a extends n{#e=null;#a=null;#t=null;#n=`secure-select-${Math.random().toString(36).substring(2,11)}`;#i=new Set;#l=!1;#s=!1;static get observedAttributes(){return[...super.observedAttributes,"name","label","required","multiple","size","value"]}constructor(){super()}render(){const t=document.createDocumentFragment(),i=document.createElement("div");i.className="select-container",i.setAttribute("part","container"),this.#s=this.hasAttribute("multiple");const e=this.getAttribute("label");e&&(this.#a=document.createElement("label"),this.#a.htmlFor=this.#n,this.#a.textContent=this.sanitizeValue(e),this.#a.setAttribute("part","label"),i.appendChild(this.#a));const s=document.createElement("div");return s.className="select-wrapper",s.setAttribute("part","wrapper"),this.#e=document.createElement("select"),this.#e.id=this.#n,this.#e.className="select-field",this.#e.setAttribute("part","select"),this.#u(),this.#d(),queueMicrotask(()=>{this.#o()}),s.appendChild(this.#e),i.appendChild(s),this.#t=document.createElement("div"),this.#t.className="error-container hidden",this.#t.setAttribute("role","alert"),this.#t.setAttribute("part","error"),this.#t.id=`${this.#n}-error`,i.appendChild(this.#t),this.addComponentStyles(this.#f()),t.appendChild(i),t}#u(){const t=this.config,i=this.getAttribute("name");i&&(this.#e.name=this.sanitizeValue(i)),!this.getAttribute("label")&&i&&this.#e.setAttribute("aria-label",this.sanitizeValue(i)),this.#e.setAttribute("aria-describedby",`${this.#n}-error`),(this.hasAttribute("required")||t.validation.required)&&(this.#e.required=!0,this.#e.setAttribute("aria-required","true")),this.hasAttribute("multiple")&&(this.#e.multiple=!0);const e=this.getAttribute("size");e&&(this.#e.size=parseInt(e,10)),this.hasAttribute("disabled")&&(this.#e.disabled=!0),t.storage.allowAutocomplete||(this.#e.autocomplete="off")}#o(){if(this.#l)return;this.#l=!0;const t=Array.from(this.querySelectorAll("option"));if(t.length===0)return;const i=[];if(t.forEach(e=>{const s=document.createElement("option"),r=e.getAttribute("value")||"";s.value=this.sanitizeValue(r),this.#i.add(s.value),s.textContent=this.sanitizeValue(e.textContent||""),e.hasAttribute("selected")&&(s.selected=!0,i.push(s.value)),e.hasAttribute("disabled")&&(s.disabled=!0),this.#e.appendChild(s)}),!this.#s){const e=this.getAttribute("value");e?this.#e.value=e:i.length>0&&(this.#e.value=i[0])}}#d(){this.#e.addEventListener("focus",()=>{this.recordTelemetryFocus(),this.audit("select_focused",{name:this.#e.name})}),this.#e.addEventListener("change",t=>{this.recordTelemetryInput(t),this.#c(t)}),this.#e.addEventListener("blur",()=>{this.recordTelemetryBlur(),this.#m(),this.audit("select_blurred",{name:this.#e.name,hasValue:this.#s?this.#e.selectedOptions.length>0:this.#e.value.length>0})})}#c(t){if(this.#s){const i=Array.from(this.#e.selectedOptions).map(s=>s.value),e=i.filter(s=>s&&!this.#i.has(s));if(e.length>0){this.#r("Invalid option selected"),this.audit("invalid_option_detected",{name:this.#e.name,attemptedValues:e});return}this.#h(),this.audit("select_changed",{name:this.#e.name,values:i}),this.dispatchEvent(new CustomEvent("secure-select",{detail:{name:this.#e.name,value:i,tier:this.securityTier},bubbles:!0,composed:!0}))}else{const i=this.#e.value;if(i&&!this.#i.has(i)){this.#r("Invalid option selected"),this.audit("invalid_option_detected",{name:this.#e.name,attemptedValue:i}),this.#e.value="";return}this.#h(),this.audit("select_changed",{name:this.#e.name,value:i}),this.dispatchEvent(new CustomEvent("secure-select",{detail:{name:this.#e.name,value:i,tier:this.securityTier},bubbles:!0,composed:!0}))}}#m(){const t=this.checkRateLimit();if(!t.allowed){this.#r(`Too many attempts. Please wait ${Math.ceil(t.retryAfter/1e3)} seconds.`);return}const i=this.hasAttribute("required")||this.config.validation.required;if(this.#s){const e=Array.from(this.#e.selectedOptions).map(r=>r.value).filter(r=>r!=="");if(i&&e.length===0){this.#r("Please select at least one option");return}if(e.filter(r=>!this.#i.has(r)).length>0){this.#r("Invalid option selected");return}}else{if(i&&!this.#e.value){this.#r("Please select an option");return}const e=this.#e.value;if(e&&!this.#i.has(e)){this.#r("Invalid option selected");return}}}#r(t){this.#t.textContent=t,this.#t.offsetHeight,this.#t.classList.remove("hidden"),this.#e.classList.add("error"),this.#e.setAttribute("aria-invalid","true")}#h(){this.#t.classList.add("hidden"),this.#t.addEventListener("transitionend",()=>{this.#t.classList.contains("hidden")&&(this.#t.textContent="")},{once:!0}),this.#e.classList.remove("error"),this.#e.removeAttribute("aria-invalid")}#f(){return new URL("./secure-select.css",import.meta.url).href}handleAttributeChange(t,i,e){if(this.#e)switch(t){case"disabled":this.#e.disabled=this.hasAttribute("disabled");break;case"value":e!==this.#e.value&&(this.#e.value=e||"");break}}get value(){return this.#e?this.#s?Array.from(this.#e.selectedOptions).map(t=>t.value).filter(t=>t!=="").join(", "):this.#e.value:""}set value(t){if(this.#e)if(this.#s){const i=t.split(",").map(e=>e.trim()).filter(e=>e!=="");Array.from(this.#e.options).forEach(e=>{e.selected=!1}),i.forEach(e=>{if(this.#i.has(e)){const s=Array.from(this.#e.options).find(r=>r.value===e);s&&(s.selected=!0)}})}else this.#i.has(t)&&(this.#e.value=t)}get name(){return this.#e?this.#e.name:""}get selectedOptions(){return this.#e?Array.from(this.#e.selectedOptions).map(t=>t.value):[]}get valid(){const t=this.hasAttribute("required")||this.config.validation.required;if(this.#s){const e=Array.from(this.#e.selectedOptions).map(s=>s.value).filter(s=>s!=="");return t&&e.length===0?!1:e.every(s=>this.#i.has(s))}if(t&&!this.#e.value)return!1;const i=this.#e.value;return!(i&&!this.#i.has(i))}focus(){this.#e&&this.#e.focus()}blur(){this.#e&&this.#e.blur()}addOption(t,i,e=!1){if(!this.#e)return;const s=document.createElement("option");s.value=this.sanitizeValue(t),s.textContent=this.sanitizeValue(i),s.selected=e,this.#i.add(s.value),this.#e.appendChild(s)}removeOption(t){if(!this.#e)return;const e=Array.from(this.#e.options).find(s=>s.value===t);e&&(this.#e.removeChild(e),this.#i.delete(t))}clearOptions(){this.#e&&(this.#e.innerHTML="",this.#i.clear())}disconnectedCallback(){super.disconnectedCallback()}}customElements.define("secure-select",a);var u=a;export{a as SecureSelect,u as default};
|