secure-ui-components 0.2.2 → 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.
- 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.css +67 -1
- package/dist/components/secure-input/secure-input.d.ts +14 -0
- package/dist/components/secure-input/secure-input.d.ts.map +1 -1
- package/dist/components/secure-input/secure-input.js +1 -805
- package/dist/components/secure-input/secure-input.js.map +1 -1
- 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.css +66 -1
- package/dist/components/secure-textarea/secure-textarea.d.ts +11 -0
- package/dist/components/secure-textarea/secure-textarea.d.ts.map +1 -1
- package/dist/components/secure-textarea/secure-textarea.js +1 -436
- package/dist/components/secure-textarea/secure-textarea.js.map +1 -1
- package/dist/core/base-component.d.ts +18 -0
- package/dist/core/base-component.d.ts.map +1 -1
- package/dist/core/base-component.js +1 -455
- package/dist/core/base-component.js.map +1 -1
- 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
|
@@ -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, '&')
|
|
285
|
-
.replace(/</g, '<')
|
|
286
|
-
.replace(/>/g, '>')
|
|
287
|
-
.replace(/"/g, '"')
|
|
288
|
-
.replace(/'/g, ''');
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}#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">\
|
|
341
|
-
<h3>${this.#
|
|
342
|
-
<p>${this.#
|
|
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.#
|
|
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="${
|
|
361
|
-
data-column="${this.#
|
|
362
|
-
${
|
|
13
|
+
class="${i.sortable!==!1?"sortable":""} ${o?"sorted":""}"
|
|
14
|
+
data-column="${this.#i(i.key)}"
|
|
15
|
+
${l}
|
|
363
16
|
>
|
|
364
|
-
${this.#
|
|
365
|
-
${
|
|
366
|
-
${
|
|
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
|
-
${
|
|
24
|
+
${s.map(i=>`
|
|
373
25
|
<tr>
|
|
374
|
-
${this.#
|
|
375
|
-
<td>${this.#
|
|
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 ${
|
|
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.#
|
|
39
|
+
<button class="pagination-button" id="prevBtn" ${this.#t.currentPage===1?"disabled":""}>
|
|
389
40
|
\u2190 Previous
|
|
390
41
|
</button>
|
|
391
|
-
${this.#
|
|
392
|
-
<button class="pagination-button" id="nextBtn" ${this.#
|
|
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(
|
|
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.#
|
|
59
|
+
value="${this.#i(this.#r)}"
|
|
424
60
|
id="searchInput"
|
|
425
61
|
/>
|
|
426
62
|
</div>
|
|
427
|
-
<div id="tableContent">${
|
|
428
|
-
<div id="paginationContent">${
|
|
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 ${
|
|
472
|
-
data-page="${
|
|
68
|
+
class="pagination-button ${a===this.#t.currentPage?"active":""}"
|
|
69
|
+
data-page="${a}"
|
|
473
70
|
>
|
|
474
|
-
${
|
|
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};
|