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.
@@ -1,416 +1,52 @@
1
- /**
2
- * Secure Table Component
3
- *
4
- * A security-aware data table component with filtering, sorting, and pagination.
5
- *
6
- * Features:
7
- * - Real-time filtering/search across all columns
8
- * - Column sorting (ascending/descending)
9
- * - Pagination
10
- * - Security tier-based column masking
11
- * - XSS prevention via sanitization
12
- * - Audit logging for data access
13
- *
14
- * @example
15
- * <secure-table
16
- * id="userTable"
17
- * security-tier="sensitive"
18
- * ></secure-table>
19
- *
20
- * // Set data programmatically
21
- * const table = document.getElementById('userTable');
22
- * table.data = [
23
- * { id: 1, name: 'John', email: 'john@example.com' },
24
- * { id: 2, name: 'Jane', email: 'jane@example.com' }
25
- * ];
26
- * table.columns = [
27
- * { key: 'id', label: 'ID', sortable: true },
28
- * { key: 'name', label: 'Name', sortable: true, filterable: true },
29
- * { key: 'email', label: 'Email', sortable: true, filterable: true, tier: 'sensitive' }
30
- * ];
31
- */
32
- import { SecureBaseComponent } from '../../core/base-component.js';
33
- import { SecurityTier } from '../../core/security-config.js';
34
- export class SecureTable extends SecureBaseComponent {
35
- /**
36
- * Data array for the table
37
- * @private
38
- */
39
- #data = [];
40
- /**
41
- * Column configuration
42
- * @private
43
- */
44
- #columns = [];
45
- /**
46
- * Filtered data after applying search
47
- * @private
48
- */
49
- #filteredData = [];
50
- /**
51
- * Current filter/search term
52
- * @private
53
- */
54
- #filterTerm = '';
55
- /**
56
- * Current sort configuration
57
- * @private
58
- */
59
- #sortConfig = { column: null, direction: 'asc' };
60
- /**
61
- * Pagination state
62
- * @private
63
- */
64
- #pagination = { currentPage: 1, pageSize: 10 };
65
- /**
66
- * Whether the component is using slotted server-rendered content
67
- * @private
68
- */
69
- #usingSlottedContent = false;
70
- constructor() {
71
- super();
72
- }
73
- /**
74
- * Required by abstract base class, but this component manages its own rendering
75
- * via the private #render() method.
76
- * @protected
77
- */
78
- render() {
79
- return null;
80
- }
81
- /**
82
- * Component lifecycle - called when added to DOM
83
- */
84
- connectedCallback() {
85
- // Initialize security tier, config, and audit - but skip the base render
86
- // lifecycle since the table manages its own innerHTML-based rendering for
87
- // dynamic sort/filter/pagination updates.
88
- this.initializeSecurity();
89
- // Try to parse server-rendered content first (progressive enhancement)
90
- const slottedTable = this.querySelector('table[slot="table"]');
91
- const parsed = this.#parseSlottedTable();
92
- if (parsed) {
93
- this.#usingSlottedContent = true;
94
- this.#columns = parsed.columns;
95
- this.#data = parsed.data;
96
- this.#filteredData = [...parsed.data];
97
- // Remove the server-rendered table from light DOM now that data is extracted
98
- if (slottedTable) {
99
- slottedTable.remove();
100
- }
101
- }
102
- this.#render();
103
- this.audit('table_mounted', {
104
- rowCount: this.#data.length,
105
- columnCount: this.#columns.length,
106
- usingSlottedContent: this.#usingSlottedContent
107
- });
108
- }
109
- /**
110
- * Parse server-rendered table from light DOM slot
111
- * @private
112
- */
113
- #parseSlottedTable() {
114
- const slottedTable = this.querySelector('table[slot="table"]');
115
- if (!slottedTable)
116
- return null;
117
- try {
118
- // Extract columns from <thead>
119
- const headers = slottedTable.querySelectorAll('thead th');
120
- if (headers.length === 0)
121
- return null;
122
- const columns = Array.from(headers).map(th => {
123
- const key = th.getAttribute('data-key') || th.textContent.trim().toLowerCase().replace(/\s+/g, '_');
124
- return {
125
- key: key,
126
- label: th.textContent.trim().replace(/\s+$/, ''), // Remove trailing spaces/badges
127
- sortable: th.hasAttribute('data-sortable') ? th.getAttribute('data-sortable') !== 'false' : true,
128
- filterable: th.hasAttribute('data-filterable') ? th.getAttribute('data-filterable') !== 'false' : undefined,
129
- tier: (th.getAttribute('data-tier') || undefined),
130
- width: th.getAttribute('data-width') || undefined,
131
- render: th.hasAttribute('data-render-html') ? this.#createRenderFunction(th) : undefined
132
- };
133
- });
134
- // Extract data from <tbody>
135
- const rows = slottedTable.querySelectorAll('tbody tr');
136
- const data = Array.from(rows).map((tr, _rowIndex) => {
137
- const cells = tr.querySelectorAll('td');
138
- const row = Object.create(null);
139
- cells.forEach((td, index) => {
140
- if (index < columns.length) {
141
- const column = columns[index];
142
- const dataKey = td.getAttribute('data-key') || column.key;
143
- // Guard against prototype pollution via attacker-controlled data-key attributes
144
- if (dataKey === '__proto__' || dataKey === 'constructor' || dataKey === 'prototype') {
145
- return;
146
- }
147
- // Store both text content and HTML if needed
148
- if (td.innerHTML.trim().includes('<')) {
149
- // Cell contains HTML (like forms, badges, etc.)
150
- row[dataKey] = td.textContent?.trim() ?? '';
151
- row[`${dataKey}_html`] = td.innerHTML.trim();
152
- }
153
- else {
154
- row[dataKey] = td.textContent?.trim() ?? '';
155
- }
156
- }
157
- });
158
- return row;
159
- });
160
- return { columns, data };
161
- }
162
- catch (error) {
163
- console.error('SecureTable: Error parsing slotted table', error);
164
- return null;
165
- }
166
- }
167
- /**
168
- * Create a render function for HTML content
169
- * @private
170
- */
171
- #createRenderFunction(_th) {
172
- return (value, row, columnKey) => {
173
- const htmlKey = `${columnKey}_html`;
174
- return (Object.hasOwn(row, htmlKey) ? row[htmlKey] : null) || this.#sanitize(value);
175
- };
176
- }
177
- /**
178
- * Set table data
179
- */
180
- set data(data) {
181
- if (!Array.isArray(data)) {
182
- return;
183
- }
184
- this.#data = data;
185
- this.#filteredData = [...data];
186
- this.#render();
187
- }
188
- /**
189
- * Get table data
190
- */
191
- get data() {
192
- return this.#data;
193
- }
194
- /**
195
- * Set column configuration
196
- */
197
- set columns(columns) {
198
- if (!Array.isArray(columns)) {
199
- return;
200
- }
201
- this.#columns = columns;
202
- this.#render();
203
- }
204
- /**
205
- * Get column configuration
206
- */
207
- get columns() {
208
- return this.#columns;
209
- }
210
- /**
211
- * Apply filter to data
212
- * @private
213
- */
214
- #applyFilter(term) {
215
- this.#filterTerm = term.toLowerCase();
216
- if (!this.#filterTerm) {
217
- this.#filteredData = [...this.#data];
218
- }
219
- else {
220
- this.#filteredData = this.#data.filter(row => {
221
- return this.#columns.some(col => {
222
- if (col.filterable === false)
223
- return false;
224
- const value = String(row[col.key] ?? '').toLowerCase();
225
- return value.includes(this.#filterTerm);
226
- });
227
- });
228
- }
229
- this.#pagination.currentPage = 1; // Reset to first page
230
- this.#updateTableContent();
231
- this.audit('table_filtered', {
232
- filterTerm: term,
233
- resultCount: this.#filteredData.length
234
- });
235
- }
236
- /**
237
- * Apply sorting to data
238
- * @private
239
- */
240
- #applySort(columnKey) {
241
- if (this.#sortConfig.column === columnKey) {
242
- // Toggle direction
243
- this.#sortConfig.direction = this.#sortConfig.direction === 'asc' ? 'desc' : 'asc';
244
- }
245
- else {
246
- this.#sortConfig.column = columnKey;
247
- this.#sortConfig.direction = 'asc';
248
- }
249
- this.#filteredData.sort((a, b) => {
250
- const aVal = Object.hasOwn(a, columnKey) ? a[columnKey] : undefined;
251
- const bVal = Object.hasOwn(b, columnKey) ? b[columnKey] : undefined;
252
- let comparison = 0;
253
- if (aVal > bVal)
254
- comparison = 1;
255
- if (aVal < bVal)
256
- comparison = -1;
257
- return this.#sortConfig.direction === 'asc' ? comparison : -comparison;
258
- });
259
- this.#updateTableContent();
260
- this.audit('table_sorted', {
261
- column: columnKey,
262
- direction: this.#sortConfig.direction
263
- });
264
- }
265
- /**
266
- * Change page
267
- * @private
268
- */
269
- #goToPage(pageNumber) {
270
- const totalPages = Math.ceil(this.#filteredData.length / this.#pagination.pageSize);
271
- if (pageNumber < 1 || pageNumber > totalPages)
272
- return;
273
- this.#pagination.currentPage = pageNumber;
274
- this.#updateTableContent();
275
- }
276
- /**
277
- * Simple HTML sanitization to prevent XSS
278
- * @private
279
- */
280
- #sanitize(str) {
281
- if (str === null || str === undefined || str === '')
282
- return '';
283
- return String(str)
284
- .replace(/&/g, '&amp;')
285
- .replace(/</g, '&lt;')
286
- .replace(/>/g, '&gt;')
287
- .replace(/"/g, '&quot;')
288
- .replace(/'/g, '&#x27;');
289
- }
290
- /**
291
- * Mask sensitive column values based on tier
292
- * @private
293
- */
294
- #maskValue(value, tier) {
295
- if (value === null || value === undefined || value === '')
296
- return '-';
297
- const strValue = String(value);
298
- if (tier === SecurityTier.SENSITIVE && strValue.length > 4) {
299
- return '\u2022'.repeat(strValue.length - 4) + strValue.slice(-4);
300
- }
301
- if (tier === SecurityTier.CRITICAL) {
302
- return '\u2022'.repeat(strValue.length);
303
- }
304
- return this.#sanitize(strValue);
305
- }
306
- /**
307
- * Render cell content with custom render function if available
308
- * @private
309
- */
310
- #renderCell(value, row, column) {
311
- // If column has custom render function, use it
312
- if (typeof column.render === 'function') {
313
- return column.render(value, row, column.key);
314
- }
315
- // Check if we have stored HTML content from server-rendered table
316
- const htmlKey = `${column.key}_html`;
317
- if (Object.hasOwn(row, htmlKey) && row[htmlKey]) {
318
- // Don't mask HTML content, it's already rendered
319
- return row[htmlKey];
320
- }
321
- // Otherwise, mask value based on security tier
322
- return this.#maskValue(value, column.tier);
323
- }
324
- /**
325
- * Generate the table body, thead, and pagination HTML
326
- * @private
327
- */
328
- #renderTableContent() {
329
- const totalPages = Math.ceil(this.#filteredData.length / this.#pagination.pageSize);
330
- const startIndex = (this.#pagination.currentPage - 1) * this.#pagination.pageSize;
331
- const endIndex = startIndex + this.#pagination.pageSize;
332
- const pageData = this.#filteredData.slice(startIndex, endIndex);
333
- let tableHtml;
334
- let paginationHtml;
335
- if (pageData.length === 0 || this.#columns.length === 0) {
336
- const emptyHeading = this.#columns.length === 0 ? 'No columns configured' : 'No results found';
337
- const emptyBody = this.#columns.length === 0 ? 'Set the columns property to configure the table' : 'Try adjusting your search term';
338
- tableHtml = `
1
+ import{SecureBaseComponent as p}from"../../core/base-component.js";import{SecurityTier as g}from"../../core/security-config.js";class b extends p{#s=[];#n=[];#e=[];#r="";#a={column:null,direction:"asc"};#t={currentPage:1,pageSize:10};#h=!1;constructor(){super()}render(){return null}connectedCallback(){this.initializeSecurity();const t=this.querySelector('table[slot="table"]'),e=this.#g();e&&(this.#h=!0,this.#n=e.columns,this.#s=e.data,this.#e=[...e.data],t&&t.remove()),this.#o(),this.audit("table_mounted",{rowCount:this.#s.length,columnCount:this.#n.length,usingSlottedContent:this.#h})}#g(){const t=this.querySelector('table[slot="table"]');if(!t)return null;try{const e=t.querySelectorAll("thead th");if(e.length===0)return null;const n=Array.from(e).map(a=>({key:a.getAttribute("data-key")||a.textContent.trim().toLowerCase().replace(/\s+/g,"_"),label:a.textContent.trim().replace(/\s+$/,""),sortable:a.hasAttribute("data-sortable")?a.getAttribute("data-sortable")!=="false":!0,filterable:a.hasAttribute("data-filterable")?a.getAttribute("data-filterable")!=="false":void 0,tier:a.getAttribute("data-tier")||void 0,width:a.getAttribute("data-width")||void 0,render:a.hasAttribute("data-render-html")?this.#p(a):void 0})),s=t.querySelectorAll("tbody tr"),r=Array.from(s).map((a,i)=>{const o=a.querySelectorAll("td"),c=Object.create(null);return o.forEach((l,d)=>{if(d<n.length){const u=n[d],h=l.getAttribute("data-key")||u.key;if(h==="__proto__"||h==="constructor"||h==="prototype")return;l.innerHTML.trim().includes("<")?(c[h]=l.textContent?.trim()??"",c[`${h}_html`]=l.innerHTML.trim()):c[h]=l.textContent?.trim()??""}}),c});return{columns:n,data:r}}catch(e){return console.error("SecureTable: Error parsing slotted table",e),null}}#p(t){return(e,n,s)=>{const r=`${s}_html`;return(Object.hasOwn(n,r)?n[r]:null)||this.#i(e)}}set data(t){Array.isArray(t)&&(this.#s=t,this.#e=[...t],this.#o())}get data(){return this.#s}set columns(t){Array.isArray(t)&&(this.#n=t,this.#o())}get columns(){return this.#n}#b(t){this.#r=t.toLowerCase(),this.#r?this.#e=this.#s.filter(e=>this.#n.some(n=>n.filterable===!1?!1:String(e[n.key]??"").toLowerCase().includes(this.#r))):this.#e=[...this.#s],this.#t.currentPage=1,this.#c(),this.audit("table_filtered",{filterTerm:t,resultCount:this.#e.length})}#f(t){this.#a.column===t?this.#a.direction=this.#a.direction==="asc"?"desc":"asc":(this.#a.column=t,this.#a.direction="asc"),this.#e.sort((e,n)=>{const s=Object.hasOwn(e,t)?e[t]:void 0,r=Object.hasOwn(n,t)?n[t]:void 0;let a=0;return s>r&&(a=1),s<r&&(a=-1),this.#a.direction==="asc"?a:-a}),this.#c(),this.audit("table_sorted",{column:t,direction:this.#a.direction})}#l(t){const e=Math.ceil(this.#e.length/this.#t.pageSize);t<1||t>e||(this.#t.currentPage=t,this.#c())}#i(t){return t==null||t===""?"":String(t).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;")}#m(t,e){if(t==null||t==="")return"-";const n=String(t);return e===g.SENSITIVE&&n.length>4?"\u2022".repeat(n.length-4)+n.slice(-4):e===g.CRITICAL?"\u2022".repeat(n.length):this.#i(n)}#y(t,e,n){if(typeof n.render=="function")return n.render(t,e,n.key);const s=`${n.key}_html`;return Object.hasOwn(e,s)&&e[s]?e[s]:this.#m(t,n.tier)}#d(){const t=Math.ceil(this.#e.length/this.#t.pageSize),e=(this.#t.currentPage-1)*this.#t.pageSize,n=e+this.#t.pageSize,s=this.#e.slice(e,n);let r,a;if(s.length===0||this.#n.length===0){const i=this.#n.length===0?"No columns configured":"No results found",o=this.#n.length===0?"Set the columns property to configure the table":"Try adjusting your search term";r=`
339
2
  <div class="empty-state">
340
- <div class="empty-state-icon" aria-hidden="true">\uD83D\uDD0D</div>
341
- <h3>${this.#sanitize(emptyHeading)}</h3>
342
- <p>${this.#sanitize(emptyBody)}</p>
343
- </div>`;
344
- paginationHtml = '';
345
- }
346
- else {
347
- tableHtml = `
3
+ <div class="empty-state-icon" aria-hidden="true">\u{1F50D}</div>
4
+ <h3>${this.#i(i)}</h3>
5
+ <p>${this.#i(o)}</p>
6
+ </div>`,a=""}else r=`
348
7
  <div class="table-wrapper">
349
8
  <table class="data-table">
350
9
  <thead>
351
10
  <tr>
352
- ${this.#columns.map(col => {
353
- const isSorted = this.#sortConfig.column === col.key;
354
- const sortArrow = isSorted ? (this.#sortConfig.direction === 'asc' ? '\u25B2' : '\u25BC') : '\u25B2';
355
- const ariaSortAttr = col.sortable !== false
356
- ? `aria-sort="${isSorted ? (this.#sortConfig.direction === 'asc' ? 'ascending' : 'descending') : 'none'}"`
357
- : '';
358
- return `
11
+ ${this.#n.map(i=>{const o=this.#a.column===i.key,c=o?this.#a.direction==="asc"?"\u25B2":"\u25BC":"\u25B2",l=i.sortable!==!1?`aria-sort="${o?this.#a.direction==="asc"?"ascending":"descending":"none"}"`:"";return`
359
12
  <th
360
- class="${col.sortable !== false ? 'sortable' : ''} ${isSorted ? 'sorted' : ''}"
361
- data-column="${this.#sanitize(col.key)}"
362
- ${ariaSortAttr}
13
+ class="${i.sortable!==!1?"sortable":""} ${o?"sorted":""}"
14
+ data-column="${this.#i(i.key)}"
15
+ ${l}
363
16
  >
364
- ${this.#sanitize(col.label)}
365
- ${col.sortable !== false ? `<span class="sort-indicator" aria-hidden="true">${sortArrow}</span>` : ''}
366
- ${col.tier ? `<span class="security-badge" aria-hidden="true">${this.#sanitize(col.tier)}</span>` : ''}
367
- </th>`;
368
- }).join('')}
17
+ ${this.#i(i.label)}
18
+ ${i.sortable!==!1?`<span class="sort-indicator" aria-hidden="true">${c}</span>`:""}
19
+ ${i.tier?`<span class="security-badge" aria-hidden="true">${this.#i(i.tier)}</span>`:""}
20
+ </th>`}).join("")}
369
21
  </tr>
370
22
  </thead>
371
23
  <tbody>
372
- ${pageData.map(row => `
24
+ ${s.map(i=>`
373
25
  <tr>
374
- ${this.#columns.map(col => `
375
- <td>${this.#renderCell(row[col.key], row, col)}</td>
376
- `).join('')}
26
+ ${this.#n.map(o=>`
27
+ <td>${this.#y(i[o.key],i,o)}</td>
28
+ `).join("")}
377
29
  </tr>
378
- `).join('')}
30
+ `).join("")}
379
31
  </tbody>
380
32
  </table>
381
- </div>`;
382
- paginationHtml = totalPages > 1 ? `
33
+ </div>`,a=t>1?`
383
34
  <div class="pagination">
384
35
  <div class="pagination-info">
385
- Showing ${startIndex + 1}-${Math.min(endIndex, this.#filteredData.length)} of ${this.#filteredData.length} results
36
+ Showing ${e+1}-${Math.min(n,this.#e.length)} of ${this.#e.length} results
386
37
  </div>
387
38
  <div class="pagination-controls">
388
- <button class="pagination-button" id="prevBtn" ${this.#pagination.currentPage === 1 ? 'disabled' : ''}>
39
+ <button class="pagination-button" id="prevBtn" ${this.#t.currentPage===1?"disabled":""}>
389
40
  \u2190 Previous
390
41
  </button>
391
- ${this.#renderPageNumbers(totalPages)}
392
- <button class="pagination-button" id="nextBtn" ${this.#pagination.currentPage === totalPages ? 'disabled' : ''}>
42
+ ${this.#v(t)}
43
+ <button class="pagination-button" id="nextBtn" ${this.#t.currentPage===t?"disabled":""}>
393
44
  Next \u2192
394
45
  </button>
395
46
  </div>
396
- </div>` : '';
397
- }
398
- return { tableHtml, paginationHtml };
399
- }
400
- /**
401
- * Full initial render of the table
402
- * @private
403
- */
404
- #render() {
405
- if (!this.shadowRoot)
406
- return;
407
- const { tableHtml, paginationHtml } = this.#renderTableContent();
408
- // Styles injected as <link> elements inside innerHTML — loads from 'self' (CSP-safe).
409
- // getBaseStylesheetUrl() uses import.meta.url from base-component.js so the path
410
- // resolves correctly regardless of where secure-table.js is located.
411
- this.shadowRoot.innerHTML = `
47
+ </div>`:"";return{tableHtml:r,paginationHtml:a}}#o(){if(!this.shadowRoot)return;const{tableHtml:t,paginationHtml:e}=this.#d();this.shadowRoot.innerHTML=`
412
48
  <link rel="stylesheet" href="${this.getBaseStylesheetUrl()}">
413
- <link rel="stylesheet" href="${new URL('./secure-table.css', import.meta.url).href}">
49
+ <link rel="stylesheet" href="${new URL("./secure-table.css",import.meta.url).href}">
414
50
  <!-- Slot for server-rendered table (fallback when JS fails to load) -->
415
51
  <slot name="table"></slot>
416
52
 
@@ -420,149 +56,18 @@ export class SecureTable extends SecureBaseComponent {
420
56
  type="search"
421
57
  class="search-input"
422
58
  placeholder="Search across all columns..."
423
- value="${this.#sanitize(this.#filterTerm)}"
59
+ value="${this.#i(this.#r)}"
424
60
  id="searchInput"
425
61
  />
426
62
  </div>
427
- <div id="tableContent">${tableHtml}</div>
428
- <div id="paginationContent">${paginationHtml}</div>
63
+ <div id="tableContent">${t}</div>
64
+ <div id="paginationContent">${e}</div>
429
65
  </div>
430
- `;
431
- // Attach event listeners
432
- this.#attachEventListeners();
433
- }
434
- /**
435
- * Partial update — only replaces table body and pagination, preserving search input focus.
436
- * @private
437
- */
438
- #updateTableContent() {
439
- if (!this.shadowRoot)
440
- return;
441
- const tableContainer = this.shadowRoot.getElementById('tableContent');
442
- const paginationContainer = this.shadowRoot.getElementById('paginationContent');
443
- if (!tableContainer) {
444
- // Fallback to full render if containers don't exist yet
445
- this.#render();
446
- return;
447
- }
448
- const { tableHtml, paginationHtml } = this.#renderTableContent();
449
- tableContainer.innerHTML = tableHtml;
450
- if (paginationContainer) {
451
- paginationContainer.innerHTML = paginationHtml;
452
- }
453
- // Re-attach listeners for table and pagination (search input listener is preserved)
454
- this.#attachTableEventListeners();
455
- }
456
- /**
457
- * Render page number buttons
458
- * @private
459
- */
460
- #renderPageNumbers(totalPages) {
461
- const maxButtons = 5;
462
- let startPage = Math.max(1, this.#pagination.currentPage - Math.floor(maxButtons / 2));
463
- const endPage = Math.min(totalPages, startPage + maxButtons - 1);
464
- if (endPage - startPage < maxButtons - 1) {
465
- startPage = Math.max(1, endPage - maxButtons + 1);
466
- }
467
- let buttons = '';
468
- for (let i = startPage; i <= endPage; i++) {
469
- buttons += `
66
+ `,this.#S()}#c(){if(!this.shadowRoot)return;const t=this.shadowRoot.getElementById("tableContent"),e=this.shadowRoot.getElementById("paginationContent");if(!t){this.#o();return}const{tableHtml:n,paginationHtml:s}=this.#d();t.innerHTML=n,e&&(e.innerHTML=s),this.#u()}#v(t){let n=Math.max(1,this.#t.currentPage-Math.floor(2.5));const s=Math.min(t,n+5-1);s-n<4&&(n=Math.max(1,s-5+1));let r="";for(let a=n;a<=s;a++)r+=`
470
67
  <button
471
- class="pagination-button ${i === this.#pagination.currentPage ? 'active' : ''}"
472
- data-page="${i}"
68
+ class="pagination-button ${a===this.#t.currentPage?"active":""}"
69
+ data-page="${a}"
473
70
  >
474
- ${i}
71
+ ${a}
475
72
  </button>
476
- `;
477
- }
478
- return buttons;
479
- }
480
- /**
481
- * Attach all event listeners (called on full render only)
482
- * @private
483
- */
484
- #attachEventListeners() {
485
- // Search input — only attached once on full render, preserved across partial updates
486
- const searchInput = this.shadowRoot.getElementById('searchInput');
487
- if (searchInput) {
488
- searchInput.addEventListener('input', (e) => {
489
- this.#applyFilter(e.target.value);
490
- });
491
- }
492
- // Table and pagination listeners
493
- this.#attachTableEventListeners();
494
- }
495
- /**
496
- * Attach event listeners for table headers and pagination (called on every update)
497
- * @private
498
- */
499
- #attachTableEventListeners() {
500
- // Column sorting
501
- const headers = this.shadowRoot.querySelectorAll('th.sortable');
502
- headers.forEach(th => {
503
- th.addEventListener('click', () => {
504
- const column = th.getAttribute('data-column');
505
- if (!column)
506
- return;
507
- this.#applySort(column);
508
- });
509
- });
510
- // Pagination
511
- const prevBtn = this.shadowRoot.getElementById('prevBtn');
512
- const nextBtn = this.shadowRoot.getElementById('nextBtn');
513
- if (prevBtn) {
514
- prevBtn.addEventListener('click', () => {
515
- this.#goToPage(this.#pagination.currentPage - 1);
516
- });
517
- }
518
- if (nextBtn) {
519
- nextBtn.addEventListener('click', () => {
520
- this.#goToPage(this.#pagination.currentPage + 1);
521
- });
522
- }
523
- // Page number buttons
524
- const pageButtons = this.shadowRoot.querySelectorAll('.pagination-button[data-page]');
525
- pageButtons.forEach(btn => {
526
- btn.addEventListener('click', () => {
527
- const page = parseInt(btn.getAttribute('data-page'), 10);
528
- this.#goToPage(page);
529
- });
530
- });
531
- // Action button delegation — dispatches 'table-action' CustomEvent on the host
532
- // element when any [data-action] element inside the table is clicked.
533
- // This allows page-level scripts to handle action buttons without needing
534
- // access to the closed shadow DOM.
535
- const tableContent = this.shadowRoot.getElementById('tableContent');
536
- if (tableContent) {
537
- tableContent.addEventListener('click', (e) => {
538
- const target = e.target.closest('[data-action]');
539
- if (!target)
540
- return;
541
- const action = target.getAttribute('data-action');
542
- // Collect all data-* attributes from the action element
543
- const detail = Object.create(null);
544
- detail['action'] = action;
545
- for (const attr of Array.from(target.attributes)) {
546
- if (attr.name.startsWith('data-') && attr.name !== 'data-action') {
547
- // Convert data-user-id to userId style key
548
- const key = attr.name.slice(5).replace(/-([a-z])/g, (_match, c) => c.toUpperCase());
549
- // Guard against prototype pollution via data-__proto__ style attributes
550
- if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
551
- continue;
552
- }
553
- detail[key] = attr.value;
554
- }
555
- }
556
- this.dispatchEvent(new CustomEvent('table-action', {
557
- bubbles: true,
558
- composed: true,
559
- detail
560
- }));
561
- this.audit('table_action', detail);
562
- });
563
- }
564
- }
565
- }
566
- // Register the custom element
567
- customElements.define('secure-table', SecureTable);
568
- //# sourceMappingURL=secure-table.js.map
73
+ `;return r}#S(){const t=this.shadowRoot.getElementById("searchInput");t&&t.addEventListener("input",e=>{this.#b(e.target.value)}),this.#u()}#u(){this.shadowRoot.querySelectorAll("th.sortable").forEach(a=>{a.addEventListener("click",()=>{const i=a.getAttribute("data-column");i&&this.#f(i)})});const e=this.shadowRoot.getElementById("prevBtn"),n=this.shadowRoot.getElementById("nextBtn");e&&e.addEventListener("click",()=>{this.#l(this.#t.currentPage-1)}),n&&n.addEventListener("click",()=>{this.#l(this.#t.currentPage+1)}),this.shadowRoot.querySelectorAll(".pagination-button[data-page]").forEach(a=>{a.addEventListener("click",()=>{const i=parseInt(a.getAttribute("data-page"),10);this.#l(i)})});const r=this.shadowRoot.getElementById("tableContent");r&&r.addEventListener("click",a=>{const i=a.target.closest("[data-action]");if(!i)return;const o=i.getAttribute("data-action"),c=Object.create(null);c.action=o;for(const l of Array.from(i.attributes))if(l.name.startsWith("data-")&&l.name!=="data-action"){const d=l.name.slice(5).replace(/-([a-z])/g,(u,h)=>h.toUpperCase());if(d==="__proto__"||d==="constructor"||d==="prototype")continue;c[d]=l.value}this.dispatchEvent(new CustomEvent("table-action",{bubbles:!0,composed:!0,detail:c})),this.audit("table_action",c)})}}customElements.define("secure-table",b);export{b as SecureTable};