@qnc/qnc_data_tables 1.3.1 → 1.3.2

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.
Files changed (85) hide show
  1. package/dist/bound_stored_value.d.ts +39 -0
  2. package/dist/bound_stored_value.js +135 -0
  3. package/dist/bound_stored_value.js.map +1 -0
  4. package/dist/bound_stored_value.ts +158 -0
  5. package/dist/column_manager.d.ts +45 -0
  6. package/dist/column_manager.js +125 -0
  7. package/dist/column_manager.js.map +1 -0
  8. package/dist/column_manager.ts +160 -0
  9. package/dist/column_resizing.d.ts +3 -0
  10. package/dist/column_resizing.js +31 -0
  11. package/dist/column_resizing.js.map +1 -0
  12. package/dist/column_resizing.ts +52 -0
  13. package/dist/column_sorting.d.ts +11 -0
  14. package/dist/column_sorting.js +54 -0
  15. package/dist/column_sorting.js.map +1 -0
  16. package/dist/column_sorting.ts +63 -0
  17. package/dist/conditionally_wrapped_element.d.ts +5 -0
  18. package/dist/conditionally_wrapped_element.js +15 -0
  19. package/dist/conditionally_wrapped_element.js.map +1 -0
  20. package/dist/conditionally_wrapped_element.ts +17 -0
  21. package/dist/create_mithril_app.d.ts +3 -0
  22. package/dist/create_mithril_app.js +26 -0
  23. package/dist/create_mithril_app.js.map +1 -0
  24. package/dist/create_mithril_app.ts +35 -0
  25. package/dist/create_style.d.ts +4 -0
  26. package/dist/create_style.js +10 -0
  27. package/dist/create_style.js.map +1 -0
  28. package/dist/create_style.ts +10 -0
  29. package/dist/custom_element.d.ts +23 -0
  30. package/dist/custom_element.js +64 -0
  31. package/dist/custom_element.js.map +1 -0
  32. package/dist/custom_element.ts +71 -0
  33. package/dist/event_names.d.ts +1 -0
  34. package/dist/event_names.js +2 -0
  35. package/dist/event_names.js.map +1 -0
  36. package/dist/event_names.ts +1 -0
  37. package/dist/index.d.ts +5 -0
  38. package/dist/index.js +5 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/index.ts +8 -0
  41. package/dist/mithril_view.d.ts +17 -0
  42. package/dist/mithril_view.js +548 -0
  43. package/dist/mithril_view.js.map +1 -0
  44. package/dist/mithril_view.ts +1138 -0
  45. package/dist/optional_storage.d.ts +6 -0
  46. package/dist/optional_storage.js +19 -0
  47. package/dist/optional_storage.js.map +1 -0
  48. package/dist/optional_storage.ts +23 -0
  49. package/dist/overflow_class_manager.d.ts +20 -0
  50. package/dist/overflow_class_manager.js +31 -0
  51. package/dist/overflow_class_manager.js.map +1 -0
  52. package/dist/overflow_class_manager.ts +40 -0
  53. package/dist/prepare_cell_html.d.ts +5 -0
  54. package/dist/prepare_cell_html.js +39 -0
  55. package/dist/prepare_cell_html.js.map +1 -0
  56. package/dist/prepare_cell_html.ts +48 -0
  57. package/dist/renderer.d.ts +16 -0
  58. package/dist/renderer.js +55 -0
  59. package/dist/renderer.js.map +1 -0
  60. package/dist/renderer.ts +89 -0
  61. package/dist/selection_fieldset_controller.d.ts +35 -0
  62. package/dist/selection_fieldset_controller.js +86 -0
  63. package/dist/selection_fieldset_controller.js.map +1 -0
  64. package/dist/selection_fieldset_controller.ts +104 -0
  65. package/dist/state_machine.d.ts +68 -0
  66. package/dist/state_machine.js +335 -0
  67. package/dist/state_machine.js.map +1 -0
  68. package/dist/state_machine.ts +458 -0
  69. package/dist/state_types.d.ts +62 -0
  70. package/dist/state_types.js +2 -0
  71. package/dist/state_types.js.map +1 -0
  72. package/dist/state_types.ts +84 -0
  73. package/dist/table_manager.d.ts +10 -0
  74. package/dist/table_manager.js +20 -0
  75. package/dist/table_manager.js.map +1 -0
  76. package/dist/table_manager.ts +32 -0
  77. package/dist/table_options.d.ts +177 -0
  78. package/dist/table_options.js +110 -0
  79. package/dist/table_options.js.map +1 -0
  80. package/dist/table_options.ts +150 -0
  81. package/dist/watched_mutable_value.d.ts +15 -0
  82. package/dist/watched_mutable_value.js +24 -0
  83. package/dist/watched_mutable_value.js.map +1 -0
  84. package/dist/watched_mutable_value.ts +23 -0
  85. package/package.json +1 -1
@@ -0,0 +1,1138 @@
1
+ import m, { Children, redraw, Vnode, VnodeDOM, trust } from "mithril";
2
+ import { AppState, SortState, SortStateInterpreter } from "./state_types.js";
3
+ import { Renderer } from "./renderer.js";
4
+ import { ExtraSortFunction } from "./table_options.js";
5
+ import { ConfiguredColumns, ConfiguredColumn } from "./column_manager.js";
6
+ import { enable } from "@qnc/drag_sorter2";
7
+ import { begin_header_drag } from "./column_resizing.js";
8
+ import { WatchedMutableValue } from "./watched_mutable_value.js";
9
+ import { QNCDialog } from "@qnc/qnc_dialog"; // access type definition
10
+ import "@qnc/qnc_dialog"; // import for side effects
11
+
12
+ function classes(...values: (string | false | null)[]) {
13
+ return values.filter(Boolean).join(" ");
14
+ }
15
+
16
+ type Updaters = {
17
+ set_page_size: (page_size: number) => void;
18
+ set_page_number: (page_number: number) => void;
19
+ set_column_enabled: (column_key: string, enabled: boolean) => void;
20
+ sort_by_function: (sort_function_key: string) => void;
21
+ sort_by_column: (
22
+ column_key: string,
23
+ direction: "forward" | "reverse",
24
+ ) => void;
25
+ set_column_width: (column_key: string, width: number) => void;
26
+ set_column_order: (keys: string[]) => void;
27
+ set_selected: (pk: string, selected: boolean) => void;
28
+ bulk_set_selected: (pks: Iterable<string>, selected: boolean) => void;
29
+ };
30
+
31
+ export function app_view(
32
+ state: AppState<string>,
33
+ renderer: Renderer,
34
+ updaters: Updaters,
35
+ ) {
36
+ return [
37
+ m("style", state.row_width_style_string.get()),
38
+ m(
39
+ "div",
40
+ { style: { marginBottom: "1em" } },
41
+ top_control_bar({
42
+ column_controls_button: dialog_column_controls_button(
43
+ state.columns,
44
+ updaters,
45
+ renderer,
46
+ ),
47
+ current_page: state.page_number,
48
+ set_page: updaters.set_page_number,
49
+ total_pages:
50
+ state.accessible_result_count !== null
51
+ ? Math.ceil(
52
+ state.accessible_result_count / state.page_size,
53
+ )
54
+ : null,
55
+ page_size: state.page_size,
56
+ set_page_size: updaters.set_page_size,
57
+ result_count: state.result_count,
58
+ result_count_loading: state.fetching_result_count,
59
+ accessible_result_count: state.accessible_result_count,
60
+ extra_sort_functions: state.extra_sort_functions,
61
+ sort_by_function: updaters.sort_by_function,
62
+ current_sort: state.current_sort,
63
+ }),
64
+ ),
65
+ // column_controls(state.columns, updaters, renderer),
66
+ m("form", [
67
+ render_with_loader(state.fetching_page_data, [
68
+ table(
69
+ state.columns,
70
+ updaters,
71
+ state.table_data,
72
+ renderer,
73
+ state.sort_state_interpreter,
74
+ state.fill_last_page,
75
+ state.page_size,
76
+ state.table_key,
77
+ state.include_selection_column,
78
+ state.table_overflows_left,
79
+ state.table_overflows_right,
80
+ state.row_width_style_string,
81
+ state.selected_pks,
82
+ ),
83
+ state.include_selection_column &&
84
+ selection_controls(state, updaters),
85
+ ]),
86
+ ]),
87
+ ];
88
+ }
89
+ type RowData = Readonly<
90
+ [
91
+ string, // row pk
92
+ ReadonlyMap<string, string>, // column key => cell html
93
+ ]
94
+ >;
95
+ function table(
96
+ columns: ConfiguredColumns,
97
+ updaters: Updaters,
98
+ data: ReadonlyArray<RowData>,
99
+ renderer: Renderer,
100
+ sort_state_interpreter: SortStateInterpreter,
101
+ fill_last_page: boolean,
102
+ page_size: number,
103
+ table_key: string,
104
+ include_selection_column: boolean,
105
+ table_overflows_left: WatchedMutableValue<boolean>,
106
+ table_overflows_right: WatchedMutableValue<boolean>,
107
+ row_width_style_string: WatchedMutableValue<string>,
108
+ selected_pks: ReadonlySet<string>,
109
+ ) {
110
+ return m(
111
+ "div",
112
+ {
113
+ class: classes(
114
+ `qnc-data-table qnc-data-table-${CSS.escape(table_key)}`,
115
+ table_overflows_left.get() && "qdt-overflows-left",
116
+ table_overflows_right.get() && "qdt-overflows-right",
117
+ ),
118
+ role: "table",
119
+ // style: all_columns().map(c=>`${column_width_property_name(c.key)}: ${c.width}px`).join(';')
120
+ },
121
+ m(
122
+ "div",
123
+ {
124
+ class: "qnc-data-table-head",
125
+ role: "rowgroup",
126
+ },
127
+ header_row({
128
+ columns,
129
+ updaters,
130
+ sort_state_interpreter,
131
+ table_key,
132
+ include_selection_column,
133
+ renderer,
134
+ table_overflows_left,
135
+ table_overflows_right,
136
+ row_width_style_string,
137
+ }),
138
+ ),
139
+ m(
140
+ "div",
141
+ {
142
+ class: "qnc-data-table-body",
143
+ role: "rowgroup",
144
+ },
145
+ table_rows(
146
+ columns,
147
+ data,
148
+ fill_last_page,
149
+ page_size,
150
+ table_key,
151
+ include_selection_column,
152
+ updaters,
153
+ selected_pks,
154
+ ),
155
+ ),
156
+ );
157
+ }
158
+ type HeaderAttrs = {
159
+ columns: ConfiguredColumns;
160
+ updaters: Updaters;
161
+ renderer: Renderer;
162
+ sort_state_interpreter: SortStateInterpreter;
163
+ table_key: string;
164
+ include_selection_column: boolean;
165
+ table_overflows_left: WatchedMutableValue<boolean>;
166
+ table_overflows_right: WatchedMutableValue<boolean>;
167
+ row_width_style_string: WatchedMutableValue<string>;
168
+ };
169
+
170
+ function HeaderComponent(initialVnode: Vnode) {
171
+ let sticky_style_string = "";
172
+
173
+ function setup_detector(
174
+ detector: Element,
175
+ watched_value: WatchedMutableValue<boolean>,
176
+ ) {
177
+ const table = detector.closest(".qnc-data-table");
178
+ if (!table) throw new Error("could not find parent table element");
179
+ new IntersectionObserver(
180
+ function (entries, observer) {
181
+ watched_value.set(!entries[0]?.isIntersecting);
182
+ },
183
+ {
184
+ root: table,
185
+ },
186
+ ).observe(detector);
187
+ }
188
+
189
+ // Note: layout tightlty coupled to recompute_sticky_style_string
190
+ function raw_header_row(
191
+ columns: ConfiguredColumns,
192
+ updaters: Updaters,
193
+ renderer: Renderer,
194
+ sort_state_interpreter: SortStateInterpreter,
195
+ table_key: string,
196
+ table_overflows_left: WatchedMutableValue<boolean>,
197
+ table_overflows_right: WatchedMutableValue<boolean>,
198
+ include_selection_column: boolean,
199
+ ) {
200
+ return m(
201
+ "div",
202
+ {
203
+ role: "row",
204
+ className: `qnc-data-table-row qnc-data-table-header-row qnc-data-table-row qnc-data-table-row-${CSS.escape(
205
+ table_key,
206
+ )}`,
207
+ },
208
+
209
+ // "overflow-left detector"
210
+ m(
211
+ "div",
212
+ {
213
+ role: "columnheader",
214
+ style: "padding: 0; width: 0; border: none;",
215
+ },
216
+ m("div", {
217
+ style: "width: 1px; height: 1px; background-color: transparent; pointerEvents: none;",
218
+ oncreate: (vnode) => {
219
+ setup_detector(vnode.dom, table_overflows_left);
220
+ },
221
+ }),
222
+ ),
223
+
224
+ include_selection_column && empty_checkbox_cell(table_key),
225
+
226
+ columns.fixed_enabled_columns.map((c) =>
227
+ header_cell(
228
+ renderer,
229
+ c,
230
+ sort_state_interpreter,
231
+ updaters,
232
+ true,
233
+ table_key,
234
+ ),
235
+ ),
236
+ m("div", {
237
+ role: "columnheader",
238
+ class: `qdt-overflow-indicator-cell qdt-overflow-indicator-cell-left qnc-data-table-fixed-cell-${CSS.escape(
239
+ table_key,
240
+ )}`,
241
+ }),
242
+ columns.sortable_enabled_columns.map((c) =>
243
+ header_cell(
244
+ renderer,
245
+ c,
246
+ sort_state_interpreter,
247
+ updaters,
248
+ false,
249
+ table_key,
250
+ ),
251
+ ),
252
+ empty_expanding_cell("columnheader"),
253
+ m("div", {
254
+ role: "columnheader",
255
+ class: `qdt-overflow-indicator-cell qdt-overflow-indicator-cell-right`,
256
+ }),
257
+
258
+ // "overflow-right detector"
259
+ m(
260
+ "div",
261
+ {
262
+ role: "columnheader",
263
+ style: "padding: 0; width: 0; border: none; position: relative;",
264
+ },
265
+ m("div", {
266
+ style: "position: absolute; right: 0; width: 1px; height: 1px; background-color: transparent; pointerEvents: none;",
267
+ oncreate: (vnode) => {
268
+ setup_detector(vnode.dom, table_overflows_right);
269
+ },
270
+ }),
271
+ ),
272
+ );
273
+ }
274
+ // Note: tightlty coupled to raw_header_row layout
275
+ function recompute_sticky_style_string(
276
+ header_element: Element,
277
+ include_selection_column: boolean,
278
+ columns: ConfiguredColumns,
279
+ table_key: string,
280
+ ) {
281
+ const old_value = sticky_style_string;
282
+ sticky_style_string = "";
283
+ const fixed_column_count =
284
+ (include_selection_column ? 1 : 0) +
285
+ columns.fixed_enabled_columns.length +
286
+ 1; // the "sticky divider" column, where box-shadow is rendered
287
+ let previous_width = 0;
288
+ const start_index = 1; // skip over the "scroll detector" column
289
+ let i = start_index; // i will range over "element index" of all fixed column headers
290
+ const end_index =
291
+ start_index +
292
+ fixed_column_count + // all fixed columns
293
+ 1; // left overflow indicator cell
294
+ while (i < end_index) {
295
+ const cell = header_element.children[i];
296
+ if (!cell) break;
297
+
298
+ sticky_style_string += `.qnc-data-table-fixed-cell-${CSS.escape(
299
+ table_key,
300
+ )}:nth-child(${i + 1}) {
301
+ position: sticky;
302
+ left: ${previous_width}px;
303
+ z-index: 2;
304
+ }`;
305
+ previous_width += cell.clientWidth;
306
+ i++;
307
+ }
308
+ return sticky_style_string != old_value;
309
+ }
310
+
311
+ /*
312
+ Rows need explicit min-width.
313
+ Otherwise, they are only as wide as table, and cells overflow.
314
+ This causes problems if:
315
+ 1. there are any background colours/borders on rows
316
+ 2. there are sticky columns, and row content is more than two table-widths wide (eventually, the sticky columns get pushed out view)
317
+
318
+ #1. can be solved by putting all colours/borders on cells, rather than rows, but issue #2 still requires fixed row widths.
319
+
320
+ While we _could_ compute the expected width based on columns.map(c => c.width), that approach would also require us to know the padding per cell (which might change based on media query). So let's just _measure_ the actual rendered width, instead.
321
+
322
+ Note: this isn't foolproof, either. In hindsight, I'd have preferred to just calculate this based on column widths (perhaps setting `box-sizing: border-box` on them, so we wouldn't have to worry about padding).
323
+ */
324
+ function get_row_width_style(header: Element, table_key: string): string {
325
+ let row_width = 0;
326
+ let cell = header.firstElementChild;
327
+
328
+ while (cell) {
329
+ // skip the "empty expanding column"; otherwise, row width will never shrink, even when it should
330
+ if (!cell.classList.contains("qdt-empty-expanding-cell")) {
331
+ row_width += cell.clientWidth;
332
+ }
333
+ cell = cell.nextElementSibling;
334
+ }
335
+ return `.qnc-data-table-row-${CSS.escape(
336
+ table_key,
337
+ )} {min-width: ${row_width}px;}`;
338
+ }
339
+
340
+ return {
341
+ oncreate(vnode: VnodeDOM) {
342
+ const {
343
+ columns,
344
+ include_selection_column,
345
+ table_key,
346
+ row_width_style_string,
347
+ } = vnode.attrs as HeaderAttrs;
348
+ row_width_style_string.set(
349
+ get_row_width_style(vnode.dom, table_key),
350
+ );
351
+ if (
352
+ recompute_sticky_style_string(
353
+ vnode.dom,
354
+ include_selection_column,
355
+ columns,
356
+ table_key,
357
+ )
358
+ )
359
+ redraw();
360
+ },
361
+ onupdate(vnode: VnodeDOM) {
362
+ const {
363
+ columns,
364
+ include_selection_column,
365
+ table_key,
366
+ row_width_style_string,
367
+ } = vnode.attrs as HeaderAttrs;
368
+ row_width_style_string.set(
369
+ get_row_width_style(vnode.dom, table_key),
370
+ );
371
+
372
+ // Optimization opportunity: we _could_ hash (column order, column widths, include_selection_count)
373
+ // and return early whenever unchanged
374
+ if (
375
+ recompute_sticky_style_string(
376
+ vnode.dom,
377
+ include_selection_column,
378
+ columns,
379
+ table_key,
380
+ )
381
+ )
382
+ redraw();
383
+ },
384
+ view(vnode: Vnode) {
385
+ const {
386
+ columns,
387
+ updaters,
388
+ renderer,
389
+ sort_state_interpreter,
390
+ table_key,
391
+ table_overflows_left,
392
+ table_overflows_right,
393
+ include_selection_column,
394
+ } = vnode.attrs as HeaderAttrs;
395
+ return [
396
+ raw_header_row(
397
+ columns,
398
+ updaters,
399
+ renderer,
400
+ sort_state_interpreter,
401
+ table_key,
402
+ table_overflows_left,
403
+ table_overflows_right,
404
+ include_selection_column,
405
+ ),
406
+ m("style", sticky_style_string),
407
+ ];
408
+ },
409
+ };
410
+ }
411
+ function header_row(attrs: HeaderAttrs) {
412
+ return (m as any)(HeaderComponent, attrs);
413
+ }
414
+
415
+ function header_cell(
416
+ renderer: Renderer,
417
+ column: ConfiguredColumn,
418
+ sort_state_interpreter: SortStateInterpreter,
419
+ updaters: Updaters,
420
+ fixed: boolean,
421
+ table_key: string,
422
+ ) {
423
+ const sort_direction = sort_state_interpreter(column.key);
424
+ return m(
425
+ "div",
426
+ {
427
+ role: "columnheader",
428
+ style: {
429
+ width: column.width + "px",
430
+ },
431
+ class: classes(
432
+ "qdt-cell",
433
+ "qdt-resizable-header",
434
+ fixed && `qnc-data-table-fixed-cell-${CSS.escape(table_key)}`,
435
+ ),
436
+ },
437
+
438
+ m(
439
+ "div", // serves two purposes: it has "overflow: hidden;", while the parent element has "overflow: visible" (to allow resize handle to overflow); also allows us to use flexbox for alignment
440
+ {
441
+ style: {
442
+ display: "flex",
443
+ justifyContent: column.horizontal_alignment,
444
+ alignItems: "center", // No strong opinion on default vertical alignment for headers; center seems reasonable
445
+ },
446
+ },
447
+ renderer.render_header({
448
+ label: renderer.render_with_optional_help_text(
449
+ column.display,
450
+ column.help_text,
451
+ ),
452
+ sortable: column.sortable,
453
+ sorted: sort_direction || "no",
454
+ set_sort: function (direction: "forward" | "reverse") {
455
+ updaters.sort_by_column(column.key, direction);
456
+ },
457
+ }),
458
+ ),
459
+
460
+ m("span", {
461
+ onmousedown: (e: MouseEvent) => {
462
+ e.preventDefault(); // prevent text highlighting
463
+
464
+ begin_header_drag(
465
+ e,
466
+ (width) => {
467
+ updaters.set_column_width(column.key, width);
468
+ m.redraw();
469
+ },
470
+ column.width,
471
+ () => {
472
+ m.redraw();
473
+ },
474
+ );
475
+ },
476
+ class: "qdt-column-resizer",
477
+ }),
478
+ );
479
+ }
480
+ function table_rows(
481
+ columns: ConfiguredColumns,
482
+ data: ReadonlyArray<RowData>,
483
+ fill_last_page: boolean,
484
+ page_size: number,
485
+ table_key: string,
486
+ include_selection_column: boolean,
487
+ updaters: Updaters,
488
+ selected_pks: ReadonlySet<string>,
489
+ ) {
490
+ return [
491
+ data.map((row) =>
492
+ table_row(
493
+ row,
494
+ columns,
495
+ table_key,
496
+ include_selection_column
497
+ ? checkbox_cell(row[0], updaters, selected_pks, table_key)
498
+ : null,
499
+ ),
500
+ ),
501
+ fill_last_page &&
502
+ Array.from(Array(Math.max(0, page_size - data.length))).map((x) =>
503
+ raw_table_row(
504
+ columns.fixed_enabled_columns.map((c) =>
505
+ data_cell(c, null, true, table_key),
506
+ ),
507
+ columns.sortable_enabled_columns.map((c) =>
508
+ data_cell(c, null, false, table_key),
509
+ ),
510
+ table_key,
511
+ null,
512
+ ),
513
+ ),
514
+ ];
515
+ }
516
+ function raw_table_row(
517
+ fixed_cells: Children,
518
+ sortable_cells: Children,
519
+ table_key: string,
520
+ checkbox_cell: Vnode | null,
521
+ ) {
522
+ return m(
523
+ "div",
524
+ {
525
+ role: "row",
526
+ className: `qnc-data-table-row qnc-data-table-body-row qnc-data-table-row qnc-data-table-row-${CSS.escape(
527
+ table_key,
528
+ )}`,
529
+ },
530
+
531
+ // Empty "scroll detector" column
532
+ m("div", {
533
+ role: "cell",
534
+ style: "width: 0px; padding: 0; border: none;",
535
+ }),
536
+
537
+ checkbox_cell,
538
+ // include_selection_column && checkbox_cell(pk, updaters, selected_pks),
539
+
540
+ fixed_cells,
541
+ m("div", {
542
+ role: "cell",
543
+ class: `qdt-overflow-indicator-cell qdt-overflow-indicator-cell-left qnc-data-table-fixed-cell-${CSS.escape(
544
+ table_key,
545
+ )}`,
546
+ }),
547
+ sortable_cells,
548
+
549
+ empty_expanding_cell(),
550
+
551
+ m("div", {
552
+ role: "cell",
553
+ class: `qdt-overflow-indicator-cell qdt-overflow-indicator-cell-right`,
554
+ }),
555
+
556
+ // Empty "overflow detector" column
557
+ m("div", {
558
+ role: "cell",
559
+ style: "width: 0px; padding: 0; border: none;",
560
+ }),
561
+ );
562
+ }
563
+
564
+ // #coupled-cell-functions: these functions are tightly coupled. TODO: move to a separate module
565
+ function data_cell(
566
+ column: ConfiguredColumn,
567
+ content: Children,
568
+ fixed: boolean,
569
+ table_key: string,
570
+ ) {
571
+ return m(
572
+ "div",
573
+ {
574
+ role: "cell",
575
+ style: {
576
+ width: column.width + "px",
577
+ "--qdt-cell-justify-content": column.horizontal_alignment,
578
+ "--qdt-cell-align-items": column.data_vertical_alignment,
579
+ },
580
+ class: classes(
581
+ "qdt-cell",
582
+ fixed && `qnc-data-table-fixed-cell-${CSS.escape(table_key)}`,
583
+ ),
584
+ },
585
+ content,
586
+ );
587
+ }
588
+ // Note: layout tightly coupled to HeaderRow
589
+ function table_row(
590
+ row: readonly [string, ReadonlyMap<string, string>],
591
+ columns: ConfiguredColumns,
592
+ table_key: string,
593
+ checkbox_cell: Vnode | null,
594
+ ) {
595
+ const [id, cell_map] = row;
596
+
597
+ return raw_table_row(
598
+ columns.fixed_enabled_columns.map((c) =>
599
+ data_cell(c, trust(cell_map.get(c.key) || ""), true, table_key),
600
+ ),
601
+ columns.sortable_enabled_columns.map((c) =>
602
+ data_cell(c, trust(cell_map.get(c.key) || ""), false, table_key),
603
+ ),
604
+ table_key,
605
+ checkbox_cell,
606
+ );
607
+ }
608
+
609
+ // #coupled-cell-functions: these functions are tightly coupled. TODO: move to a separate module
610
+ function empty_checkbox_cell(table_key: string) {
611
+ return m(
612
+ "div",
613
+ {
614
+ role: "columnheader",
615
+ style: { width: "auto" }, // This is the only auto-width column; it works because every row has the same content
616
+ className: `qdt-cell qnc-data-table-fixed-cell-${CSS.escape(
617
+ table_key,
618
+ )}`,
619
+ },
620
+ // Render an invisible checkbox, so this header cell takes up the same width as the rest of the checkbox cells
621
+ m(
622
+ "label",
623
+ { className: "qdt-row-checkbox-label qdt-flex-cell" },
624
+ m("input", {
625
+ type: "checkbox",
626
+ style: {
627
+ visibility: "hidden",
628
+ pointerEvents: "none",
629
+ },
630
+ tabIndex: -1,
631
+ }),
632
+ ),
633
+ );
634
+ }
635
+
636
+ // #coupled-cell-functions: these functions are tightly coupled. TODO: move to a separate module
637
+ function checkbox_cell(
638
+ pk: string,
639
+ updaters: Updaters,
640
+ selected_pks: ReadonlySet<string>,
641
+ table_key: string,
642
+ ) {
643
+ return m(
644
+ "div",
645
+ {
646
+ role: "cell",
647
+ style: { width: "auto" }, // This is the only auto-width column; it works because every row has the same content
648
+ className: `qdt-cell qnc-data-table-fixed-cell-${CSS.escape(
649
+ table_key,
650
+ )}`,
651
+ },
652
+ m(
653
+ "label",
654
+ { className: "qdt-row-checkbox-label qdt-flex-cell" },
655
+ m("input", {
656
+ type: "checkbox",
657
+ checked: selected_pks.has(pk),
658
+ onchange: (e: MouseEvent) => {
659
+ updaters.set_selected(pk, !selected_pks.has(pk));
660
+ // TODO: handle shift+clicking, like django_data_tables did
661
+ },
662
+ }),
663
+ ),
664
+ );
665
+ }
666
+ function empty_expanding_cell(role = "cell") {
667
+ return m("div", {
668
+ style: { flexGrow: "1", padding: "0", flexBasis: "0px" },
669
+ class: "qdt-empty-expanding-cell",
670
+ // Just in case users are using this in their selector for default cell background/border
671
+ role: role,
672
+ });
673
+ }
674
+
675
+ function extra_sort_function_widget(
676
+ current_sort: SortState | null,
677
+ extra_sort_functions: ExtraSortFunction[],
678
+ sort_by_function: (function_key: string) => void,
679
+ ) {
680
+ // TODO: update checkbox-dropdown to work with radio buttons, publish on npm, document that it should always be included as a peer dependency
681
+ // use that, so we can make help text smaller, and not show in "summary"
682
+ const current_function_key =
683
+ current_sort &&
684
+ current_sort.type == "function" &&
685
+ current_sort.function_key;
686
+ return m(
687
+ "label",
688
+ "sort by: ",
689
+ m(
690
+ "select",
691
+ {
692
+ onchange: (e: Event) => {
693
+ const key = (e.target as HTMLSelectElement).value;
694
+ if (key) sort_by_function(key);
695
+ },
696
+ },
697
+ current_sort == null && m("option", "-----"),
698
+ current_sort &&
699
+ current_sort.type == "column" &&
700
+ m("option", "(selected column)"),
701
+ extra_sort_functions.map((esf) =>
702
+ m(
703
+ "option",
704
+ {
705
+ selected: current_function_key == esf.key,
706
+ value: esf.key,
707
+ },
708
+ esf.display,
709
+ esf.help_text && ` (${esf.help_text})`,
710
+ ),
711
+ ),
712
+ ),
713
+ );
714
+ }
715
+
716
+ function top_control_bar(options: {
717
+ column_controls_button: Vnode;
718
+ current_page: number;
719
+ set_page: (page: number) => void;
720
+ total_pages: number | null;
721
+ page_size: number;
722
+ set_page_size: (size: number) => void;
723
+ result_count: number | null;
724
+ result_count_loading: boolean;
725
+ accessible_result_count: number | null;
726
+ sort_by_function: (function_key: string) => void;
727
+ extra_sort_functions: ExtraSortFunction[];
728
+ current_sort: SortState | null;
729
+ }) {
730
+ function space_horizontally(gap: string, items: m.Children[]) {
731
+ return m(
732
+ "div",
733
+ { style: { display: "flex", columnGap: gap, flexWrap: "wrap" } },
734
+ items.map((item) => m("div", item, " ")),
735
+ );
736
+ }
737
+
738
+ const {
739
+ current_page,
740
+ set_page,
741
+ total_pages,
742
+ page_size,
743
+ set_page_size,
744
+ result_count,
745
+ result_count_loading,
746
+ accessible_result_count,
747
+ } = options;
748
+
749
+ const paginator = [
750
+ m(
751
+ "button",
752
+ {
753
+ type: "button",
754
+ disabled: current_page <= 1,
755
+ onclick: (e: MouseEvent) => set_page(current_page - 1),
756
+ },
757
+ "<",
758
+ ),
759
+ " Page ",
760
+ m("input", {
761
+ type: "number",
762
+ value: current_page,
763
+ max: total_pages,
764
+ style: { width: "3.5em" },
765
+ onchange: function (e: Event) {
766
+ set_page(parseInt((e.target as HTMLInputElement).value));
767
+ },
768
+ }),
769
+ " of ",
770
+ total_pages ?? "?",
771
+ " ",
772
+ m(
773
+ "button",
774
+ {
775
+ type: "button",
776
+ disabled: current_page >= (total_pages ?? 1),
777
+ onclick: (e: MouseEvent) => set_page(current_page + 1),
778
+ },
779
+ ">",
780
+ ),
781
+ ];
782
+ const results_per_page = m(
783
+ "label",
784
+ m("input", {
785
+ value: page_size,
786
+ type: "number",
787
+ style: { width: "3.5em" },
788
+ onchange: function (e: Event) {
789
+ set_page_size(parseInt((e.target as HTMLInputElement).value));
790
+ },
791
+ }),
792
+ " results per page",
793
+ );
794
+ const total_result_count = render_with_loader(
795
+ result_count_loading,
796
+ m("span", m("b", result_count), " result(s) total."),
797
+ );
798
+ const optional_extra_sort_functions_widget =
799
+ options.extra_sort_functions.length > 0 &&
800
+ extra_sort_function_widget(
801
+ options.current_sort,
802
+ options.extra_sort_functions,
803
+ options.sort_by_function,
804
+ );
805
+
806
+ return [
807
+ space_horizontally("3em", [
808
+ space_horizontally("1.5em", [
809
+ paginator,
810
+ results_per_page,
811
+ total_result_count,
812
+ ]),
813
+ optional_extra_sort_functions_widget,
814
+ options.column_controls_button,
815
+ ]),
816
+
817
+ m(
818
+ "div",
819
+ accessible_result_count !== null &&
820
+ result_count !== null &&
821
+ accessible_result_count < result_count &&
822
+ m(
823
+ "div",
824
+ { style: { color: "red", fontWeight: "bold" } },
825
+ m("sup", "*"),
826
+ "The result table is limited to the first ",
827
+ accessible_result_count,
828
+ " results.",
829
+ ),
830
+ ),
831
+ ];
832
+ }
833
+
834
+ function dialog_column_controls_button(
835
+ columns: ConfiguredColumns,
836
+ updaters: Updaters,
837
+ renderer: Renderer,
838
+ ): Vnode {
839
+ const dialog = m(
840
+ "qnc-dialog",
841
+ {
842
+ className: "qdt-qnc-dialog",
843
+ style: {
844
+ "--horizontal-preferences": "center end",
845
+ "--vertical-preferences": "after before",
846
+ },
847
+ },
848
+ m(
849
+ "ul",
850
+ {
851
+ style: {
852
+ margin: "0",
853
+ padding: "0",
854
+ listStyle: "none",
855
+ },
856
+ oncreate: (vnode) =>
857
+ enable(vnode.dom as HTMLElement, {
858
+ on_committed_move: (
859
+ container: Element,
860
+ dragged_element: Element,
861
+ start_index: number,
862
+ end_index: number,
863
+ ) => {
864
+ console.log("moved");
865
+ updaters.set_column_order(
866
+ Array.from(container.children).map(function (
867
+ li: Element,
868
+ ) {
869
+ if (!(li instanceof HTMLElement))
870
+ throw new Error(
871
+ `unexpected child: ${li}`,
872
+ );
873
+ const value = li.dataset.key;
874
+ if (typeof value == "undefined")
875
+ throw new Error(
876
+ "column li missing [data-key]",
877
+ );
878
+ return value;
879
+ }),
880
+ );
881
+ },
882
+ stationary_list_item_markers: true,
883
+ handle_selector: ".handle",
884
+ }),
885
+ },
886
+ columns.all_sortable_columns.map((c) =>
887
+ m(
888
+ "li",
889
+ {
890
+ "data-key": c.key,
891
+ key: c.key,
892
+ className: "qnc-data-table-column-item",
893
+ },
894
+ m(
895
+ "label",
896
+ m("input", {
897
+ type: "checkbox",
898
+ checked: c.enabled,
899
+ onclick: (e: MouseEvent) =>
900
+ updaters.set_column_enabled(c.key, !c.enabled),
901
+ }),
902
+ " ",
903
+ c.display,
904
+ " ",
905
+ // don't wrap c.display in help text, because it's part of the drag handle
906
+ c.help_text &&
907
+ renderer.render_with_help_text("", c.help_text),
908
+ " ",
909
+ m(
910
+ "span.handle",
911
+ { style: { padding: "0 0.5em", cursor: "grab" } },
912
+ "↕︎",
913
+ ),
914
+ ),
915
+ ),
916
+ ),
917
+ ),
918
+ );
919
+ return m(
920
+ "span",
921
+ m(
922
+ "button",
923
+ {
924
+ className: "qdt-column-options-button",
925
+ onclick: function (event: MouseEvent) {
926
+ const button = event.currentTarget as HTMLElement;
927
+ (button.nextElementSibling as QNCDialog).showNextTo(button);
928
+ },
929
+ },
930
+ "Columns ⋮",
931
+ ),
932
+ dialog,
933
+ );
934
+ }
935
+
936
+ function column_controls(
937
+ columns: ConfiguredColumns,
938
+ updaters: Updaters,
939
+ renderer: Renderer,
940
+ ) {
941
+ return m(
942
+ "details",
943
+ { className: "qdt-column-details" },
944
+ m("summary", "Columns"),
945
+ m(
946
+ "ol",
947
+ {
948
+ oncreate: (vnode) =>
949
+ enable(vnode.dom as HTMLElement, {
950
+ on_committed_move: (
951
+ container: Element,
952
+ dragged_element: Element,
953
+ start_index: number,
954
+ end_index: number,
955
+ ) => {
956
+ console.log("moved");
957
+ updaters.set_column_order(
958
+ Array.from(container.children).map(function (
959
+ li: Element,
960
+ ) {
961
+ if (!(li instanceof HTMLElement))
962
+ throw new Error(
963
+ `unexpected child: ${li}`,
964
+ );
965
+ const value = li.dataset.key;
966
+ if (typeof value == "undefined")
967
+ throw new Error(
968
+ "column li missing [data-key]",
969
+ );
970
+ return value;
971
+ }),
972
+ );
973
+ },
974
+ stationary_list_item_markers: true,
975
+ handle_selector: ".handle",
976
+ }),
977
+ },
978
+ columns.all_sortable_columns.map((c) =>
979
+ m(
980
+ "li",
981
+ {
982
+ "data-key": c.key,
983
+ key: c.key,
984
+ className: "qnc-data-table-column-item",
985
+ },
986
+ m(
987
+ "label",
988
+ m("input", {
989
+ type: "checkbox",
990
+ checked: c.enabled,
991
+ onclick: (e: MouseEvent) =>
992
+ updaters.set_column_enabled(c.key, !c.enabled),
993
+ }),
994
+ " ",
995
+ c.display,
996
+ " ",
997
+ // don't wrap c.display in help text, because it's part of the drag handle
998
+ c.help_text &&
999
+ renderer.render_with_help_text("", c.help_text),
1000
+ " ",
1001
+ m(
1002
+ "span.handle",
1003
+ { style: { padding: "0 0.5em", cursor: "grab" } },
1004
+ "↕︎",
1005
+ ),
1006
+ ),
1007
+ ),
1008
+ ),
1009
+ ),
1010
+ );
1011
+ }
1012
+
1013
+ // I'd like to replace this with some kind of <qdt-loading-container> (make a new package on npm) which is user-styleable
1014
+ // Alternatively (or as part of that work), we could make use of <qnc-spinner-uppercase>
1015
+ // https://tracker.quadrant.net/issues/271/
1016
+ function render_with_loader(loading: boolean, content: m.Children) {
1017
+ return m(
1018
+ "div",
1019
+ {
1020
+ style: {
1021
+ "--qdt-loading-spinner-size": "1em",
1022
+ minWidth: "var(--qdt-loading-spinner-size)",
1023
+ minHeight: "var(--qdt-loading-spinner-size)",
1024
+ position: "relative",
1025
+ isolation: "isolate", // force new stacking context
1026
+ },
1027
+ },
1028
+ // content wrapper
1029
+ m(
1030
+ "div",
1031
+ {
1032
+ style: {
1033
+ opacity: loading ? "50%" : "",
1034
+ },
1035
+ },
1036
+
1037
+ content,
1038
+ ),
1039
+ // spinner
1040
+ m("div", {
1041
+ style: {
1042
+ display: loading ? "block" : "none",
1043
+ pointerEvents: "none",
1044
+ zIndex: 2147483647,
1045
+ position: "absolute",
1046
+ top: "calc(50% - calc(var(--qdt-loading-spinner-size)/2))",
1047
+ left: "calc(50% - calc(var(--qdt-loading-spinner-size)/2))",
1048
+ borderRadius: "1000px",
1049
+ border: "1px solid black",
1050
+ borderWidth: "calc(var(--qdt-loading-spinner-size)/2)",
1051
+ borderLeftColor: "white",
1052
+ borderRightColor: "white",
1053
+ opacity: "0.5",
1054
+ animation: "qdt-loader-spin 1.2s linear infinite",
1055
+ },
1056
+ }),
1057
+ );
1058
+ }
1059
+ function selection_controls(state: AppState<string>, updaters: Updaters) {
1060
+ const page_ids = state.table_data.map(([pk, cells]) => pk);
1061
+ const entire_page_selected = contains_all(
1062
+ state.selected_pks,
1063
+ new Set(page_ids),
1064
+ );
1065
+ const all_results_selected = contains_all(
1066
+ state.selected_pks,
1067
+ new Set(state.filtered_pks),
1068
+ );
1069
+
1070
+ function clear_page() {
1071
+ updaters.bulk_set_selected(page_ids, false);
1072
+ }
1073
+ function select_page() {
1074
+ updaters.bulk_set_selected(page_ids, true);
1075
+ }
1076
+ function clear_table() {
1077
+ updaters.bulk_set_selected(state.filtered_pks, false);
1078
+ }
1079
+ function select_table() {
1080
+ updaters.bulk_set_selected(state.filtered_pks, true);
1081
+ }
1082
+ function clear_selection() {
1083
+ updaters.bulk_set_selected(state.selected_pks, false);
1084
+ }
1085
+
1086
+ const selection_operators = [
1087
+ entire_page_selected
1088
+ ? m(
1089
+ "button",
1090
+ { onclick: clear_page, type: "button" },
1091
+ "Deselect Current Page",
1092
+ )
1093
+ : m(
1094
+ "button",
1095
+ { onclick: select_page, type: "button" },
1096
+ "Select Current Page",
1097
+ ),
1098
+ all_results_selected
1099
+ ? m(
1100
+ "button",
1101
+ { onclick: clear_table, type: "button" },
1102
+ "Deselect All Results",
1103
+ )
1104
+ : m(
1105
+ "button",
1106
+ { onclick: select_table, type: "button" },
1107
+ "Select All Results",
1108
+ ),
1109
+ m(
1110
+ "button",
1111
+ {
1112
+ onclick: clear_selection,
1113
+ disabled: state.selected_pks.size == 0,
1114
+ type: "button",
1115
+ },
1116
+ "Clear Selection",
1117
+ ),
1118
+ ];
1119
+ return m("div.qdt-selection-controls", selection_operators);
1120
+ }
1121
+
1122
+ const contains_all = (Set.prototype as any).isSubsetOf
1123
+ ? function <U>(
1124
+ possible_superset: ReadonlySet<U>,
1125
+ possible_subset: ReadonlySet<U>,
1126
+ ): boolean {
1127
+ // @ts-ignore
1128
+ return possible_subset.isSubsetOf(possible_superset);
1129
+ }
1130
+ : function <U>(
1131
+ possible_superset: ReadonlySet<U>,
1132
+ possible_subset: ReadonlySet<U>,
1133
+ ): boolean {
1134
+ for (const value of possible_subset) {
1135
+ if (!possible_superset.has(value)) return false;
1136
+ }
1137
+ return true;
1138
+ };