@design.estate/dees-catalog 3.63.0 → 3.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design.estate/dees-catalog",
3
- "version": "3.63.0",
3
+ "version": "3.64.0",
4
4
  "private": false,
5
5
  "description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
6
6
  "main": "dist_ts_web/index.js",
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@design.estate/dees-catalog',
6
- version: '3.63.0',
6
+ version: '3.64.0',
7
7
  description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
8
8
  }
@@ -184,6 +184,14 @@ export class DeesTable<T> extends DeesElement {
184
184
  accessor columnFilters: Record<string, string> = {};
185
185
  @property({ type: Boolean, attribute: 'show-column-filters' })
186
186
  accessor showColumnFilters: boolean = false;
187
+ /**
188
+ * When true, the table renders a leftmost checkbox column for click-driven
189
+ * (de)selection. Row selection by mouse (plain/shift/ctrl click) is always
190
+ * available regardless of this flag.
191
+ */
192
+ @property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
193
+ accessor showSelectionCheckbox: boolean = false;
194
+
187
195
  /**
188
196
  * When set, the table renders inside a fixed-height scroll container
189
197
  * (`max-height: var(--table-max-height, 360px)`) and the header sticks
@@ -209,9 +217,72 @@ export class DeesTable<T> extends DeesElement {
209
217
  accessor selectedIds: Set<string> = new Set();
210
218
  private _rowIdMap = new WeakMap<object, string>();
211
219
  private _rowIdCounter = 0;
220
+ /**
221
+ * Anchor row id for shift+click range selection. Set whenever the user
222
+ * makes a non-range click (plain or cmd/ctrl) so the next shift+click
223
+ * can compute a contiguous range from this anchor.
224
+ */
225
+ private __selectionAnchorId?: string;
212
226
 
213
227
  constructor() {
214
228
  super();
229
+ // Make the host focusable so it can receive Ctrl/Cmd+C for copy.
230
+ if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
231
+ this.addEventListener('keydown', this.__handleHostKeydown);
232
+ }
233
+
234
+ /**
235
+ * Ctrl/Cmd+C copies the currently selected rows as a JSON array. Falls
236
+ * back to copying the focused-row (`selectedDataRow`) if no multi
237
+ * selection exists. No-op if a focused input/textarea would normally
238
+ * receive the copy.
239
+ */
240
+ private __handleHostKeydown = (eventArg: KeyboardEvent) => {
241
+ const isCopy = (eventArg.metaKey || eventArg.ctrlKey) && (eventArg.key === 'c' || eventArg.key === 'C');
242
+ if (!isCopy) return;
243
+ // Don't hijack copy when the user is selecting text in an input/textarea.
244
+ const path = (eventArg.composedPath?.() || []) as EventTarget[];
245
+ for (const t of path) {
246
+ const tag = (t as HTMLElement)?.tagName;
247
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
248
+ if ((t as HTMLElement)?.isContentEditable) return;
249
+ }
250
+ const rows: T[] = [];
251
+ if (this.selectedIds.size > 0) {
252
+ for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
253
+ } else if (this.selectedDataRow) {
254
+ rows.push(this.selectedDataRow);
255
+ }
256
+ if (rows.length === 0) return;
257
+ eventArg.preventDefault();
258
+ this.__writeRowsAsJson(rows);
259
+ };
260
+
261
+ /**
262
+ * Copies the current selection as a JSON array. If `fallbackRow` is given
263
+ * and there is no multi-selection, that row is copied instead. Used both
264
+ * by the Ctrl/Cmd+C handler and by the default context-menu action.
265
+ */
266
+ public copySelectionAsJson(fallbackRow?: T) {
267
+ const rows: T[] = [];
268
+ if (this.selectedIds.size > 0) {
269
+ for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
270
+ } else if (fallbackRow) {
271
+ rows.push(fallbackRow);
272
+ } else if (this.selectedDataRow) {
273
+ rows.push(this.selectedDataRow);
274
+ }
275
+ if (rows.length === 0) return;
276
+ this.__writeRowsAsJson(rows);
277
+ }
278
+
279
+ private __writeRowsAsJson(rows: T[]) {
280
+ try {
281
+ const json = JSON.stringify(rows, null, 2);
282
+ navigator.clipboard?.writeText(json);
283
+ } catch {
284
+ /* ignore — clipboard may be unavailable */
285
+ }
215
286
  }
216
287
 
217
288
  public static styles = tableStyles;
@@ -319,15 +390,11 @@ export class DeesTable<T> extends DeesElement {
319
390
  };
320
391
  return html`
321
392
  <tr
322
- @click=${() => {
323
- this.selectedDataRow = itemArg;
324
- if (this.selectionMode === 'single') {
325
- const id = this.getRowId(itemArg);
326
- this.selectedIds.clear();
327
- this.selectedIds.add(id);
328
- this.emitSelectionChange();
329
- this.requestUpdate();
330
- }
393
+ @click=${(e: MouseEvent) => this.handleRowClick(e, itemArg, rowIndex, viewData)}
394
+ @mousedown=${(e: MouseEvent) => {
395
+ // Prevent the browser's native shift-click text
396
+ // selection so range-select doesn't highlight text.
397
+ if (e.shiftKey && this.selectionMode !== 'single') e.preventDefault();
331
398
  }}
332
399
  @dragenter=${async (eventArg: DragEvent) => {
333
400
  eventArg.preventDefault();
@@ -362,27 +429,51 @@ export class DeesTable<T> extends DeesElement {
362
429
  }
363
430
  }}
364
431
  @contextmenu=${async (eventArg: MouseEvent) => {
365
- DeesContextmenu.openContextMenuWithOptions(
366
- eventArg,
367
- this.getActionsForType('contextmenu').map((action) => {
368
- const menuItem: plugins.tsclass.website.IMenuItem = {
369
- name: action.name,
370
- iconName: action.iconName as any,
371
- action: async () => {
372
- await action.actionFunc({
373
- item: itemArg,
374
- table: this,
375
- });
376
- return null;
377
- },
378
- };
379
- return menuItem;
380
- })
381
- );
432
+ // If the right-clicked row isn't part of the
433
+ // current selection, treat it like a plain click
434
+ // first so the context menu acts on a sensible
435
+ // selection (matches file-manager behavior).
436
+ if (!this.isRowSelected(itemArg)) {
437
+ this.selectedDataRow = itemArg;
438
+ this.selectedIds.clear();
439
+ this.selectedIds.add(this.getRowId(itemArg));
440
+ this.__selectionAnchorId = this.getRowId(itemArg);
441
+ this.emitSelectionChange();
442
+ this.requestUpdate();
443
+ }
444
+ const userItems: plugins.tsclass.website.IMenuItem[] =
445
+ this.getActionsForType('contextmenu').map((action) => ({
446
+ name: action.name,
447
+ iconName: action.iconName as any,
448
+ action: async () => {
449
+ await action.actionFunc({
450
+ item: itemArg,
451
+ table: this,
452
+ });
453
+ return null;
454
+ },
455
+ }));
456
+ const defaultItems: plugins.tsclass.website.IMenuItem[] = [
457
+ {
458
+ name:
459
+ this.selectedIds.size > 1
460
+ ? `Copy ${this.selectedIds.size} rows as JSON`
461
+ : 'Copy row as JSON',
462
+ iconName: 'lucide:Copy' as any,
463
+ action: async () => {
464
+ this.copySelectionAsJson(itemArg);
465
+ return null;
466
+ },
467
+ },
468
+ ];
469
+ DeesContextmenu.openContextMenuWithOptions(eventArg, [
470
+ ...userItems,
471
+ ...defaultItems,
472
+ ]);
382
473
  }}
383
- class="${itemArg === this.selectedDataRow ? 'selected' : ''}"
474
+ class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
384
475
  >
385
- ${this.selectionMode !== 'none'
476
+ ${this.showSelectionCheckbox
386
477
  ? html`<td style="width:42px; text-align:center;">
387
478
  <dees-input-checkbox
388
479
  .value=${this.isRowSelected(itemArg)}
@@ -502,7 +593,7 @@ export class DeesTable<T> extends DeesElement {
502
593
  private renderHeaderRows(effectiveColumns: Column<T>[]): TemplateResult {
503
594
  return html`
504
595
  <tr>
505
- ${this.selectionMode !== 'none'
596
+ ${this.showSelectionCheckbox
506
597
  ? html`
507
598
  <th style="width:42px; text-align:center;">
508
599
  ${this.selectionMode === 'multi'
@@ -547,7 +638,7 @@ export class DeesTable<T> extends DeesElement {
547
638
  </tr>
548
639
  ${this.showColumnFilters
549
640
  ? html`<tr class="filtersRow">
550
- ${this.selectionMode !== 'none'
641
+ ${this.showSelectionCheckbox
551
642
  ? html`<th style="width:42px;"></th>`
552
643
  : html``}
553
644
  ${effectiveColumns
@@ -1302,6 +1393,74 @@ export class DeesTable<T> extends DeesElement {
1302
1393
  this.requestUpdate();
1303
1394
  }
1304
1395
 
1396
+ /**
1397
+ * Handles row clicks with file-manager style selection semantics:
1398
+ * - plain click: select only this row, set anchor
1399
+ * - cmd/ctrl+click: toggle this row in/out, set anchor
1400
+ * - shift+click: select the contiguous range from the anchor to this row
1401
+ *
1402
+ * Multi-row click selection is always available (`selectionMode === 'none'`
1403
+ * and `'multi'` both behave this way) so consumers can always copy a set
1404
+ * of rows. Only `selectionMode === 'single'` restricts to one row.
1405
+ */
1406
+ private handleRowClick(eventArg: MouseEvent, item: T, rowIndex: number, view: T[]) {
1407
+ const id = this.getRowId(item);
1408
+
1409
+ if (this.selectionMode === 'single') {
1410
+ this.selectedDataRow = item;
1411
+ this.selectedIds.clear();
1412
+ this.selectedIds.add(id);
1413
+ this.__selectionAnchorId = id;
1414
+ this.emitSelectionChange();
1415
+ this.requestUpdate();
1416
+ return;
1417
+ }
1418
+
1419
+ // multi
1420
+ const isToggle = eventArg.metaKey || eventArg.ctrlKey;
1421
+ const isRange = eventArg.shiftKey;
1422
+
1423
+ if (isRange && this.__selectionAnchorId !== undefined) {
1424
+ // Clear any text selection the browser may have created.
1425
+ window.getSelection?.()?.removeAllRanges();
1426
+ const anchorIdx = view.findIndex((r) => this.getRowId(r) === this.__selectionAnchorId);
1427
+ if (anchorIdx >= 0) {
1428
+ const [a, b] = anchorIdx <= rowIndex ? [anchorIdx, rowIndex] : [rowIndex, anchorIdx];
1429
+ this.selectedIds.clear();
1430
+ for (let i = a; i <= b; i++) this.selectedIds.add(this.getRowId(view[i]));
1431
+ } else {
1432
+ // Anchor no longer in view (filter changed, etc.) — fall back to single select.
1433
+ this.selectedIds.clear();
1434
+ this.selectedIds.add(id);
1435
+ this.__selectionAnchorId = id;
1436
+ }
1437
+ this.selectedDataRow = item;
1438
+ } else if (isToggle) {
1439
+ const wasSelected = this.selectedIds.has(id);
1440
+ if (wasSelected) {
1441
+ this.selectedIds.delete(id);
1442
+ // If we just deselected the focused row, move focus to another
1443
+ // selected row (or clear it) so the highlight goes away.
1444
+ if (this.selectedDataRow === item) {
1445
+ const remaining = view.find((r) => this.selectedIds.has(this.getRowId(r)));
1446
+ this.selectedDataRow = remaining as T;
1447
+ }
1448
+ } else {
1449
+ this.selectedIds.add(id);
1450
+ this.selectedDataRow = item;
1451
+ }
1452
+ this.__selectionAnchorId = id;
1453
+ } else {
1454
+ this.selectedDataRow = item;
1455
+ this.selectedIds.clear();
1456
+ this.selectedIds.add(id);
1457
+ this.__selectionAnchorId = id;
1458
+ }
1459
+
1460
+ this.emitSelectionChange();
1461
+ this.requestUpdate();
1462
+ }
1463
+
1305
1464
  private setRowSelected(row: T, checked: boolean) {
1306
1465
  const id = this.getRowId(row);
1307
1466
  if (this.selectionMode === 'single') {
@@ -196,6 +196,7 @@ export const tableStyles: CSSResult[] = [
196
196
  tbody tr {
197
197
  transition: background-color 0.15s ease;
198
198
  position: relative;
199
+ user-select: none;
199
200
  }
200
201
 
201
202
  /* Default horizontal lines (bottom border only) */