secure-ui-components 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,494 +27,4 @@
27
27
  *
28
28
  * @module secure-textarea
29
29
  * @license MIT
30
- */
31
- import { SecureBaseComponent } from '../../core/base-component.js';
32
- /**
33
- * Secure Textarea Web Component
34
- *
35
- * Provides a security-hardened textarea field with progressive enhancement.
36
- * The component works as a standard form textarea without JavaScript and
37
- * enhances with security features when JavaScript is available.
38
- *
39
- * @extends SecureBaseComponent
40
- */
41
- export class SecureTextarea extends SecureBaseComponent {
42
- /**
43
- * Textarea element reference
44
- * @private
45
- */
46
- #textareaElement = null;
47
- /**
48
- * Label element reference
49
- * @private
50
- */
51
- #labelElement = null;
52
- /**
53
- * Error container element reference
54
- * @private
55
- */
56
- #errorContainer = null;
57
- /**
58
- * Threat feedback container — separate from validation errors so
59
- * #clearErrors() never clobbers an active threat message.
60
- * @private
61
- */
62
- #threatContainer = null;
63
- /**
64
- * Character count display element
65
- * @private
66
- */
67
- #charCountElement = null;
68
- /**
69
- * Unique ID for this textarea instance
70
- * @private
71
- */
72
- #instanceId = `secure-textarea-${Math.random().toString(36).substring(2, 11)}`;
73
- /**
74
- * Observed attributes for this component
75
- *
76
- * @static
77
- */
78
- static get observedAttributes() {
79
- return [
80
- ...super.observedAttributes,
81
- 'name',
82
- 'label',
83
- 'placeholder',
84
- 'required',
85
- 'minlength',
86
- 'maxlength',
87
- 'rows',
88
- 'cols',
89
- 'wrap',
90
- 'value'
91
- ];
92
- }
93
- /**
94
- * Constructor
95
- */
96
- constructor() {
97
- super();
98
- }
99
- /**
100
- * Render the textarea component
101
- *
102
- * Security Note: We use a native <textarea> element wrapped in our web component
103
- * to ensure progressive enhancement. The native textarea works without JavaScript,
104
- * and we enhance it with security features when JS is available.
105
- *
106
- * @protected
107
- */
108
- render() {
109
- const fragment = document.createDocumentFragment();
110
- const container = document.createElement('div');
111
- container.className = 'textarea-container';
112
- container.setAttribute('part', 'container');
113
- // Create label
114
- const label = this.getAttribute('label');
115
- if (label) {
116
- this.#labelElement = document.createElement('label');
117
- this.#labelElement.htmlFor = this.#instanceId;
118
- this.#labelElement.textContent = this.sanitizeValue(label);
119
- this.#labelElement.setAttribute('part', 'label');
120
- container.appendChild(this.#labelElement);
121
- }
122
- // Create textarea wrapper for progressive enhancement
123
- const textareaWrapper = document.createElement('div');
124
- textareaWrapper.className = 'textarea-wrapper';
125
- textareaWrapper.setAttribute('part', 'wrapper');
126
- // Create the actual textarea element
127
- this.#textareaElement = document.createElement('textarea');
128
- this.#textareaElement.id = this.#instanceId;
129
- this.#textareaElement.className = 'textarea-field';
130
- this.#textareaElement.setAttribute('part', 'textarea');
131
- // Apply attributes from web component to native textarea
132
- this.#applyTextareaAttributes();
133
- // Set up event listeners
134
- this.#attachEventListeners();
135
- textareaWrapper.appendChild(this.#textareaElement);
136
- container.appendChild(textareaWrapper);
137
- // Create character count display
138
- this.#charCountElement = document.createElement('span');
139
- this.#charCountElement.className = 'char-count';
140
- this.#updateCharCount();
141
- container.appendChild(this.#charCountElement);
142
- // Create error container
143
- // role="alert" already implies aria-live="assertive" — do not override with polite
144
- this.#errorContainer = document.createElement('div');
145
- this.#errorContainer.className = 'error-container hidden';
146
- this.#errorContainer.setAttribute('role', 'alert');
147
- this.#errorContainer.setAttribute('part', 'error');
148
- this.#errorContainer.id = `${this.#instanceId}-error`;
149
- container.appendChild(this.#errorContainer);
150
- // Threat feedback container — kept separate from #errorContainer so
151
- // #clearErrors() (called on every input event) never clobbers a threat message.
152
- this.#threatContainer = document.createElement('div');
153
- this.#threatContainer.className = 'threat-container hidden';
154
- this.#threatContainer.setAttribute('role', 'alert');
155
- this.#threatContainer.setAttribute('part', 'threat');
156
- this.#threatContainer.id = `${this.#instanceId}-threat`;
157
- container.appendChild(this.#threatContainer);
158
- // Add component styles (CSP-compliant via adoptedStyleSheets)
159
- this.addComponentStyles(this.#getComponentStyles());
160
- fragment.appendChild(container);
161
- return fragment;
162
- }
163
- /**
164
- * Apply attributes from the web component to the native textarea
165
- *
166
- * Security Note: This is where we enforce tier-specific security controls
167
- * like autocomplete, caching, and validation rules.
168
- *
169
- * @private
170
- */
171
- #applyTextareaAttributes() {
172
- const config = this.config;
173
- // Name attribute (required for form submission)
174
- const name = this.getAttribute('name');
175
- if (name) {
176
- this.#textareaElement.name = this.sanitizeValue(name);
177
- }
178
- // Accessible name fallback when no visible label is provided
179
- if (!this.getAttribute('label') && name) {
180
- this.#textareaElement.setAttribute('aria-label', this.sanitizeValue(name));
181
- }
182
- // Link textarea to its error container for screen readers
183
- this.#textareaElement.setAttribute('aria-describedby', `${this.#instanceId}-error`);
184
- // Placeholder
185
- const placeholder = this.getAttribute('placeholder');
186
- if (placeholder) {
187
- this.#textareaElement.placeholder = this.sanitizeValue(placeholder);
188
- }
189
- // Required attribute
190
- if (this.hasAttribute('required') || config.validation.required) {
191
- this.#textareaElement.required = true;
192
- this.#textareaElement.setAttribute('aria-required', 'true');
193
- }
194
- // Length constraints
195
- const minLength = this.getAttribute('minlength');
196
- if (minLength) {
197
- this.#textareaElement.minLength = parseInt(minLength, 10);
198
- }
199
- const maxLength = this.getAttribute('maxlength') || config.validation.maxLength;
200
- if (maxLength) {
201
- this.#textareaElement.maxLength = parseInt(String(maxLength), 10);
202
- }
203
- // Rows and columns
204
- const rows = this.getAttribute('rows') || 3;
205
- this.#textareaElement.rows = parseInt(String(rows), 10);
206
- const cols = this.getAttribute('cols');
207
- if (cols) {
208
- this.#textareaElement.cols = parseInt(cols, 10);
209
- }
210
- // Wrap attribute
211
- const wrap = this.getAttribute('wrap') || 'soft';
212
- this.#textareaElement.wrap = wrap;
213
- // CRITICAL SECURITY: Autocomplete control based on tier
214
- // For SENSITIVE and CRITICAL tiers, we disable autocomplete to prevent
215
- // browser storage of sensitive data
216
- if (config.storage.allowAutocomplete) {
217
- const autocomplete = this.getAttribute('autocomplete') || 'on';
218
- this.#textareaElement.autocomplete = autocomplete;
219
- }
220
- else {
221
- this.#textareaElement.autocomplete = 'off';
222
- }
223
- // Disabled state
224
- if (this.hasAttribute('disabled')) {
225
- this.#textareaElement.disabled = true;
226
- }
227
- // Readonly state
228
- if (this.hasAttribute('readonly')) {
229
- this.#textareaElement.readOnly = true;
230
- }
231
- // Initial value
232
- const value = this.getAttribute('value');
233
- if (value) {
234
- this.#textareaElement.value = value;
235
- }
236
- }
237
- /**
238
- * Attach event listeners to the textarea
239
- *
240
- * @private
241
- */
242
- #attachEventListeners() {
243
- // Focus event - audit logging + telemetry
244
- this.#textareaElement.addEventListener('focus', () => {
245
- this.recordTelemetryFocus();
246
- this.audit('textarea_focused', {
247
- name: this.#textareaElement.name
248
- });
249
- });
250
- // Input event - real-time validation and character counting + telemetry
251
- this.#textareaElement.addEventListener('input', (e) => {
252
- this.recordTelemetryInput(e);
253
- this.#handleInput(e);
254
- });
255
- // Blur event - final validation + telemetry
256
- this.#textareaElement.addEventListener('blur', () => {
257
- this.recordTelemetryBlur();
258
- this.#validateAndShowErrors();
259
- this.audit('textarea_blurred', {
260
- name: this.#textareaElement.name,
261
- hasValue: this.#textareaElement.value.length > 0
262
- });
263
- });
264
- // Change event - audit logging
265
- this.#textareaElement.addEventListener('change', () => {
266
- this.audit('textarea_changed', {
267
- name: this.#textareaElement.name,
268
- valueLength: this.#textareaElement.value.length
269
- });
270
- });
271
- }
272
- /**
273
- * Handle input events
274
- *
275
- * Security Note: This is where we implement real-time validation and character counting.
276
- *
277
- * @private
278
- */
279
- #handleInput(_event) {
280
- this.detectInjection(this.#textareaElement.value, this.#textareaElement.name);
281
- // Update character count
282
- this.#updateCharCount();
283
- // Clear previous errors on input (improve UX)
284
- this.#clearErrors();
285
- // Dispatch custom event for parent forms
286
- this.dispatchEvent(new CustomEvent('secure-textarea', {
287
- detail: {
288
- name: this.#textareaElement.name,
289
- value: this.#textareaElement.value,
290
- tier: this.securityTier
291
- },
292
- bubbles: true,
293
- composed: true
294
- }));
295
- }
296
- /**
297
- * Update character count display
298
- *
299
- * @private
300
- */
301
- #updateCharCount() {
302
- const currentLength = this.#textareaElement.value.length;
303
- const maxLength = this.#textareaElement.maxLength;
304
- if (maxLength > 0) {
305
- this.#charCountElement.textContent = `${currentLength} / ${maxLength}`;
306
- // Warn when approaching limit
307
- if (currentLength > maxLength * 0.9) {
308
- this.#charCountElement.classList.add('warning');
309
- }
310
- else {
311
- this.#charCountElement.classList.remove('warning');
312
- }
313
- }
314
- else {
315
- this.#charCountElement.textContent = `${currentLength}`;
316
- }
317
- }
318
- /**
319
- * Validate the textarea and show error messages
320
- *
321
- * @private
322
- */
323
- #validateAndShowErrors() {
324
- // Check rate limit first
325
- const rateLimitCheck = this.checkRateLimit();
326
- if (!rateLimitCheck.allowed) {
327
- this.#showError(`Too many attempts. Please wait ${Math.ceil(rateLimitCheck.retryAfter / 1000)} seconds.`);
328
- return;
329
- }
330
- // Perform validation
331
- const minLength = this.getAttribute('minlength');
332
- const maxLength = this.getAttribute('maxlength');
333
- const validation = this.validateInput(this.#textareaElement.value, {
334
- required: this.hasAttribute('required') || this.config.validation.required,
335
- minLength: minLength ? parseInt(minLength, 10) : 0,
336
- maxLength: maxLength ? parseInt(maxLength, 10) : this.config.validation.maxLength
337
- });
338
- if (!validation.valid) {
339
- this.#showError(validation.errors.join(', '));
340
- }
341
- }
342
- /**
343
- * Show error message
344
- *
345
- * @private
346
- */
347
- #showError(message) {
348
- this.#errorContainer.textContent = message;
349
- // Force reflow so browser registers the hidden state with content,
350
- // then remove hidden to trigger the CSS transition
351
- void this.#errorContainer.offsetHeight;
352
- this.#errorContainer.classList.remove('hidden');
353
- this.#textareaElement.classList.add('error');
354
- this.#textareaElement.setAttribute('aria-invalid', 'true');
355
- }
356
- /**
357
- * Clear error messages
358
- *
359
- * @private
360
- */
361
- #clearErrors() {
362
- // Start the hide animation first, clear text only after transition ends
363
- this.#errorContainer.classList.add('hidden');
364
- this.#errorContainer.addEventListener('transitionend', () => {
365
- if (this.#errorContainer.classList.contains('hidden')) {
366
- this.#errorContainer.textContent = '';
367
- }
368
- }, { once: true });
369
- this.#textareaElement.classList.remove('error');
370
- this.#textareaElement.removeAttribute('aria-invalid');
371
- }
372
- /**
373
- * Get component-specific styles
374
- *
375
- * @private
376
- */
377
- #getComponentStyles() {
378
- return new URL('./secure-textarea.css', import.meta.url).href;
379
- }
380
- /**
381
- * Handle attribute changes
382
- *
383
- * @protected
384
- */
385
- handleAttributeChange(name, _oldValue, newValue) {
386
- if (!this.#textareaElement)
387
- return;
388
- switch (name) {
389
- case 'disabled':
390
- this.#textareaElement.disabled = this.hasAttribute('disabled');
391
- break;
392
- case 'readonly':
393
- this.#textareaElement.readOnly = this.hasAttribute('readonly');
394
- break;
395
- case 'value':
396
- if (newValue !== this.#textareaElement.value) {
397
- this.#textareaElement.value = newValue || '';
398
- this.#updateCharCount();
399
- }
400
- break;
401
- }
402
- }
403
- /**
404
- * Get the current value
405
- *
406
- * @public
407
- */
408
- get value() {
409
- return this.#textareaElement ? this.#textareaElement.value : '';
410
- }
411
- /**
412
- * Set the value
413
- *
414
- * @public
415
- */
416
- set value(value) {
417
- if (this.#textareaElement) {
418
- this.#textareaElement.value = value || '';
419
- this.#updateCharCount();
420
- }
421
- }
422
- /**
423
- * Get the textarea name
424
- *
425
- * @public
426
- */
427
- get name() {
428
- return this.#textareaElement ? this.#textareaElement.name : '';
429
- }
430
- /**
431
- * Check if the textarea is valid
432
- *
433
- * @public
434
- */
435
- get valid() {
436
- const minLength = this.getAttribute('minlength');
437
- const maxLength = this.getAttribute('maxlength');
438
- const validation = this.validateInput(this.#textareaElement.value, {
439
- required: this.hasAttribute('required') || this.config.validation.required,
440
- minLength: minLength ? parseInt(minLength, 10) : 0,
441
- maxLength: maxLength ? parseInt(maxLength, 10) : this.config.validation.maxLength
442
- });
443
- return validation.valid;
444
- }
445
- /**
446
- * Focus the textarea
447
- *
448
- * @public
449
- */
450
- focus() {
451
- if (this.#textareaElement) {
452
- this.#textareaElement.focus();
453
- }
454
- }
455
- /**
456
- * Blur the textarea
457
- *
458
- * @public
459
- */
460
- blur() {
461
- if (this.#textareaElement) {
462
- this.#textareaElement.blur();
463
- }
464
- }
465
- /**
466
- * Cleanup on disconnect
467
- */
468
- /**
469
- * Show inline threat feedback inside the component's shadow DOM.
470
- * @protected
471
- */
472
- showThreatFeedback(patternId, tier) {
473
- if (!this.#threatContainer || !this.#textareaElement)
474
- return;
475
- this.#threatContainer.textContent = '';
476
- const msg = document.createElement('span');
477
- msg.className = 'threat-message';
478
- msg.textContent = this.getThreatLabel(patternId);
479
- const patternBadge = document.createElement('span');
480
- patternBadge.className = 'threat-badge';
481
- patternBadge.textContent = patternId;
482
- const tierBadge = document.createElement('span');
483
- tierBadge.className = `threat-tier threat-tier--${tier}`;
484
- tierBadge.textContent = tier;
485
- this.#threatContainer.appendChild(msg);
486
- this.#threatContainer.appendChild(patternBadge);
487
- this.#threatContainer.appendChild(tierBadge);
488
- void this.#threatContainer.offsetHeight;
489
- this.#threatContainer.classList.remove('hidden');
490
- this.#textareaElement.classList.add('threat');
491
- this.#textareaElement.setAttribute('aria-invalid', 'true');
492
- }
493
- /**
494
- * Clear the threat feedback container.
495
- * @protected
496
- */
497
- clearThreatFeedback() {
498
- if (!this.#threatContainer || !this.#textareaElement)
499
- return;
500
- this.#threatContainer.classList.add('hidden');
501
- this.#threatContainer.addEventListener('transitionend', () => {
502
- if (this.#threatContainer.classList.contains('hidden')) {
503
- this.#threatContainer.textContent = '';
504
- }
505
- }, { once: true });
506
- this.#textareaElement.classList.remove('threat');
507
- this.#textareaElement.removeAttribute('aria-invalid');
508
- }
509
- disconnectedCallback() {
510
- super.disconnectedCallback();
511
- // Clear sensitive data from memory
512
- if (this.#textareaElement) {
513
- this.#textareaElement.value = '';
514
- }
515
- }
516
- }
517
- // Define the custom element
518
- customElements.define('secure-textarea', SecureTextarea);
519
- export default SecureTextarea;
520
- //# sourceMappingURL=secure-textarea.js.map
30
+ */import{SecureBaseComponent as u}from"../../core/base-component.js";class h extends u{#t=null;#a=null;#i=null;#e=null;#s=null;#r=`secure-textarea-${Math.random().toString(36).substring(2,11)}`;static get observedAttributes(){return[...super.observedAttributes,"name","label","placeholder","required","minlength","maxlength","rows","cols","wrap","value"]}constructor(){super()}render(){const t=document.createDocumentFragment(),e=document.createElement("div");e.className="textarea-container",e.setAttribute("part","container");const i=this.getAttribute("label");i&&(this.#a=document.createElement("label"),this.#a.htmlFor=this.#r,this.#a.textContent=this.sanitizeValue(i),this.#a.setAttribute("part","label"),e.appendChild(this.#a));const s=document.createElement("div");return s.className="textarea-wrapper",s.setAttribute("part","wrapper"),this.#t=document.createElement("textarea"),this.#t.id=this.#r,this.#t.className="textarea-field",this.#t.setAttribute("part","textarea"),this.#l(),this.#o(),s.appendChild(this.#t),e.appendChild(s),this.#s=document.createElement("span"),this.#s.className="char-count",this.#n(),e.appendChild(this.#s),this.#i=document.createElement("div"),this.#i.className="error-container hidden",this.#i.setAttribute("role","alert"),this.#i.setAttribute("part","error"),this.#i.id=`${this.#r}-error`,e.appendChild(this.#i),this.#e=document.createElement("div"),this.#e.className="threat-container hidden",this.#e.setAttribute("role","alert"),this.#e.setAttribute("part","threat"),this.#e.id=`${this.#r}-threat`,e.appendChild(this.#e),this.addComponentStyles(this.#m()),t.appendChild(e),t}#l(){const t=this.config,e=this.getAttribute("name");e&&(this.#t.name=this.sanitizeValue(e)),!this.getAttribute("label")&&e&&this.#t.setAttribute("aria-label",this.sanitizeValue(e)),this.#t.setAttribute("aria-describedby",`${this.#r}-error`);const i=this.getAttribute("placeholder");i&&(this.#t.placeholder=this.sanitizeValue(i)),(this.hasAttribute("required")||t.validation.required)&&(this.#t.required=!0,this.#t.setAttribute("aria-required","true"));const s=this.getAttribute("minlength");s&&(this.#t.minLength=parseInt(s,10));const a=this.getAttribute("maxlength")||t.validation.maxLength;a&&(this.#t.maxLength=parseInt(String(a),10));const l=this.getAttribute("rows")||3;this.#t.rows=parseInt(String(l),10);const r=this.getAttribute("cols");r&&(this.#t.cols=parseInt(r,10));const o=this.getAttribute("wrap")||"soft";if(this.#t.wrap=o,t.storage.allowAutocomplete){const d=this.getAttribute("autocomplete")||"on";this.#t.autocomplete=d}else this.#t.autocomplete="off";this.hasAttribute("disabled")&&(this.#t.disabled=!0),this.hasAttribute("readonly")&&(this.#t.readOnly=!0);const n=this.getAttribute("value");n&&(this.#t.value=n)}#o(){this.#t.addEventListener("focus",()=>{this.recordTelemetryFocus(),this.audit("textarea_focused",{name:this.#t.name})}),this.#t.addEventListener("input",t=>{this.recordTelemetryInput(t),this.#d(t)}),this.#t.addEventListener("blur",()=>{this.recordTelemetryBlur(),this.#u(),this.audit("textarea_blurred",{name:this.#t.name,hasValue:this.#t.value.length>0})}),this.#t.addEventListener("change",()=>{this.audit("textarea_changed",{name:this.#t.name,valueLength:this.#t.value.length})})}#d(t){this.detectInjection(this.#t.value,this.#t.name),this.#n(),this.#c(),this.dispatchEvent(new CustomEvent("secure-textarea",{detail:{name:this.#t.name,value:this.#t.value,tier:this.securityTier},bubbles:!0,composed:!0}))}#n(){const t=this.#t.value.length,e=this.#t.maxLength;e>0?(this.#s.textContent=`${t} / ${e}`,t>e*.9?this.#s.classList.add("warning"):this.#s.classList.remove("warning")):this.#s.textContent=`${t}`}#u(){const t=this.checkRateLimit();if(!t.allowed){this.#h(`Too many attempts. Please wait ${Math.ceil(t.retryAfter/1e3)} seconds.`);return}const e=this.getAttribute("minlength"),i=this.getAttribute("maxlength"),s=this.validateInput(this.#t.value,{required:this.hasAttribute("required")||this.config.validation.required,minLength:e?parseInt(e,10):0,maxLength:i?parseInt(i,10):this.config.validation.maxLength});s.valid||this.#h(s.errors.join(", "))}#h(t){this.#i.textContent=t,this.#i.offsetHeight,this.#i.classList.remove("hidden"),this.#t.classList.add("error"),this.#t.setAttribute("aria-invalid","true")}#c(){this.#i.classList.add("hidden"),this.#i.addEventListener("transitionend",()=>{this.#i.classList.contains("hidden")&&(this.#i.textContent="")},{once:!0}),this.#t.classList.remove("error"),this.#t.removeAttribute("aria-invalid")}#m(){return new URL("./secure-textarea.css",import.meta.url).href}handleAttributeChange(t,e,i){if(this.#t)switch(t){case"disabled":this.#t.disabled=this.hasAttribute("disabled");break;case"readonly":this.#t.readOnly=this.hasAttribute("readonly");break;case"value":i!==this.#t.value&&(this.#t.value=i||"",this.#n());break}}get value(){return this.#t?this.#t.value:""}set value(t){this.#t&&(this.#t.value=t||"",this.#n())}get name(){return this.#t?this.#t.name:""}get valid(){const t=this.getAttribute("minlength"),e=this.getAttribute("maxlength");return this.validateInput(this.#t.value,{required:this.hasAttribute("required")||this.config.validation.required,minLength:t?parseInt(t,10):0,maxLength:e?parseInt(e,10):this.config.validation.maxLength}).valid}focus(){this.#t&&this.#t.focus()}blur(){this.#t&&this.#t.blur()}showThreatFeedback(t,e){if(!this.#e||!this.#t)return;this.#e.textContent="";const i=document.createElement("span");i.className="threat-message",i.textContent=this.getThreatLabel(t);const s=document.createElement("span");s.className="threat-badge",s.textContent=t;const a=document.createElement("span");a.className=`threat-tier threat-tier--${e}`,a.textContent=e,this.#e.appendChild(i),this.#e.appendChild(s),this.#e.appendChild(a),this.#e.offsetHeight,this.#e.classList.remove("hidden"),this.#t.classList.add("threat"),this.#t.setAttribute("aria-invalid","true")}clearThreatFeedback(){!this.#e||!this.#t||(this.#e.classList.add("hidden"),this.#e.addEventListener("transitionend",()=>{this.#e.classList.contains("hidden")&&(this.#e.textContent="")},{once:!0}),this.#t.classList.remove("threat"),this.#t.removeAttribute("aria-invalid"))}disconnectedCallback(){super.disconnectedCallback(),this.#t&&(this.#t.value="")}}customElements.define("secure-textarea",h);var p=h;export{h as SecureTextarea,p as default};