@zeyos/zx 2.1.0 → 2.2.1
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/CHANGELOG.md +51 -0
- package/README.md +15 -0
- package/dist/types/compat/base.d.ts +156 -0
- package/dist/types/compat/globals.d.ts +20 -0
- package/dist/types/compat/index.d.ts +128 -0
- package/dist/types/compat/map/basic.d.ts +175 -0
- package/dist/types/compat/map/data.d.ts +102 -0
- package/dist/types/compat/map/date-time.d.ts +80 -0
- package/dist/types/compat/map/factory.d.ts +9 -0
- package/dist/types/compat/map/form.d.ts +98 -0
- package/dist/types/compat/map/helpers.d.ts +12 -0
- package/dist/types/compat/map/http.d.ts +36 -0
- package/dist/types/compat/map/layout.d.ts +106 -0
- package/dist/types/compat/map/options.d.ts +80 -0
- package/dist/types/compat/map/select.d.ts +95 -0
- package/dist/types/compat/map/stubs.d.ts +32 -0
- package/dist/types/compat/map/utilities.d.ts +37 -0
- package/dist/types/compat-entry.d.ts +5 -0
- package/dist/types/components/badge/badge.d.ts +46 -0
- package/dist/types/components/breadcrumb/breadcrumb.d.ts +200 -0
- package/dist/types/components/button/button.d.ts +42 -0
- package/dist/types/components/check-button/check-button.d.ts +98 -0
- package/dist/types/components/checklist/checklist.d.ts +143 -0
- package/dist/types/components/checklist/field-adapter.d.ts +5 -0
- package/dist/types/components/context-menu/context-menu.d.ts +130 -0
- package/dist/types/components/copy/copy-button.d.ts +48 -0
- package/dist/types/components/copy/copy-input.d.ts +91 -0
- package/dist/types/components/data-filter/data-filter.d.ts +250 -0
- package/dist/types/components/data-filter/filter-core.d.ts +52 -0
- package/dist/types/components/date-picker/date-picker.d.ts +137 -0
- package/dist/types/components/date-picker/date-range-picker.d.ts +350 -0
- package/dist/types/components/date-picker/field-adapter.d.ts +6 -0
- package/dist/types/components/date-picker/month-picker.d.ts +75 -0
- package/dist/types/components/date-picker/range-field-adapter.d.ts +7 -0
- package/dist/types/components/date-picker/time-picker.d.ts +127 -0
- package/dist/types/components/datebox/date-range-box.d.ts +218 -0
- package/dist/types/components/datebox/datebox.d.ts +160 -0
- package/dist/types/components/datebox/field-adapter.d.ts +5 -0
- package/dist/types/components/dialog/dialog.d.ts +284 -0
- package/dist/types/components/dropdown/dropdown.d.ts +118 -0
- package/dist/types/components/empty-state/empty-state.d.ts +38 -0
- package/dist/types/components/field/field.d.ts +231 -0
- package/dist/types/components/field-upload/field-adapter.d.ts +5 -0
- package/dist/types/components/field-upload/field-upload.d.ts +213 -0
- package/dist/types/components/fieldset/fieldset.d.ts +125 -0
- package/dist/types/components/finder/finder.d.ts +252 -0
- package/dist/types/components/form/form.d.ts +194 -0
- package/dist/types/components/groupbox/groupbox.d.ts +89 -0
- package/dist/types/components/layout/layout.d.ts +132 -0
- package/dist/types/components/loading/inline-loading.d.ts +86 -0
- package/dist/types/components/loading/progress-bar.d.ts +168 -0
- package/dist/types/components/loading/spinner.d.ts +34 -0
- package/dist/types/components/master-panel/master-panel.d.ts +112 -0
- package/dist/types/components/menu-button/menu-button.d.ts +142 -0
- package/dist/types/components/message/message.d.ts +212 -0
- package/dist/types/components/modal/modal.d.ts +105 -0
- package/dist/types/components/multi-value-editor/field-adapter.d.ts +5 -0
- package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +66 -0
- package/dist/types/components/navigation-bar/navigation-bar.d.ts +229 -0
- package/dist/types/components/number-field/field-adapter.d.ts +5 -0
- package/dist/types/components/number-field/number-field.d.ts +292 -0
- package/dist/types/components/pagination/pagination.d.ts +267 -0
- package/dist/types/components/panel/panel.d.ts +194 -0
- package/dist/types/components/permission/permission.d.ts +93 -0
- package/dist/types/components/rating/field-adapter.d.ts +5 -0
- package/dist/types/components/rating/rating.d.ts +213 -0
- package/dist/types/components/search/search.d.ts +98 -0
- package/dist/types/components/select/field-adapter.d.ts +5 -0
- package/dist/types/components/select/filter.d.ts +11 -0
- package/dist/types/components/select/select.d.ts +235 -0
- package/dist/types/components/skeleton/skeleton.d.ts +97 -0
- package/dist/types/components/slider/field-adapter.d.ts +5 -0
- package/dist/types/components/slider/slider.d.ts +216 -0
- package/dist/types/components/split-view/split-view.d.ts +335 -0
- package/dist/types/components/stepper/stepper.d.ts +314 -0
- package/dist/types/components/stepper/steps.d.ts +131 -0
- package/dist/types/components/tabbox/tabbox.d.ts +230 -0
- package/dist/types/components/table/sort.d.ts +49 -0
- package/dist/types/components/table/table.d.ts +872 -0
- package/dist/types/components/tag-picker/field-adapter.d.ts +5 -0
- package/dist/types/components/tag-picker/tag-picker.d.ts +375 -0
- package/dist/types/components/timebox/field-adapter.d.ts +5 -0
- package/dist/types/components/timebox/timebox.d.ts +121 -0
- package/dist/types/components/toggle/field-adapter.d.ts +5 -0
- package/dist/types/components/toggle/toggle.d.ts +94 -0
- package/dist/types/components/toolbar/toolbar.d.ts +230 -0
- package/dist/types/components/tooltip/tooltip.d.ts +183 -0
- package/dist/types/components/tree/hierarchy.d.ts +113 -0
- package/dist/types/components/tree/tree.d.ts +383 -0
- package/dist/types/components/truncate/truncate.d.ts +61 -0
- package/dist/types/components/value-list/field-adapter.d.ts +5 -0
- package/dist/types/components/value-list/value-list.d.ts +143 -0
- package/dist/types/core/breakpoint.d.ts +68 -0
- package/dist/types/core/component.d.ts +115 -0
- package/dist/types/core/date.d.ts +50 -0
- package/dist/types/core/dom.d.ts +86 -0
- package/dist/types/core/export.d.ts +70 -0
- package/dist/types/core/fontawesome.d.ts +107 -0
- package/dist/types/core/format.d.ts +107 -0
- package/dist/types/core/http.d.ts +122 -0
- package/dist/types/core/i18n.d.ts +35 -0
- package/dist/types/core/icons.d.ts +137 -0
- package/dist/types/core/keyboard.d.ts +72 -0
- package/dist/types/core/position.d.ts +45 -0
- package/dist/types/core/storage.d.ts +46 -0
- package/dist/types/core/util.d.ts +117 -0
- package/dist/types/elements/define.d.ts +73 -0
- package/dist/types/elements/reflect.d.ts +27 -0
- package/dist/types/index.d.ts +79 -0
- package/dist/types/zeyos/connect.d.ts +65 -0
- package/dist/types/zeyos/form.d.ts +140 -0
- package/dist/types/zeyos/icons.d.ts +104 -0
- package/dist/types/zeyos/index.d.ts +12 -0
- package/dist/types/zeyos/modules.d.ts +110 -0
- package/dist/types/zeyos/query.d.ts +157 -0
- package/dist/types/zeyos/schema.d.ts +79 -0
- package/dist/types/zeyos/select.d.ts +167 -0
- package/dist/types/zeyos/table.d.ts +205 -0
- package/dist/zx-compat.esm.js +375 -39
- package/dist/zx-compat.esm.min.js +1 -1
- package/dist/zx-compat.global.js +372 -73
- package/dist/zx-compat.global.min.js +3 -3
- package/dist/zx-zeyos.esm.js +355 -49
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +68 -6
- package/dist/zx.esm.js +359 -73
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +359 -73
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +48 -2
- package/docs/llms.md +31 -0
- package/package.json +37 -9
- package/src/compat/base.js +291 -0
- package/src/compat/globals.js +165 -0
- package/src/compat/index.js +84 -0
- package/src/compat/map/basic.js +464 -0
- package/src/compat/map/data.js +232 -0
- package/src/compat/map/date-time.js +177 -0
- package/src/compat/map/factory.js +45 -0
- package/src/compat/map/form.js +330 -0
- package/src/compat/map/helpers.js +46 -0
- package/src/compat/map/http.js +117 -0
- package/src/compat/map/layout.js +256 -0
- package/src/compat/map/options.js +267 -0
- package/src/compat/map/select.js +198 -0
- package/src/compat/map/stubs.js +26 -0
- package/src/compat/map/utilities.js +113 -0
- package/src/compat-entry.js +11 -0
- package/src/components/badge/badge.css +147 -0
- package/src/components/badge/badge.js +92 -0
- package/src/components/breadcrumb/breadcrumb.css +105 -0
- package/src/components/breadcrumb/breadcrumb.js +331 -0
- package/src/components/button/button.css +104 -0
- package/src/components/button/button.js +76 -0
- package/src/components/check-button/check-button.css +66 -0
- package/src/components/check-button/check-button.js +167 -0
- package/src/components/checklist/checklist.css +65 -0
- package/src/components/checklist/checklist.js +282 -0
- package/src/components/checklist/field-adapter.js +46 -0
- package/src/components/context-menu/context-menu.css +54 -0
- package/src/components/context-menu/context-menu.js +334 -0
- package/src/components/copy/copy-button.css +27 -0
- package/src/components/copy/copy-button.js +78 -0
- package/src/components/copy/copy-input.css +59 -0
- package/src/components/copy/copy-input.js +123 -0
- package/src/components/data-filter/data-filter.css +53 -0
- package/src/components/data-filter/data-filter.js +400 -0
- package/src/components/data-filter/filter-core.js +128 -0
- package/src/components/date-picker/date-picker.css +181 -0
- package/src/components/date-picker/date-picker.js +500 -0
- package/src/components/date-picker/date-range-picker.css +264 -0
- package/src/components/date-picker/date-range-picker.js +990 -0
- package/src/components/date-picker/field-adapter.js +36 -0
- package/src/components/date-picker/month-picker.js +223 -0
- package/src/components/date-picker/range-field-adapter.js +31 -0
- package/src/components/date-picker/time-picker.css +191 -0
- package/src/components/date-picker/time-picker.js +406 -0
- package/src/components/datebox/date-range-box.css +128 -0
- package/src/components/datebox/date-range-box.js +492 -0
- package/src/components/datebox/datebox.css +108 -0
- package/src/components/datebox/datebox.js +358 -0
- package/src/components/datebox/field-adapter.js +34 -0
- package/src/components/dialog/dialog.css +74 -0
- package/src/components/dialog/dialog.js +450 -0
- package/src/components/dropdown/dropdown.css +24 -0
- package/src/components/dropdown/dropdown.js +288 -0
- package/src/components/empty-state/empty-state.css +80 -0
- package/src/components/empty-state/empty-state.js +92 -0
- package/src/components/field/field.css +127 -0
- package/src/components/field/field.js +605 -0
- package/src/components/field-upload/field-adapter.js +21 -0
- package/src/components/field-upload/field-upload.css +108 -0
- package/src/components/field-upload/field-upload.js +475 -0
- package/src/components/fieldset/fieldset.css +37 -0
- package/src/components/fieldset/fieldset.js +307 -0
- package/src/components/finder/finder.css +98 -0
- package/src/components/finder/finder.js +509 -0
- package/src/components/form/form.css +32 -0
- package/src/components/form/form.js +302 -0
- package/src/components/groupbox/groupbox.css +51 -0
- package/src/components/groupbox/groupbox.js +199 -0
- package/src/components/layout/layout.css +117 -0
- package/src/components/layout/layout.js +136 -0
- package/src/components/loading/inline-loading.css +34 -0
- package/src/components/loading/inline-loading.js +140 -0
- package/src/components/loading/progress-bar.css +115 -0
- package/src/components/loading/progress-bar.js +259 -0
- package/src/components/loading/spinner.css +58 -0
- package/src/components/loading/spinner.js +57 -0
- package/src/components/master-panel/master-panel.css +51 -0
- package/src/components/master-panel/master-panel.js +203 -0
- package/src/components/menu-button/menu-button.css +49 -0
- package/src/components/menu-button/menu-button.js +294 -0
- package/src/components/message/message.css +122 -0
- package/src/components/message/message.js +499 -0
- package/src/components/modal/modal.css +47 -0
- package/src/components/modal/modal.js +203 -0
- package/src/components/multi-value-editor/field-adapter.js +44 -0
- package/src/components/multi-value-editor/multi-value-editor.css +56 -0
- package/src/components/multi-value-editor/multi-value-editor.js +170 -0
- package/src/components/navigation-bar/navigation-bar.css +130 -0
- package/src/components/navigation-bar/navigation-bar.js +450 -0
- package/src/components/number-field/field-adapter.js +40 -0
- package/src/components/number-field/number-field.css +77 -0
- package/src/components/number-field/number-field.js +545 -0
- package/src/components/pagination/pagination.css +95 -0
- package/src/components/pagination/pagination.js +481 -0
- package/src/components/panel/panel.css +123 -0
- package/src/components/panel/panel.js +336 -0
- package/src/components/permission/permission.css +47 -0
- package/src/components/permission/permission.js +198 -0
- package/src/components/rating/field-adapter.js +34 -0
- package/src/components/rating/rating.css +97 -0
- package/src/components/rating/rating.js +395 -0
- package/src/components/search/search.css +70 -0
- package/src/components/search/search.js +193 -0
- package/src/components/select/field-adapter.js +24 -0
- package/src/components/select/filter.js +39 -0
- package/src/components/select/select.css +180 -0
- package/src/components/select/select.js +663 -0
- package/src/components/skeleton/skeleton.css +76 -0
- package/src/components/skeleton/skeleton.js +139 -0
- package/src/components/slider/field-adapter.js +36 -0
- package/src/components/slider/slider.css +181 -0
- package/src/components/slider/slider.js +399 -0
- package/src/components/split-view/split-view.css +139 -0
- package/src/components/split-view/split-view.js +806 -0
- package/src/components/stepper/stepper.css +234 -0
- package/src/components/stepper/stepper.js +459 -0
- package/src/components/stepper/steps.js +131 -0
- package/src/components/tabbox/tabbox.css +281 -0
- package/src/components/tabbox/tabbox.js +484 -0
- package/src/components/table/sort.js +85 -0
- package/src/components/table/table.css +364 -0
- package/src/components/table/table.js +1711 -0
- package/src/components/tag-picker/field-adapter.js +35 -0
- package/src/components/tag-picker/tag-picker.css +169 -0
- package/src/components/tag-picker/tag-picker.js +837 -0
- package/src/components/timebox/field-adapter.js +24 -0
- package/src/components/timebox/timebox.css +51 -0
- package/src/components/timebox/timebox.js +276 -0
- package/src/components/toggle/field-adapter.js +24 -0
- package/src/components/toggle/toggle.css +66 -0
- package/src/components/toggle/toggle.js +150 -0
- package/src/components/toolbar/toolbar.css +81 -0
- package/src/components/toolbar/toolbar.js +511 -0
- package/src/components/tooltip/tooltip.css +36 -0
- package/src/components/tooltip/tooltip.js +420 -0
- package/src/components/tree/hierarchy.js +151 -0
- package/src/components/tree/tree.css +99 -0
- package/src/components/tree/tree.js +784 -0
- package/src/components/truncate/truncate.css +20 -0
- package/src/components/truncate/truncate.js +92 -0
- package/src/components/value-list/field-adapter.js +24 -0
- package/src/components/value-list/value-list.css +108 -0
- package/src/components/value-list/value-list.js +336 -0
- package/src/core/breakpoint.js +150 -0
- package/src/core/component.js +292 -0
- package/src/core/date.js +216 -0
- package/src/core/dom.js +260 -0
- package/src/core/export.js +204 -0
- package/src/core/fontawesome.js +177 -0
- package/src/core/format.js +283 -0
- package/src/core/http.js +246 -0
- package/src/core/i18n.js +83 -0
- package/src/core/icons.js +283 -0
- package/src/core/keyboard.js +240 -0
- package/src/core/position.js +177 -0
- package/src/core/storage.js +175 -0
- package/src/core/util.js +336 -0
- package/src/elements/define.js +715 -0
- package/src/elements/reflect.js +79 -0
- package/src/index.js +131 -0
- package/src/zeyos/connect.js +142 -0
- package/src/zeyos/form.js +254 -0
- package/src/zeyos/icons.js +92 -0
- package/src/zeyos/index.js +21 -0
- package/src/zeyos/modules.js +269 -0
- package/src/zeyos/query.js +199 -0
- package/src/zeyos/schema.js +474 -0
- package/src/zeyos/select.js +175 -0
- package/src/zeyos/table.js +272 -0
- package/styles/base.css +124 -0
- package/styles/icons.css +34 -0
- package/styles/tokens/dark.css +84 -0
- package/styles/tokens/density.css +9 -0
- package/styles/tokens/global.css +80 -0
- package/styles/tokens/modules.css +109 -0
- package/styles/tokens/semantic.css +49 -0
- package/styles/zx.css +61 -0
|
@@ -0,0 +1,1711 @@
|
|
|
1
|
+
import { Component } from '../../core/component.js';
|
|
2
|
+
import { breakpoints, onBreakpoint } from '../../core/breakpoint.js';
|
|
3
|
+
import { h } from '../../core/dom.js';
|
|
4
|
+
import { printf } from '../../core/i18n.js';
|
|
5
|
+
import { uid } from '../../core/util.js';
|
|
6
|
+
import { Datebox } from '../datebox/datebox.js';
|
|
7
|
+
import { NumberField } from '../number-field/number-field.js';
|
|
8
|
+
import { Select } from '../select/select.js';
|
|
9
|
+
import { Toggle } from '../toggle/toggle.js';
|
|
10
|
+
import { sortRows } from './sort.js';
|
|
11
|
+
|
|
12
|
+
/** @typedef {Record<string, any>} TableRow */
|
|
13
|
+
/** @typedef {'asc'|'desc'} TableSortDirection */
|
|
14
|
+
/** @typedef {false|'single'|'multi'} TableSelectionMode */
|
|
15
|
+
/** @typedef {false|'cell'|'row'} TableEditMode */
|
|
16
|
+
/** @typedef {'text'|'number'|'select'|'date'|'checkbox'|'textarea'|'custom'} TableEditorType */
|
|
17
|
+
/** @typedef {{value: unknown, label: string}} TableEditorOption */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Editing handle handed to a custom `column.editor`.
|
|
21
|
+
* @typedef {Object} TableEditorApi
|
|
22
|
+
* @property {unknown} value Value the cell holds when the editor opens.
|
|
23
|
+
* @property {(value: unknown) => void} commit Commits the edit with the given value.
|
|
24
|
+
* @property {() => void} cancel Cancels the edit and restores the cell.
|
|
25
|
+
* @property {TableRow} row Row being edited.
|
|
26
|
+
* @property {TableColumn} column Column being edited.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} TableColumn
|
|
31
|
+
* @property {string} id Row property and stable column identifier.
|
|
32
|
+
* @property {string} label Visible header label.
|
|
33
|
+
* @property {boolean} [sortable=false] Whether the header changes the active sort.
|
|
34
|
+
* @property {string} [width='auto'] CSS width, `auto`, or an `fr` proportion. `fr` values are
|
|
35
|
+
* converted into proportional percentages on `<col>` elements and enable `table-layout: fixed`;
|
|
36
|
+
* this preserves a single aligned header/body table without measuring layout in JavaScript.
|
|
37
|
+
* @property {'start'|'center'|'end'|'left'|'right'} [align='start'] Cell alignment.
|
|
38
|
+
* @property {(row: TableRow, rowIndex: number) => Node|string|number|null|undefined} [render]
|
|
39
|
+
* Cell renderer. Non-Node results are inserted as text.
|
|
40
|
+
* @property {(row: TableRow) => unknown} [sortValue] Sort-value accessor.
|
|
41
|
+
* @property {string} [headerTitle] Header tooltip.
|
|
42
|
+
* @property {boolean} [popin=true] While stacked, whether this column becomes a labelled line.
|
|
43
|
+
* `false` keeps it in the card's headline — the identifying columns usually want that.
|
|
44
|
+
* @property {boolean|TableEditorType|((row: TableRow) => boolean|TableEditorType)} [editable=false]
|
|
45
|
+
* Editor for this column while the table runs with `editMode`. `true` means `'text'`; a function is
|
|
46
|
+
* evaluated per row so individual rows stay read-only.
|
|
47
|
+
* @property {TableEditorOption[]|Record<string, string>|((row: TableRow) => TableEditorOption[]|Record<string, string>)} [options]
|
|
48
|
+
* Choices for an `'select'` editor: `[{value, label}]`, a `{value: label}` map, or a row callback.
|
|
49
|
+
* @property {Record<string, unknown>} [editorProps] Options forwarded to the underlying editor
|
|
50
|
+
* component (NumberField, Select, Datebox, Toggle) or to the generated `<input>`/`<textarea>`.
|
|
51
|
+
* @property {(row: TableRow, api: TableEditorApi) => Node|{toElement: () => Node, destroy?: () => void}} [editor]
|
|
52
|
+
* Fully custom editor. Wins over `editable`, except that a per-row `editable` function returning
|
|
53
|
+
* `false` still marks the cell read-only. A returned Zx component is destroyed with the edit.
|
|
54
|
+
* @property {(raw: unknown, row: TableRow) => unknown} [parse] Converts the editor's raw output into
|
|
55
|
+
* the value written back into the row.
|
|
56
|
+
* @property {(value: unknown, row: TableRow) => string} [format] Renders the value as the initial
|
|
57
|
+
* text of a `'text'` or `'textarea'` editor.
|
|
58
|
+
* @property {(value: unknown, row: TableRow) => true|string} [validate] Commit guard. A returned
|
|
59
|
+
* string marks the cell invalid, shows the message, and refuses the commit.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {Object} TableOptions
|
|
64
|
+
* @property {TableColumn[]} [columns=[]] Column definitions.
|
|
65
|
+
* @property {TableRow[]} [data=[]] Initial rows.
|
|
66
|
+
* @property {string|((row: TableRow) => unknown)} [rowId='ID'] Row id accessor.
|
|
67
|
+
* @property {{id: string, dir: TableSortDirection}|null} [sort=null] Initial sort.
|
|
68
|
+
* @property {'local'|'server'} [sortMode='local'] Whether sorting reorders local rows.
|
|
69
|
+
* @property {TableSelectionMode} [selectable=false] Row selection mode.
|
|
70
|
+
* @property {boolean} [stickyHeader=true] Whether column headers stick while scrolling.
|
|
71
|
+
* @property {false|number} [growing=false] Number of rows to render at first, with a control that
|
|
72
|
+
* appends the next batch on demand. A result set an ERP query returns happily — tens of thousands
|
|
73
|
+
* of rows — costs more to lay out than anyone will ever scroll through.
|
|
74
|
+
* @property {false|'sm'|'md'|'lg'|'xl'} [responsive=false] Width below which each row stacks into a
|
|
75
|
+
* card of label/value pairs instead of scrolling sideways. Measured on the table's own container,
|
|
76
|
+
* not the viewport, so a table inside a split pane stacks when *it* is narrow.
|
|
77
|
+
* @property {number|string|null} [height=null] Scroll-region height in pixels or CSS units.
|
|
78
|
+
* @property {string|Node|(() => Node|string)|null} [emptyText=null] What to show when there are no
|
|
79
|
+
* rows; null resolves `table.empty`. A Node — an `emptyState()` with an action in it, say — is
|
|
80
|
+
* placed as given, and a function is called on each render, which is what to use when the
|
|
81
|
+
* placeholder holds live controls.
|
|
82
|
+
* @property {((row: TableRow) => string)|null} [rowClass=null] Additional row class callback.
|
|
83
|
+
* @property {boolean} [zebra=true] Whether alternate rows use the zebra background.
|
|
84
|
+
* @property {TableEditMode} [editMode=false] Inline editing mode. `'cell'` edits one cell, `'row'`
|
|
85
|
+
* opens every editable cell of the row together and commits them as a unit. Editing is completely
|
|
86
|
+
* inert while this is `false`.
|
|
87
|
+
* @property {(event: CustomEvent<TableRowClickDetail>) => void} [onrowclick]
|
|
88
|
+
* @property {(event: CustomEvent<TableRowClickDetail>) => void} [onrowdblclick]
|
|
89
|
+
* @property {(event: CustomEvent<TableSortDetail>) => void} [onsort]
|
|
90
|
+
* @property {(event: CustomEvent<TableSelectionDetail>) => void} [onselectionchange]
|
|
91
|
+
* @property {(event: CustomEvent<TableDataDetail>) => void} [ondatachange]
|
|
92
|
+
* @property {(event: CustomEvent<{stacked: boolean}>) => void} [onstackedchange] Fired when the
|
|
93
|
+
* table crosses its `responsive` width in either direction.
|
|
94
|
+
* @property {(event: CustomEvent<{rendered: number, total: number}>) => void} [ongrow] Fired after
|
|
95
|
+
* more rows are rendered.
|
|
96
|
+
* @property {(event: CustomEvent<TableEditStartDetail>) => void} [oneditstart]
|
|
97
|
+
* @property {(event: CustomEvent<TableEditCommitDetail>) => void} [oneditcommit]
|
|
98
|
+
* @property {(event: CustomEvent<TableEditCancelDetail>) => void} [oneditcancel]
|
|
99
|
+
* @property {(event: CustomEvent<TableEditInvalidDetail>) => void} [oneditinvalid]
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {Object} TableRowClickDetail
|
|
104
|
+
* @property {TableRow} row
|
|
105
|
+
* @property {unknown} id
|
|
106
|
+
* @property {number} index
|
|
107
|
+
* @property {Event} event
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
/** @typedef {{id: string, dir: TableSortDirection}} TableSortDetail */
|
|
111
|
+
/** @typedef {{rows: TableRow[], ids: unknown[]}} TableSelectionDetail */
|
|
112
|
+
/** @typedef {{rows: TableRow[]}} TableDataDetail */
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @typedef {Object} TableEditStartDetail
|
|
116
|
+
* @property {TableRow} row Row being edited.
|
|
117
|
+
* @property {unknown} id Row id.
|
|
118
|
+
* @property {TableColumn|null} column Focused column.
|
|
119
|
+
* @property {string} columnId Focused column id.
|
|
120
|
+
* @property {unknown} value Value the editor opened with.
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @typedef {Object} TableEditCommitDetail
|
|
125
|
+
* @property {TableRow} row Row as it is before the commit.
|
|
126
|
+
* @property {unknown} id Row id.
|
|
127
|
+
* @property {TableColumn|null} column Focused column.
|
|
128
|
+
* @property {string} columnId Focused column id.
|
|
129
|
+
* @property {unknown} value Committed value of the focused column.
|
|
130
|
+
* @property {unknown} previous Previous value of the focused column.
|
|
131
|
+
* @property {Record<string, unknown>} changes Every changed cell of the row, keyed by column id.
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @typedef {Object} TableEditCancelDetail
|
|
136
|
+
* @property {TableRow} row Row that was being edited.
|
|
137
|
+
* @property {unknown} id Row id.
|
|
138
|
+
* @property {TableColumn|null} column Focused column.
|
|
139
|
+
* @property {string} columnId Focused column id.
|
|
140
|
+
*/
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @typedef {Object} TableEditInvalidDetail
|
|
144
|
+
* @property {TableRow} row Row being edited.
|
|
145
|
+
* @property {unknown} id Row id.
|
|
146
|
+
* @property {TableColumn|null} column Rejected column.
|
|
147
|
+
* @property {string} columnId Rejected column id.
|
|
148
|
+
* @property {unknown} value Rejected value.
|
|
149
|
+
* @property {string} message Validation message.
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Internal per-cell editor bookkeeping.
|
|
154
|
+
* @typedef {Object} TableEditorEntry
|
|
155
|
+
* @property {TableColumn} column Edited column.
|
|
156
|
+
* @property {TableEditorType} type Resolved editor type.
|
|
157
|
+
* @property {HTMLTableCellElement} td Edited cell.
|
|
158
|
+
* @property {HTMLElement} host Element the editor lives in.
|
|
159
|
+
* @property {HTMLElement|null} control Focusable control inside the editor.
|
|
160
|
+
* @property {{destroy: () => void}|null} component Zx component instance, when the editor uses one.
|
|
161
|
+
* @property {() => unknown} read Reads the editor's current value.
|
|
162
|
+
* @property {() => void} destroy Destroys the editor and removes its DOM.
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
/** Editor types accepted by `column.editable`. */
|
|
166
|
+
const EDITOR_TYPES = new Set(['text', 'number', 'select', 'date', 'checkbox', 'textarea']);
|
|
167
|
+
/** Selector used to find the focusable control inside a custom editor. */
|
|
168
|
+
const FOCUSABLE = 'input, textarea, select, button, [tabindex]:not([tabindex="-1"])';
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Semantic, sortable data table with optional single or checkbox-based multi-selection and
|
|
172
|
+
* opt-in inline cell or row editing.
|
|
173
|
+
* @fires Table#rowclick
|
|
174
|
+
* @fires Table#rowdblclick
|
|
175
|
+
* @fires Table#sort
|
|
176
|
+
* @fires Table#selectionchange
|
|
177
|
+
* @fires Table#datachange
|
|
178
|
+
* @fires Table#editstart
|
|
179
|
+
* @fires Table#editcommit
|
|
180
|
+
* @fires Table#editcancel
|
|
181
|
+
* @fires Table#editinvalid
|
|
182
|
+
* @extends {Component<TableOptions>}
|
|
183
|
+
*/
|
|
184
|
+
export class Table extends Component {
|
|
185
|
+
static cssName = 'table';
|
|
186
|
+
|
|
187
|
+
/** @type {TableOptions} */
|
|
188
|
+
static defaults = {
|
|
189
|
+
columns: [],
|
|
190
|
+
data: [],
|
|
191
|
+
rowId: 'ID',
|
|
192
|
+
sort: null,
|
|
193
|
+
sortMode: 'local',
|
|
194
|
+
selectable: false,
|
|
195
|
+
stickyHeader: true,
|
|
196
|
+
responsive: false,
|
|
197
|
+
growing: false,
|
|
198
|
+
height: null,
|
|
199
|
+
emptyText: null,
|
|
200
|
+
rowClass: null,
|
|
201
|
+
zebra: true,
|
|
202
|
+
editMode: false
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Creates a table around a target or creates a new root when target is null.
|
|
207
|
+
* @param {Element|string|null} target Existing root, selector, or null.
|
|
208
|
+
* @param {TableOptions} [options={}] Table options.
|
|
209
|
+
*/
|
|
210
|
+
constructor(target, options = {}) {
|
|
211
|
+
super(target, options);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @returns {HTMLElement} Table root. */
|
|
215
|
+
render() {
|
|
216
|
+
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
217
|
+
// Claimed here, not left to the base constructor: `_watchWidth()` below observes `this.el`,
|
|
218
|
+
// and until this assignment it is null for a component creating its own root.
|
|
219
|
+
this.el = root;
|
|
220
|
+
this._originalChildren = this.el ? Array.from(this.el.childNodes) : null;
|
|
221
|
+
this._columns = Array.isArray(this.options.columns) ? this.options.columns.map((column) => ({ ...column })) : [];
|
|
222
|
+
this._data = Array.isArray(this.options.data) ? [...this.options.data] : [];
|
|
223
|
+
this._selected = new Set();
|
|
224
|
+
this._selectionAnchorId = null;
|
|
225
|
+
this._sort = normalizeSort(this.options.sort, this._columns);
|
|
226
|
+
this._rowMeta = new WeakMap();
|
|
227
|
+
this._rowElements = new Map();
|
|
228
|
+
this._restored = false;
|
|
229
|
+
// Editing state. `_edit` is null unless a cell or row is currently open for editing; every
|
|
230
|
+
// other member below is only ever touched while `_editMode !== false`.
|
|
231
|
+
this._editMode = normalizeEditMode(this.options.editMode);
|
|
232
|
+
this._edit = null;
|
|
233
|
+
this._editMessage = null;
|
|
234
|
+
this._stacked = false;
|
|
235
|
+
this._width = null;
|
|
236
|
+
this._growStep = growStep(this.options.growing);
|
|
237
|
+
this._rendered = this._growStep ?? Infinity;
|
|
238
|
+
this._more = null;
|
|
239
|
+
this._rovingKey = null;
|
|
240
|
+
this._rovingCell = null;
|
|
241
|
+
this._rovingMatched = false;
|
|
242
|
+
|
|
243
|
+
if (this._sort && this.options.sortMode === 'local') this._sortData();
|
|
244
|
+
|
|
245
|
+
this._table = h('table', { class: 'zx-table__table' });
|
|
246
|
+
this._colgroup = h('colgroup', { class: 'zx-table__columns' });
|
|
247
|
+
this._thead = h('thead', { class: 'zx-table__head' });
|
|
248
|
+
this._tbody = h('tbody', { class: 'zx-table__body' });
|
|
249
|
+
this._table.append(this._colgroup, this._thead, this._tbody);
|
|
250
|
+
|
|
251
|
+
this._scroll = h('div', {
|
|
252
|
+
class: 'zx-table__scroll',
|
|
253
|
+
dataset: {
|
|
254
|
+
stickyHeader: String(Boolean(this.options.stickyHeader)),
|
|
255
|
+
zebra: String(Boolean(this.options.zebra))
|
|
256
|
+
}
|
|
257
|
+
}, this._table);
|
|
258
|
+
if (this.options.height != null) {
|
|
259
|
+
this._scroll.style.blockSize = typeof this.options.height === 'number' ?
|
|
260
|
+
`${this.options.height}px` : String(this.options.height);
|
|
261
|
+
}
|
|
262
|
+
if (this._editMode === false) {
|
|
263
|
+
root.replaceChildren(this._scroll);
|
|
264
|
+
} else {
|
|
265
|
+
root.dataset.editMode = this._editMode;
|
|
266
|
+
// Always present and empty rather than hidden: a live region that is revealed together with
|
|
267
|
+
// its text is frequently missed by screen readers.
|
|
268
|
+
this._editMessage = h('div', {
|
|
269
|
+
class: 'zx-table__message',
|
|
270
|
+
id: uid('zx-table-message'),
|
|
271
|
+
role: 'status',
|
|
272
|
+
ariaLive: 'polite'
|
|
273
|
+
});
|
|
274
|
+
root.replaceChildren(this._scroll, this._editMessage);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
this._renderColumns();
|
|
278
|
+
this._renderHeader();
|
|
279
|
+
this._renderBody();
|
|
280
|
+
this._watchWidth();
|
|
281
|
+
this.listen(this._tbody, 'click', (event) => this._handleBodyClick(event));
|
|
282
|
+
this.listen(this._tbody, 'dblclick', (event) => this._handleBodyDoubleClick(event));
|
|
283
|
+
if (this._editMode !== false) {
|
|
284
|
+
this.listen(this._tbody, 'keydown', (event) => this._handleEditKeydown(/** @type {KeyboardEvent} */ (event)));
|
|
285
|
+
this.listen(this._tbody, 'focusin', (event) => this._handleEditFocusIn(event));
|
|
286
|
+
this.listen(document, 'pointerdown', (event) => this._handleDocumentPointerDown(event), { capture: true });
|
|
287
|
+
}
|
|
288
|
+
return root;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Toggles a loading state. While loading, the body is dimmed, an indeterminate progress bar is
|
|
293
|
+
* shown, and `aria-busy` is set — a lightweight take on the busy/skeleton pattern used by
|
|
294
|
+
* enterprise tables (SAP UI5, Salesforce SLDS). Call `setLoading(false)` (or `setData`) when done.
|
|
295
|
+
* @param {boolean} [loading=true] Whether the table is loading.
|
|
296
|
+
* @returns {this}
|
|
297
|
+
*/
|
|
298
|
+
setLoading(loading = true) {
|
|
299
|
+
if (loading) this.el.dataset.loading = 'true';
|
|
300
|
+
else delete this.el.dataset.loading;
|
|
301
|
+
this.el.setAttribute('aria-busy', String(Boolean(loading)));
|
|
302
|
+
return this;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Replaces all rows.
|
|
307
|
+
* @param {TableRow[]} rows New rows.
|
|
308
|
+
* @returns {this}
|
|
309
|
+
* @fires Table#datachange
|
|
310
|
+
* @fires Table#editcancel
|
|
311
|
+
*/
|
|
312
|
+
setData(rows) {
|
|
313
|
+
// New data means a new result set; whatever the reader had grown to belonged to the old one.
|
|
314
|
+
this._rendered = this._growStep ?? Infinity;
|
|
315
|
+
this.setLoading(false);
|
|
316
|
+
assertRows(rows);
|
|
317
|
+
this._data = [...rows];
|
|
318
|
+
if (this._sort && this.options.sortMode === 'local') this._sortData();
|
|
319
|
+
const selectionChanged = this._pruneSelection();
|
|
320
|
+
this._renderBody();
|
|
321
|
+
if (selectionChanged) this._emitSelectionChange();
|
|
322
|
+
this._emitDataChange();
|
|
323
|
+
return this;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Appends rows to the current data.
|
|
328
|
+
* @param {TableRow[]} rows Rows to append.
|
|
329
|
+
* @returns {this}
|
|
330
|
+
* @fires Table#datachange
|
|
331
|
+
* @fires Table#editcancel
|
|
332
|
+
*/
|
|
333
|
+
addData(rows) {
|
|
334
|
+
assertRows(rows);
|
|
335
|
+
this._data.push(...rows);
|
|
336
|
+
if (this._sort && this.options.sortMode === 'local') this._sortData();
|
|
337
|
+
this._renderBody();
|
|
338
|
+
this._emitDataChange();
|
|
339
|
+
return this;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Replaces the row identified by its current id.
|
|
344
|
+
* @param {unknown} id Existing row id.
|
|
345
|
+
* @param {TableRow} row Replacement row.
|
|
346
|
+
* @returns {this}
|
|
347
|
+
* @fires Table#datachange
|
|
348
|
+
* @fires Table#editcancel
|
|
349
|
+
*/
|
|
350
|
+
updateRow(id, row) {
|
|
351
|
+
const index = this._data.findIndex((candidate) => Object.is(this._idFor(candidate), id));
|
|
352
|
+
if (index < 0) return this;
|
|
353
|
+
const wasSelected = this._selected.delete(id);
|
|
354
|
+
this._data[index] = row;
|
|
355
|
+
if (wasSelected) this._selected.add(this._idFor(row));
|
|
356
|
+
if (this._sort && this.options.sortMode === 'local') this._sortData();
|
|
357
|
+
this._renderBody();
|
|
358
|
+
if (wasSelected && !Object.is(id, this._idFor(row))) this._emitSelectionChange();
|
|
359
|
+
this._emitDataChange();
|
|
360
|
+
return this;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Removes the row with the given id.
|
|
365
|
+
* @param {unknown} id Row id.
|
|
366
|
+
* @returns {this}
|
|
367
|
+
* @fires Table#selectionchange
|
|
368
|
+
* @fires Table#datachange
|
|
369
|
+
* @fires Table#editcancel
|
|
370
|
+
*/
|
|
371
|
+
removeRow(id) {
|
|
372
|
+
const index = this._data.findIndex((candidate) => Object.is(this._idFor(candidate), id));
|
|
373
|
+
if (index < 0) return this;
|
|
374
|
+
this._data.splice(index, 1);
|
|
375
|
+
const selectionChanged = this._selected.delete(id);
|
|
376
|
+
if (Object.is(this._selectionAnchorId, id)) this._selectionAnchorId = null;
|
|
377
|
+
this._renderBody();
|
|
378
|
+
if (selectionChanged) this._emitSelectionChange();
|
|
379
|
+
this._emitDataChange();
|
|
380
|
+
return this;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Returns a row by id.
|
|
385
|
+
* @param {unknown} id Row id.
|
|
386
|
+
* @returns {TableRow|null} Matching row or null.
|
|
387
|
+
*/
|
|
388
|
+
getRow(id) {
|
|
389
|
+
return this._data.find((row) => Object.is(this._idFor(row), id)) ?? null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** @returns {TableRow[]} Shallow copy of current rows in display order. */
|
|
393
|
+
getData() {
|
|
394
|
+
return [...this._data];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Removes all rows.
|
|
399
|
+
* @returns {this}
|
|
400
|
+
* @fires Table#selectionchange
|
|
401
|
+
* @fires Table#datachange
|
|
402
|
+
* @fires Table#editcancel
|
|
403
|
+
*/
|
|
404
|
+
empty() {
|
|
405
|
+
const selectionChanged = this._selected.size > 0;
|
|
406
|
+
this._data = [];
|
|
407
|
+
this._selected.clear();
|
|
408
|
+
this._selectionAnchorId = null;
|
|
409
|
+
this._renderBody();
|
|
410
|
+
if (selectionChanged) this._emitSelectionChange();
|
|
411
|
+
this._emitDataChange();
|
|
412
|
+
return this;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Activates a column sort. Local mode stably reorders data; server mode only updates the
|
|
417
|
+
* header state and emits the request event.
|
|
418
|
+
* @param {string} id Column id.
|
|
419
|
+
* @param {TableSortDirection} [dir] Direction; omitted toggles the current column.
|
|
420
|
+
* @param {{silent?: boolean}} [options={}] Event options.
|
|
421
|
+
* @returns {this}
|
|
422
|
+
* @fires Table#sort
|
|
423
|
+
* @fires Table#editcancel
|
|
424
|
+
*/
|
|
425
|
+
setSort(id, dir, options = {}) {
|
|
426
|
+
// Server mode never re-renders the body, so the open editor is dropped here rather than in
|
|
427
|
+
// `_renderBody()`.
|
|
428
|
+
this._abortEdit({ focus: false });
|
|
429
|
+
const column = this._columns.find((candidate) => candidate.id === id);
|
|
430
|
+
if (!column) throw new RangeError(`Unknown table column: ${id}`);
|
|
431
|
+
const direction = dir ?? (this._sort?.id === id && this._sort.dir === 'asc' ? 'desc' : 'asc');
|
|
432
|
+
if (direction !== 'asc' && direction !== 'desc') {
|
|
433
|
+
throw new TypeError('Table sort direction must be "asc" or "desc"');
|
|
434
|
+
}
|
|
435
|
+
this._sort = { id, dir: direction };
|
|
436
|
+
if (this.options.sortMode === 'local') {
|
|
437
|
+
this._sortData();
|
|
438
|
+
this._renderBody();
|
|
439
|
+
}
|
|
440
|
+
this._syncSortHeader();
|
|
441
|
+
if (!options.silent) this.emit('sort', { id, dir: direction });
|
|
442
|
+
return this;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** @returns {TableRow[]} Selected rows in current display order. */
|
|
446
|
+
getSelection() {
|
|
447
|
+
return this._data.filter((row) => this._selected.has(this._idFor(row)));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Replaces selected ids, ignoring ids absent from the current data.
|
|
452
|
+
* @param {unknown[]} ids Row ids.
|
|
453
|
+
* @returns {this}
|
|
454
|
+
* @fires Table#selectionchange
|
|
455
|
+
*/
|
|
456
|
+
setSelection(ids) {
|
|
457
|
+
if (!Array.isArray(ids)) throw new TypeError('Table selection must be an array of row ids');
|
|
458
|
+
const valid = new Set(this._data.map((row) => this._idFor(row)));
|
|
459
|
+
const next = new Set();
|
|
460
|
+
if (this.options.selectable !== false) {
|
|
461
|
+
for (const id of ids) {
|
|
462
|
+
if (valid.has(id)) next.add(id);
|
|
463
|
+
if (this.options.selectable === 'single' && next.size > 0) break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (sameSet(this._selected, next)) return this;
|
|
467
|
+
this._selected = next;
|
|
468
|
+
this._syncSelection();
|
|
469
|
+
this._emitSelectionChange();
|
|
470
|
+
return this;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Clears all selected rows.
|
|
475
|
+
* @returns {this}
|
|
476
|
+
* @fires Table#selectionchange
|
|
477
|
+
*/
|
|
478
|
+
clearSelection() {
|
|
479
|
+
if (this._selected.size === 0) return this;
|
|
480
|
+
this._selected.clear();
|
|
481
|
+
this._selectionAnchorId = null;
|
|
482
|
+
this._syncSelection();
|
|
483
|
+
this._emitSelectionChange();
|
|
484
|
+
return this;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Opens the editor for a cell. No-op unless the table runs with `editMode` and the column
|
|
489
|
+
* resolves to an editable type for that row. In `'row'` mode every editable cell of the row is
|
|
490
|
+
* opened and `columnId` only decides which editor receives focus. Any other open edit is
|
|
491
|
+
* committed first.
|
|
492
|
+
* @param {unknown} rowId Row id.
|
|
493
|
+
* @param {string} columnId Column id.
|
|
494
|
+
* @returns {this}
|
|
495
|
+
* @fires Table#editstart
|
|
496
|
+
*/
|
|
497
|
+
startEdit(rowId, columnId) {
|
|
498
|
+
if (this._editMode === false) return this;
|
|
499
|
+
const column = this._columns.find((candidate) => candidate.id === columnId);
|
|
500
|
+
if (!column) return this;
|
|
501
|
+
const current = this._data.find((candidate) => Object.is(this._idFor(candidate), rowId));
|
|
502
|
+
if (current === undefined || resolveEditable(column, current) === false) return this;
|
|
503
|
+
|
|
504
|
+
if (this._edit) {
|
|
505
|
+
if (Object.is(this._edit.id, rowId) && this._edit.cells.has(columnId)) {
|
|
506
|
+
this._edit.columnId = columnId;
|
|
507
|
+
this._focusEditor(columnId);
|
|
508
|
+
return this;
|
|
509
|
+
}
|
|
510
|
+
this.commitEdit();
|
|
511
|
+
// A rejected or prevented commit keeps the previous editor open and wins over the new one.
|
|
512
|
+
if (this._edit) return this;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Resolve the row again: the commit above may have rewritten and re-sorted the data.
|
|
516
|
+
const index = this._data.findIndex((candidate) => Object.is(this._idFor(candidate), rowId));
|
|
517
|
+
if (index < 0) return this;
|
|
518
|
+
const row = this._data[index];
|
|
519
|
+
const tr = this._rowElements.get(rowId)?.[0];
|
|
520
|
+
if (!tr) return this;
|
|
521
|
+
const targets = this._editMode === 'row'
|
|
522
|
+
? this._columns.filter((candidate) => resolveEditable(candidate, row) !== false)
|
|
523
|
+
: [column];
|
|
524
|
+
/** @type {Map<string, TableEditorEntry>} */
|
|
525
|
+
const cells = new Map();
|
|
526
|
+
for (const target of targets) {
|
|
527
|
+
const td = cellIn(tr, target.id);
|
|
528
|
+
if (!td) continue;
|
|
529
|
+
const entry = this._createEditor(td, target, row, index);
|
|
530
|
+
if (entry) cells.set(target.id, entry);
|
|
531
|
+
}
|
|
532
|
+
if (cells.size === 0) return this;
|
|
533
|
+
|
|
534
|
+
this._edit = { id: rowId, columnId, row, cells, tr };
|
|
535
|
+
tr.dataset.editing = 'true';
|
|
536
|
+
this.emit('editstart', { row, id: rowId, column, columnId, value: row?.[columnId] });
|
|
537
|
+
this._focusEditor(columnId);
|
|
538
|
+
return this;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Commits the open edit. Validation runs first: the first column whose `validate` rejects marks
|
|
543
|
+
* its cell invalid, emits `editinvalid`, and leaves the editor open. A listener may
|
|
544
|
+
* `preventDefault()` the `editcommit` event — e.g. while a server round-trip is pending — which
|
|
545
|
+
* also keeps the editor open. Otherwise every changed cell is written back through `updateRow()`,
|
|
546
|
+
* so `datachange` fires exactly as it does for a programmatic row update.
|
|
547
|
+
* @returns {this}
|
|
548
|
+
* @fires Table#editcommit
|
|
549
|
+
* @fires Table#editinvalid
|
|
550
|
+
* @fires Table#datachange
|
|
551
|
+
*/
|
|
552
|
+
commitEdit() {
|
|
553
|
+
const edit = this._edit;
|
|
554
|
+
if (!edit) return this;
|
|
555
|
+
const row = this.getRow(edit.id) ?? edit.row;
|
|
556
|
+
/** @type {Record<string, unknown>} */
|
|
557
|
+
const values = {};
|
|
558
|
+
for (const [columnId, entry] of edit.cells) values[columnId] = entry.read();
|
|
559
|
+
|
|
560
|
+
const editedColumns = this._columns.filter((column) => edit.cells.has(column.id));
|
|
561
|
+
const failure = firstValidationError(values, editedColumns, row, this._msgOr('table.invalid', 'Invalid value'));
|
|
562
|
+
if (failure) {
|
|
563
|
+
this._markInvalid(failure.columnId, failure.message);
|
|
564
|
+
edit.columnId = failure.columnId;
|
|
565
|
+
this.emit('editinvalid', {
|
|
566
|
+
row,
|
|
567
|
+
id: edit.id,
|
|
568
|
+
column: this._columns.find((column) => column.id === failure.columnId) ?? null,
|
|
569
|
+
columnId: failure.columnId,
|
|
570
|
+
value: values[failure.columnId],
|
|
571
|
+
message: failure.message
|
|
572
|
+
});
|
|
573
|
+
this._focusEditor(failure.columnId);
|
|
574
|
+
return this;
|
|
575
|
+
}
|
|
576
|
+
this._clearInvalid();
|
|
577
|
+
|
|
578
|
+
const changes = diffChanges(row, values);
|
|
579
|
+
const columnId = edit.columnId;
|
|
580
|
+
const event = this.emit('editcommit', {
|
|
581
|
+
row,
|
|
582
|
+
id: edit.id,
|
|
583
|
+
column: this._columns.find((column) => column.id === columnId) ?? null,
|
|
584
|
+
columnId,
|
|
585
|
+
value: values[columnId],
|
|
586
|
+
previous: row?.[columnId],
|
|
587
|
+
changes
|
|
588
|
+
});
|
|
589
|
+
if (event.defaultPrevented) return this;
|
|
590
|
+
|
|
591
|
+
const restoreFocus = this._editHasFocus();
|
|
592
|
+
const updated = { ...row, ...changes };
|
|
593
|
+
this._teardownEdit();
|
|
594
|
+
if (Object.keys(changes).length > 0) this.updateRow(edit.id, updated);
|
|
595
|
+
// Read the id back off the updated row: editing the id column itself moves the row's identity.
|
|
596
|
+
if (restoreFocus) this._focusCell(this._idFor(updated), columnId);
|
|
597
|
+
return this;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Closes the open edit without writing anything back and returns focus to the cell.
|
|
602
|
+
* @returns {this}
|
|
603
|
+
* @fires Table#editcancel
|
|
604
|
+
*/
|
|
605
|
+
cancelEdit() {
|
|
606
|
+
this._abortEdit({ focus: true });
|
|
607
|
+
return this;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** @returns {boolean} Whether a cell or row is currently open for editing. */
|
|
611
|
+
isEditing() {
|
|
612
|
+
return this._edit !== null;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** @returns {{id: unknown, columnId: string}|null} Row id and focused column id, or null. */
|
|
616
|
+
getEditing() {
|
|
617
|
+
return this._edit ? { id: this._edit.id, columnId: this._edit.columnId } : null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The rows currently rendered — all of them unless `growing` is on.
|
|
622
|
+
* @returns {TableRow[]}
|
|
623
|
+
*/
|
|
624
|
+
_visibleData() {
|
|
625
|
+
return this._rendered === Infinity ? this._data : this._data.slice(0, this._rendered);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Renders the next batch of rows.
|
|
630
|
+
* @param {number} [count] How many to add; defaults to the configured batch size.
|
|
631
|
+
* @returns {this}
|
|
632
|
+
* @fires Table#grow
|
|
633
|
+
*/
|
|
634
|
+
growBy(count) {
|
|
635
|
+
if (!this._growStep) return this;
|
|
636
|
+
const step = Number(count) > 0 ? Math.trunc(Number(count)) : this._growStep;
|
|
637
|
+
const before = this._rendered;
|
|
638
|
+
this._rendered = Math.min(this._data.length, this._rendered + step);
|
|
639
|
+
if (this._rendered === before) return this;
|
|
640
|
+
this._renderBody();
|
|
641
|
+
this.emit('grow', { rendered: this._rendered, total: this._data.length });
|
|
642
|
+
return this;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Renders every remaining row.
|
|
647
|
+
* @returns {this}
|
|
648
|
+
* @fires Table#grow
|
|
649
|
+
*/
|
|
650
|
+
showAll() {
|
|
651
|
+
return this.growBy(this._data.length);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* How many rows are currently rendered.
|
|
656
|
+
* @returns {number}
|
|
657
|
+
*/
|
|
658
|
+
getRenderedCount() {
|
|
659
|
+
return Math.min(this._rendered, this._data.length);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Keeps the "show more" control in step with what is rendered.
|
|
664
|
+
*
|
|
665
|
+
* The control sits outside the table rather than in a `<tfoot>` row spanning every column: it is
|
|
666
|
+
* a command, not data, and a button inside the grid would be reachable by the row navigation
|
|
667
|
+
* keys as though it were a cell.
|
|
668
|
+
* @returns {void}
|
|
669
|
+
*/
|
|
670
|
+
_syncGrowing() {
|
|
671
|
+
if (!this._growStep) return;
|
|
672
|
+
const remaining = this._data.length - this.getRenderedCount();
|
|
673
|
+
if (remaining <= 0) {
|
|
674
|
+
this._more?.remove();
|
|
675
|
+
this._more = null;
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const label = this._message('table.more', 'Show %1 more', [remaining]);
|
|
679
|
+
if (!this._more) {
|
|
680
|
+
this._more = h('button', {
|
|
681
|
+
class: 'zx-table__more',
|
|
682
|
+
type: 'button',
|
|
683
|
+
onclick: () => this.growBy()
|
|
684
|
+
});
|
|
685
|
+
this.el.append(this._more);
|
|
686
|
+
}
|
|
687
|
+
this._more.textContent = label;
|
|
688
|
+
// Announced as a count rather than a bare "show more", so the size of what is left is audible.
|
|
689
|
+
this._more.setAttribute('aria-label', label);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Resolves a localized message, falling back to a built-in template.
|
|
694
|
+
* @param {string} key Message key.
|
|
695
|
+
* @param {string} fallback Template used when the host has no translation.
|
|
696
|
+
* @param {unknown[]} args Positional interpolation values.
|
|
697
|
+
* @returns {string}
|
|
698
|
+
*/
|
|
699
|
+
_message(key, fallback, args) {
|
|
700
|
+
const message = this.msg(key, ...args);
|
|
701
|
+
return message === key ? printf(fallback, args) : message;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Watches the table's own width and stacks the rows below `responsive`.
|
|
706
|
+
*
|
|
707
|
+
* The width comes from `onBreakpoint` observing this element rather than the window, which is the
|
|
708
|
+
* whole point of the option: a table inside a split pane or a modal has no idea how wide the
|
|
709
|
+
* viewport is, and a media query would stack it on a phone while leaving it unreadable in a
|
|
710
|
+
* 320px pane on a desktop.
|
|
711
|
+
* @returns {void}
|
|
712
|
+
*/
|
|
713
|
+
_watchWidth() {
|
|
714
|
+
this._width?.destroy();
|
|
715
|
+
this._width = null;
|
|
716
|
+
const threshold = this.options.responsive;
|
|
717
|
+
if (!threshold || !(threshold in breakpoints)) return;
|
|
718
|
+
this._width = onBreakpoint(
|
|
719
|
+
(_name, width) => this._setStacked(width > 0 && width < breakpoints[threshold]),
|
|
720
|
+
{ target: this.el }
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Enters or leaves stacked mode.
|
|
726
|
+
* @param {boolean} stacked Whether rows should stack.
|
|
727
|
+
* @returns {void}
|
|
728
|
+
* @fires Table#stackedchange
|
|
729
|
+
*/
|
|
730
|
+
_setStacked(stacked) {
|
|
731
|
+
if (this._stacked === stacked) return;
|
|
732
|
+
this._stacked = stacked;
|
|
733
|
+
this.el.toggleAttribute('data-stacked', stacked);
|
|
734
|
+
this._applyStackedRoles(stacked);
|
|
735
|
+
this.emit('stackedchange', { stacked });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* States the table roles explicitly while stacked, and removes them again on the way out.
|
|
740
|
+
*
|
|
741
|
+
* Stacking works by changing `display` on the table elements, and a table whose display is not
|
|
742
|
+
* `table` loses its implicit ARIA roles — leaving a screen reader with a pile of generic blocks
|
|
743
|
+
* where a table used to be. The roles are only asserted while the layout has taken them away.
|
|
744
|
+
* @param {boolean} stacked Whether the roles should be present.
|
|
745
|
+
* @returns {void}
|
|
746
|
+
*/
|
|
747
|
+
_applyStackedRoles(stacked) {
|
|
748
|
+
/** @type {Array<[Element|null, string]>} */
|
|
749
|
+
const parts = [[this._table, 'table'], [this._thead, 'rowgroup'], [this._tbody, 'rowgroup']];
|
|
750
|
+
for (const [element, role] of parts) {
|
|
751
|
+
if (!element) continue;
|
|
752
|
+
if (stacked) element.setAttribute('role', role);
|
|
753
|
+
else element.removeAttribute('role');
|
|
754
|
+
}
|
|
755
|
+
for (const [selector, role] of [['tr', 'row'], ['td', 'cell'], ['th', 'columnheader']]) {
|
|
756
|
+
for (const element of this._table.querySelectorAll(selector)) {
|
|
757
|
+
if (stacked) element.setAttribute('role', role);
|
|
758
|
+
else element.removeAttribute('role');
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Reports whether the rows are currently stacked.
|
|
765
|
+
* @returns {boolean}
|
|
766
|
+
*/
|
|
767
|
+
isStacked() {
|
|
768
|
+
return Boolean(this._stacked);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Restores an enhanced target and releases all component listeners. @returns {void} */
|
|
772
|
+
destroy() {
|
|
773
|
+
this._teardownEdit();
|
|
774
|
+
this._width?.destroy();
|
|
775
|
+
this._width = null;
|
|
776
|
+
if (this.el) delete this.el.dataset.editMode;
|
|
777
|
+
if (!this._restored && this._originalChildren) {
|
|
778
|
+
this.el.replaceChildren(...this._originalChildren);
|
|
779
|
+
this._restored = true;
|
|
780
|
+
}
|
|
781
|
+
super.destroy();
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** @returns {void} */
|
|
785
|
+
_renderColumns() {
|
|
786
|
+
const fragment = document.createDocumentFragment();
|
|
787
|
+
if (this.options.selectable === 'multi') {
|
|
788
|
+
fragment.append(h('col', { class: 'zx-table__selection-column' }));
|
|
789
|
+
}
|
|
790
|
+
const fractions = this._columns.map((column) => parseFraction(column.width));
|
|
791
|
+
const total = fractions.reduce((sum, value) => sum + value, 0);
|
|
792
|
+
this._table.dataset.layout = total > 0 ? 'fixed' : 'auto';
|
|
793
|
+
this._columns.forEach((column, index) => {
|
|
794
|
+
const col = h('col');
|
|
795
|
+
if (fractions[index] > 0) col.style.width = `${fractions[index] / total * 100}%`;
|
|
796
|
+
else if (column.width && column.width !== 'auto') col.style.width = String(column.width);
|
|
797
|
+
fragment.append(col);
|
|
798
|
+
});
|
|
799
|
+
this._colgroup.replaceChildren(fragment);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** @returns {void} */
|
|
803
|
+
_renderHeader() {
|
|
804
|
+
const row = h('tr', { class: 'zx-table__header-row' });
|
|
805
|
+
if (this._growStep) row.setAttribute('aria-rowindex', '1');
|
|
806
|
+
if (this.options.selectable === 'multi') {
|
|
807
|
+
this._selectAll = h('input', {
|
|
808
|
+
class: 'zx-table__checkbox zx-table__select-all',
|
|
809
|
+
type: 'checkbox',
|
|
810
|
+
ariaLabel: 'Select all rows'
|
|
811
|
+
});
|
|
812
|
+
const selectionHeader = h('th', {
|
|
813
|
+
class: 'zx-table__selection-header',
|
|
814
|
+
scope: 'col'
|
|
815
|
+
}, this._selectAll);
|
|
816
|
+
row.append(selectionHeader);
|
|
817
|
+
this.listen(this._selectAll, 'click', () => {
|
|
818
|
+
/*
|
|
819
|
+
* The rendered rows, not every row in the data. With `growing` on, selecting a hundred
|
|
820
|
+
* records because the reader ticked a box above the ten they can see is a trap — the next
|
|
821
|
+
* click is usually a bulk action. `showAll()` first if you mean all of them.
|
|
822
|
+
*/
|
|
823
|
+
if (this._selectAll.checked) {
|
|
824
|
+
for (const dataRow of this._visibleData()) this._selected.add(this._idFor(dataRow));
|
|
825
|
+
} else {
|
|
826
|
+
for (const dataRow of this._visibleData()) this._selected.delete(this._idFor(dataRow));
|
|
827
|
+
}
|
|
828
|
+
this._selectionAnchorId = null;
|
|
829
|
+
this._syncSelection();
|
|
830
|
+
this._emitSelectionChange();
|
|
831
|
+
});
|
|
832
|
+
} else {
|
|
833
|
+
this._selectAll = null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
this._headers = new Map();
|
|
837
|
+
for (const column of this._columns) {
|
|
838
|
+
const th = h('th', { scope: 'col' });
|
|
839
|
+
if (column.align) th.dataset.align = column.align;
|
|
840
|
+
if (column.headerTitle) th.title = column.headerTitle;
|
|
841
|
+
if (column.sortable) {
|
|
842
|
+
const button = h('button', {
|
|
843
|
+
class: 'zx-table__sort-button',
|
|
844
|
+
type: 'button'
|
|
845
|
+
}, h('span', { class: 'zx-table__header-label' }, column.label));
|
|
846
|
+
this.listen(button, 'click', () => {
|
|
847
|
+
const direction = this._sort?.id === column.id && this._sort.dir === 'asc' ? 'desc' : 'asc';
|
|
848
|
+
this.setSort(column.id, direction);
|
|
849
|
+
});
|
|
850
|
+
th.append(button);
|
|
851
|
+
} else {
|
|
852
|
+
th.append(h('span', { class: 'zx-table__header-label' }, column.label));
|
|
853
|
+
}
|
|
854
|
+
this._headers.set(column.id, th);
|
|
855
|
+
row.append(th);
|
|
856
|
+
}
|
|
857
|
+
this._thead.replaceChildren(row);
|
|
858
|
+
this._syncSortHeader();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** @returns {void} @fires Table#editcancel */
|
|
862
|
+
_renderBody() {
|
|
863
|
+
// Every data mutation funnels through here, so this is the single place that guarantees no
|
|
864
|
+
// editor component outlives the rows it was rendered into.
|
|
865
|
+
this._abortEdit({ focus: false });
|
|
866
|
+
const fragment = document.createDocumentFragment();
|
|
867
|
+
this._rowMeta = new WeakMap();
|
|
868
|
+
this._rowElements = new Map();
|
|
869
|
+
this._rovingCell = null;
|
|
870
|
+
this._rovingMatched = false;
|
|
871
|
+
if (this._data.length === 0) {
|
|
872
|
+
fragment.append(h('tr', { class: 'zx-table__empty-row' },
|
|
873
|
+
h('td', {
|
|
874
|
+
class: 'zx-table__empty',
|
|
875
|
+
colspan: this._columns.length + (this.options.selectable === 'multi' ? 1 : 0)
|
|
876
|
+
}, this._emptyContent())
|
|
877
|
+
));
|
|
878
|
+
} else {
|
|
879
|
+
// Indices stay absolute: a row's index is its place in the data, not in what is on screen.
|
|
880
|
+
this._visibleData().forEach((row, index) => fragment.append(this._createRow(row, index)));
|
|
881
|
+
}
|
|
882
|
+
this._tbody.replaceChildren(fragment);
|
|
883
|
+
/*
|
|
884
|
+
* With `growing` on, the DOM holds a prefix of the data — so without this a screen reader
|
|
885
|
+
* announces "row 3 of 10" for a table of ten thousand. The header counts as a row.
|
|
886
|
+
*/
|
|
887
|
+
if (this._growStep) this._table.setAttribute('aria-rowcount', String(this._data.length + 1));
|
|
888
|
+
this._syncSelectAll();
|
|
889
|
+
this._syncGrowing();
|
|
890
|
+
if (this._stacked) this._applyStackedRoles(true);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* What to put in the empty row.
|
|
895
|
+
*
|
|
896
|
+
* An empty table is a screen in its own right — the place to say why there is nothing and what to
|
|
897
|
+
* do about it — so this takes a node as readily as a string. A function is re-called on every
|
|
898
|
+
* render, which matters when the placeholder contains a button: reusing one node would move the
|
|
899
|
+
* same element between renders and quietly share its listeners.
|
|
900
|
+
* @returns {Node|string}
|
|
901
|
+
*/
|
|
902
|
+
_emptyContent() {
|
|
903
|
+
const empty = this.options.emptyText;
|
|
904
|
+
if (empty == null) return this.msg('table.empty');
|
|
905
|
+
return typeof empty === 'function' ? empty() : empty;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** @param {TableRow} row @param {number} index @returns {HTMLTableRowElement} */
|
|
909
|
+
_createRow(row, index) {
|
|
910
|
+
const id = this._idFor(row);
|
|
911
|
+
const tr = /** @type {HTMLTableRowElement} */ (h('tr', { dataset: { row: '' } }));
|
|
912
|
+
// Only a prefix of the data is in the DOM while growing, so each row states where it really is.
|
|
913
|
+
if (this._growStep) tr.setAttribute('aria-rowindex', String(index + 2));
|
|
914
|
+
const rowClass = typeof this.options.rowClass === 'function' ? this.options.rowClass(row) : '';
|
|
915
|
+
if (rowClass) tr.classList.add(...String(rowClass).split(/\s+/).filter(Boolean));
|
|
916
|
+
if (this.options.selectable !== false) tr.setAttribute('aria-selected', String(this._selected.has(id)));
|
|
917
|
+
this._rowMeta.set(tr, { row, id, index });
|
|
918
|
+
const elements = this._rowElements.get(id) ?? [];
|
|
919
|
+
elements.push(tr);
|
|
920
|
+
this._rowElements.set(id, elements);
|
|
921
|
+
|
|
922
|
+
if (this.options.selectable === 'multi') {
|
|
923
|
+
const checkbox = h('input', {
|
|
924
|
+
class: 'zx-table__checkbox zx-table__row-checkbox',
|
|
925
|
+
type: 'checkbox',
|
|
926
|
+
checked: this._selected.has(id),
|
|
927
|
+
ariaLabel: `Select row ${index + 1}`
|
|
928
|
+
});
|
|
929
|
+
tr.append(h('td', { class: 'zx-table__selection-cell' }, checkbox));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
this._columns.forEach((column) => {
|
|
933
|
+
const cell = /** @type {HTMLTableCellElement} */ (h('td'));
|
|
934
|
+
if (column.align) cell.dataset.align = column.align;
|
|
935
|
+
// Written unconditionally: `responsive` can be switched on after the rows exist, and a
|
|
936
|
+
// stacked cell has no column header above it to say what its value means.
|
|
937
|
+
cell.dataset.label = column.label;
|
|
938
|
+
if (column.popin === false) cell.dataset.popin = 'false';
|
|
939
|
+
if (this._editMode !== false) {
|
|
940
|
+
cell.dataset.column = column.id;
|
|
941
|
+
if (resolveEditable(column, row) !== false) {
|
|
942
|
+
cell.dataset.editable = 'true';
|
|
943
|
+
cell.tabIndex = -1;
|
|
944
|
+
this._offerRovingCell(cell, id, column.id);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
this._renderCellContent(cell, column, row, index);
|
|
948
|
+
tr.append(cell);
|
|
949
|
+
});
|
|
950
|
+
return tr;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Fills a cell with the column's rendered value, replacing anything already in it.
|
|
955
|
+
* @param {HTMLTableCellElement} cell Target cell.
|
|
956
|
+
* @param {TableColumn} column Column definition.
|
|
957
|
+
* @param {TableRow} row Row data.
|
|
958
|
+
* @param {number} index Row index.
|
|
959
|
+
* @returns {void}
|
|
960
|
+
*/
|
|
961
|
+
_renderCellContent(cell, column, row, index) {
|
|
962
|
+
const value = typeof column.render === 'function' ? column.render(row, index) : row?.[column.id];
|
|
963
|
+
if (value && typeof value === 'object' && typeof value.nodeType === 'number') cell.replaceChildren(value);
|
|
964
|
+
else if (value != null) cell.replaceChildren(document.createTextNode(String(value)));
|
|
965
|
+
else cell.replaceChildren();
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** @param {MouseEvent} event @returns {void} */
|
|
969
|
+
_handleBodyClick(event) {
|
|
970
|
+
const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : event.target?.parentElement);
|
|
971
|
+
// Interacting with an open editor is never a row activation.
|
|
972
|
+
if (this._edit && isInsideEditor(target)) return;
|
|
973
|
+
const rowElement = target?.closest('tr[data-row]');
|
|
974
|
+
if (!rowElement || !this._tbody.contains(rowElement)) return;
|
|
975
|
+
const meta = this._rowMeta.get(rowElement);
|
|
976
|
+
if (!meta) return;
|
|
977
|
+
// Read the row metadata first: committing re-renders the body and detaches `rowElement`.
|
|
978
|
+
if (this._edit) this.commitEdit();
|
|
979
|
+
|
|
980
|
+
const checkbox = target.closest('.zx-table__row-checkbox');
|
|
981
|
+
if (checkbox && this.options.selectable === 'multi') {
|
|
982
|
+
this._toggleMultiSelection(meta, /** @type {HTMLInputElement} */ (checkbox).checked, event.shiftKey);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
this.emit('rowclick', { ...meta, event });
|
|
987
|
+
if (this.options.selectable === 'single' && !this._selected.has(meta.id)) {
|
|
988
|
+
this._selected = new Set([meta.id]);
|
|
989
|
+
this._selectionAnchorId = meta.id;
|
|
990
|
+
this._syncSelection();
|
|
991
|
+
this._emitSelectionChange();
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** @param {MouseEvent} event @returns {void} @fires Table#editstart */
|
|
996
|
+
_handleBodyDoubleClick(event) {
|
|
997
|
+
const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : event.target?.parentElement);
|
|
998
|
+
if (target?.closest('.zx-table__row-checkbox')) return;
|
|
999
|
+
if (this._edit && isInsideEditor(target)) return;
|
|
1000
|
+
const rowElement = target?.closest('tr[data-row]');
|
|
1001
|
+
if (!rowElement || !this._tbody.contains(rowElement)) return;
|
|
1002
|
+
const meta = this._rowMeta.get(rowElement);
|
|
1003
|
+
if (!meta) return;
|
|
1004
|
+
if (this._editMode !== false) {
|
|
1005
|
+
const cell = target?.closest('td[data-editable="true"]');
|
|
1006
|
+
if (cell && this._tbody.contains(cell)) {
|
|
1007
|
+
// A double-click that opens an editor is an edit gesture, not a row activation.
|
|
1008
|
+
this.startEdit(meta.id, String(cell.dataset.column));
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
this.emit('rowdblclick', { ...meta, event });
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Builds the editor for one cell and mounts it over the cell's content.
|
|
1017
|
+
* @param {HTMLTableCellElement} td Cell to edit.
|
|
1018
|
+
* @param {TableColumn} column Column definition.
|
|
1019
|
+
* @param {TableRow} row Row being edited.
|
|
1020
|
+
* @param {number} index Row index.
|
|
1021
|
+
* @returns {TableEditorEntry|null} Editor entry, or null when the column is read-only.
|
|
1022
|
+
*/
|
|
1023
|
+
_createEditor(td, column, row, index) {
|
|
1024
|
+
const type = resolveEditable(column, row);
|
|
1025
|
+
if (type === false) return null;
|
|
1026
|
+
const value = row?.[column.id];
|
|
1027
|
+
const props = column.editorProps && typeof column.editorProps === 'object' ? column.editorProps : {};
|
|
1028
|
+
// The rendered content stays in the cell but invisible, so the column keeps its intrinsic width
|
|
1029
|
+
// while the editor — which is taken out of flow — can never change the row height.
|
|
1030
|
+
const shadow = h('span', { class: 'zx-table__cell-shadow', ariaHidden: 'true' });
|
|
1031
|
+
shadow.append(...td.childNodes);
|
|
1032
|
+
const host = h('div', { class: 'zx-table__editor', dataset: { editor: type } });
|
|
1033
|
+
td.replaceChildren(shadow, host);
|
|
1034
|
+
td.dataset.editing = 'true';
|
|
1035
|
+
|
|
1036
|
+
/** @type {TableEditorEntry} */
|
|
1037
|
+
const entry = {
|
|
1038
|
+
column,
|
|
1039
|
+
type,
|
|
1040
|
+
td,
|
|
1041
|
+
host,
|
|
1042
|
+
control: null,
|
|
1043
|
+
component: null,
|
|
1044
|
+
read: () => value,
|
|
1045
|
+
destroy() {
|
|
1046
|
+
this.component?.destroy();
|
|
1047
|
+
this.host.remove();
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
if (type === 'custom') {
|
|
1052
|
+
entry.custom = value;
|
|
1053
|
+
entry.read = () => parseCellValue(column, entry.custom, row);
|
|
1054
|
+
/** @type {TableEditorApi} */
|
|
1055
|
+
const api = {
|
|
1056
|
+
value,
|
|
1057
|
+
row,
|
|
1058
|
+
column,
|
|
1059
|
+
commit: (next) => {
|
|
1060
|
+
entry.custom = next;
|
|
1061
|
+
this.commitEdit();
|
|
1062
|
+
},
|
|
1063
|
+
cancel: () => this.cancelEdit()
|
|
1064
|
+
};
|
|
1065
|
+
const node = column.editor(row, api);
|
|
1066
|
+
// A custom editor may return a Node or a Zx component; a component is adopted so that it is
|
|
1067
|
+
// destroyed with the edit instead of outliving the row it was rendered into.
|
|
1068
|
+
if (node && typeof node.toElement === 'function') {
|
|
1069
|
+
if (typeof node.destroy === 'function') entry.component = node;
|
|
1070
|
+
host.append(node.toElement());
|
|
1071
|
+
} else if (node) {
|
|
1072
|
+
host.append(node);
|
|
1073
|
+
}
|
|
1074
|
+
entry.control = /** @type {HTMLElement|null} */ (host.querySelector(FOCUSABLE)) ?? host;
|
|
1075
|
+
return entry;
|
|
1076
|
+
}
|
|
1077
|
+
if (type === 'number') {
|
|
1078
|
+
const field = new NumberField(null, { ...props, value: value ?? null });
|
|
1079
|
+
host.append(field.el);
|
|
1080
|
+
entry.component = field;
|
|
1081
|
+
entry.control = field.getInput();
|
|
1082
|
+
entry.read = () => {
|
|
1083
|
+
// The field only parses its text on blur or Enter; a commit triggered by Tab or by a click
|
|
1084
|
+
// outside must not discard what the user has typed.
|
|
1085
|
+
field.set(field.getInput().value, { silent: true });
|
|
1086
|
+
return parseCellValue(column, field.get(), row);
|
|
1087
|
+
};
|
|
1088
|
+
return entry;
|
|
1089
|
+
}
|
|
1090
|
+
if (type === 'select') {
|
|
1091
|
+
const items = normalizeEditorOptions(column.options, row);
|
|
1092
|
+
// Fall back to a loose match so `{value: label}` maps (whose keys are always strings) still
|
|
1093
|
+
// open on the right option for a numeric row value.
|
|
1094
|
+
const match = items.find((item) => Object.is(item.value, value))
|
|
1095
|
+
?? items.find((item) => String(item.value) === String(value ?? ''));
|
|
1096
|
+
const select = new Select(null, {
|
|
1097
|
+
...props, items, valueKey: 'value', labelKey: 'label', value: match ? match.value : null
|
|
1098
|
+
});
|
|
1099
|
+
host.append(select.el);
|
|
1100
|
+
entry.component = select;
|
|
1101
|
+
entry.control = /** @type {HTMLElement} */ (select.refs.input);
|
|
1102
|
+
entry.read = () => parseCellValue(column, select.value, row);
|
|
1103
|
+
return entry;
|
|
1104
|
+
}
|
|
1105
|
+
if (type === 'date') {
|
|
1106
|
+
const datebox = new Datebox(null, { ...props, value: value ?? null });
|
|
1107
|
+
host.append(datebox.el);
|
|
1108
|
+
entry.component = datebox;
|
|
1109
|
+
const dateInput = /** @type {HTMLInputElement} */ (datebox.refs.input);
|
|
1110
|
+
entry.control = dateInput;
|
|
1111
|
+
entry.read = () => {
|
|
1112
|
+
// Same as the number editor: push the typed text through the box's own parser, but keep
|
|
1113
|
+
// the previous date when it cannot be read rather than clearing the cell.
|
|
1114
|
+
const text = String(dateInput.value ?? '').trim();
|
|
1115
|
+
const previous = datebox.get();
|
|
1116
|
+
if (text === '') return parseCellValue(column, null, row);
|
|
1117
|
+
datebox.set(text, { silent: true });
|
|
1118
|
+
if (datebox.get() === null) datebox.set(previous, { silent: true });
|
|
1119
|
+
return parseCellValue(column, datebox.get(), row);
|
|
1120
|
+
};
|
|
1121
|
+
return entry;
|
|
1122
|
+
}
|
|
1123
|
+
if (type === 'checkbox') {
|
|
1124
|
+
const toggle = new Toggle(null, { ...props, checked: Boolean(value) });
|
|
1125
|
+
host.append(toggle.el);
|
|
1126
|
+
entry.component = toggle;
|
|
1127
|
+
entry.control = /** @type {HTMLElement} */ (toggle.el);
|
|
1128
|
+
entry.read = () => parseCellValue(column, toggle.get(), row);
|
|
1129
|
+
return entry;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const textarea = type === 'textarea';
|
|
1133
|
+
const input = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (h(textarea ? 'textarea' : 'input', {
|
|
1134
|
+
...props,
|
|
1135
|
+
class: textarea ? 'zx-table__editor-input zx-table__editor-textarea' : 'zx-table__editor-input',
|
|
1136
|
+
...(textarea ? {} : { type: 'text' }),
|
|
1137
|
+
ariaLabel: props.ariaLabel ?? `${column.label} row ${index + 1}`,
|
|
1138
|
+
value: formatCellValue(column, value, row)
|
|
1139
|
+
}));
|
|
1140
|
+
host.append(input);
|
|
1141
|
+
entry.control = input;
|
|
1142
|
+
entry.read = () => parseCellValue(column, input.value, row);
|
|
1143
|
+
return entry;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Moves focus into one of the open editors.
|
|
1148
|
+
* @param {string} columnId Column id.
|
|
1149
|
+
* @returns {void}
|
|
1150
|
+
*/
|
|
1151
|
+
_focusEditor(columnId) {
|
|
1152
|
+
const entry = this._edit?.cells.get(columnId);
|
|
1153
|
+
const control = entry?.control;
|
|
1154
|
+
if (!control || typeof control.focus !== 'function') return;
|
|
1155
|
+
control.focus();
|
|
1156
|
+
// Opening a text editor selects its content, so typing replaces the old value.
|
|
1157
|
+
if (entry.type === 'text' && typeof control.select === 'function') control.select();
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Moves focus back onto a cell.
|
|
1162
|
+
* @param {unknown} id Row id.
|
|
1163
|
+
* @param {string} columnId Column id.
|
|
1164
|
+
* @returns {void}
|
|
1165
|
+
*/
|
|
1166
|
+
_focusCell(id, columnId) {
|
|
1167
|
+
const tr = this._rowElements.get(id)?.[0];
|
|
1168
|
+
const td = tr ? cellIn(tr, columnId) : null;
|
|
1169
|
+
td?.focus();
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** Destroys every open editor and restores the cells they covered. @returns {void} */
|
|
1173
|
+
_teardownEdit() {
|
|
1174
|
+
const edit = this._edit;
|
|
1175
|
+
if (!edit) return;
|
|
1176
|
+
this._edit = null;
|
|
1177
|
+
const index = this._data.findIndex((candidate) => Object.is(this._idFor(candidate), edit.id));
|
|
1178
|
+
const row = index < 0 ? edit.row : this._data[index];
|
|
1179
|
+
for (const entry of edit.cells.values()) {
|
|
1180
|
+
entry.destroy();
|
|
1181
|
+
delete entry.td.dataset.editing;
|
|
1182
|
+
delete entry.td.dataset.invalid;
|
|
1183
|
+
this._renderCellContent(entry.td, entry.column, row, index < 0 ? 0 : index);
|
|
1184
|
+
}
|
|
1185
|
+
edit.cells.clear();
|
|
1186
|
+
delete edit.tr.dataset.editing;
|
|
1187
|
+
this._clearInvalid();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Cancels the open edit, optionally returning focus to its cell.
|
|
1192
|
+
* @param {{focus?: boolean}} [options={}] Whether focus returns to the edited cell.
|
|
1193
|
+
* @returns {void}
|
|
1194
|
+
* @fires Table#editcancel
|
|
1195
|
+
*/
|
|
1196
|
+
_abortEdit({ focus = true } = {}) {
|
|
1197
|
+
const edit = this._edit;
|
|
1198
|
+
if (!edit) return;
|
|
1199
|
+
const restoreFocus = focus && this._editHasFocus();
|
|
1200
|
+
const row = this.getRow(edit.id) ?? edit.row;
|
|
1201
|
+
const column = this._columns.find((candidate) => candidate.id === edit.columnId) ?? null;
|
|
1202
|
+
this._teardownEdit();
|
|
1203
|
+
if (restoreFocus) this._focusCell(edit.id, edit.columnId);
|
|
1204
|
+
this.emit('editcancel', { row, id: edit.id, column, columnId: edit.columnId });
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** @returns {boolean} Whether focus currently sits inside this table. */
|
|
1208
|
+
_editHasFocus() {
|
|
1209
|
+
const active = document.activeElement;
|
|
1210
|
+
return Boolean(active && this.el?.contains(active));
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Marks one edited cell invalid and announces the message.
|
|
1215
|
+
* @param {string} columnId Rejected column id.
|
|
1216
|
+
* @param {string} message Validation message.
|
|
1217
|
+
* @returns {void}
|
|
1218
|
+
*/
|
|
1219
|
+
_markInvalid(columnId, message) {
|
|
1220
|
+
this._clearInvalid();
|
|
1221
|
+
const entry = this._edit?.cells.get(columnId);
|
|
1222
|
+
if (!entry) return;
|
|
1223
|
+
entry.td.dataset.invalid = 'true';
|
|
1224
|
+
entry.control?.setAttribute('aria-invalid', 'true');
|
|
1225
|
+
if (!this._editMessage) return;
|
|
1226
|
+
entry.control?.setAttribute('aria-describedby', this._editMessage.id);
|
|
1227
|
+
this._editMessage.textContent = message;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** Clears the invalid marks and the validation message. @returns {void} */
|
|
1231
|
+
_clearInvalid() {
|
|
1232
|
+
if (this._edit) {
|
|
1233
|
+
for (const entry of this._edit.cells.values()) {
|
|
1234
|
+
delete entry.td.dataset.invalid;
|
|
1235
|
+
entry.control?.removeAttribute('aria-invalid');
|
|
1236
|
+
entry.control?.removeAttribute('aria-describedby');
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
if (this._editMessage) this._editMessage.textContent = '';
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Handles Enter/F2 on a focused cell and Escape/Enter/Tab inside an open editor.
|
|
1244
|
+
* @param {KeyboardEvent} event Keydown event.
|
|
1245
|
+
* @returns {void}
|
|
1246
|
+
*/
|
|
1247
|
+
_handleEditKeydown(event) {
|
|
1248
|
+
const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : event.target?.parentElement);
|
|
1249
|
+
if (!target) return;
|
|
1250
|
+
|
|
1251
|
+
if (this._edit && isInsideEditor(target)) {
|
|
1252
|
+
// An open combobox list or calendar owns these keys until it closes again.
|
|
1253
|
+
if (deferToEditor(target)) return;
|
|
1254
|
+
if (event.key === 'Escape') {
|
|
1255
|
+
if (event.defaultPrevented) return;
|
|
1256
|
+
event.preventDefault();
|
|
1257
|
+
this.cancelEdit();
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (event.key === 'Enter') {
|
|
1261
|
+
if (target.localName === 'textarea' && event.shiftKey) return;
|
|
1262
|
+
event.preventDefault();
|
|
1263
|
+
this.commitEdit();
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
if (event.key === 'Tab') this._moveEdit(event);
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (event.key !== 'Enter' && event.key !== 'F2') return;
|
|
1271
|
+
const cell = target.closest('td[data-editable="true"]');
|
|
1272
|
+
if (!cell || !this._tbody.contains(cell)) return;
|
|
1273
|
+
const meta = this._rowMeta.get(cell.parentElement);
|
|
1274
|
+
if (!meta) return;
|
|
1275
|
+
event.preventDefault();
|
|
1276
|
+
this.startEdit(meta.id, String(cell.dataset.column));
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Commits the current editor and moves the edit one editable cell forward or backward.
|
|
1281
|
+
* @param {KeyboardEvent} event Tab keydown.
|
|
1282
|
+
* @returns {void}
|
|
1283
|
+
*/
|
|
1284
|
+
_moveEdit(event) {
|
|
1285
|
+
const edit = this._edit;
|
|
1286
|
+
if (!edit) return;
|
|
1287
|
+
const direction = event.shiftKey ? -1 : 1;
|
|
1288
|
+
const index = this._data.findIndex((candidate) => Object.is(this._idFor(candidate), edit.id));
|
|
1289
|
+
const next = this._nextEditableCell(index, edit.columnId, direction);
|
|
1290
|
+
|
|
1291
|
+
if (next && Object.is(next.id, edit.id) && edit.cells.has(next.columnId)) {
|
|
1292
|
+
// Row mode: the neighbouring editor is already open, so this is a plain focus move.
|
|
1293
|
+
event.preventDefault();
|
|
1294
|
+
edit.columnId = next.columnId;
|
|
1295
|
+
this._focusEditor(next.columnId);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (!next) {
|
|
1299
|
+
// Past the last editable cell: commit, then let the browser move focus out of the table.
|
|
1300
|
+
this.commitEdit();
|
|
1301
|
+
if (this._edit) event.preventDefault();
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
event.preventDefault();
|
|
1305
|
+
this.commitEdit();
|
|
1306
|
+
if (!this._edit) this.startEdit(next.id, next.columnId);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Finds the next editable cell, wrapping from the end of a row into the next row.
|
|
1311
|
+
* @param {number} rowIndex Row index to start from.
|
|
1312
|
+
* @param {string} columnId Column id to start from.
|
|
1313
|
+
* @param {number} direction `1` forward, `-1` backward.
|
|
1314
|
+
* @returns {{id: unknown, columnId: string, index: number}|null} Next cell, or null at the end.
|
|
1315
|
+
*/
|
|
1316
|
+
_nextEditableCell(rowIndex, columnId, direction) {
|
|
1317
|
+
const columnCount = this._columns.length;
|
|
1318
|
+
if (rowIndex < 0 || columnCount === 0) return null;
|
|
1319
|
+
let row = rowIndex;
|
|
1320
|
+
let column = this._columns.findIndex((candidate) => candidate.id === columnId);
|
|
1321
|
+
const limit = this._data.length * columnCount + columnCount;
|
|
1322
|
+
for (let step = 0; step < limit; step += 1) {
|
|
1323
|
+
column += direction;
|
|
1324
|
+
if (column >= columnCount) {
|
|
1325
|
+
row += 1;
|
|
1326
|
+
column = 0;
|
|
1327
|
+
} else if (column < 0) {
|
|
1328
|
+
row -= 1;
|
|
1329
|
+
column = columnCount - 1;
|
|
1330
|
+
}
|
|
1331
|
+
if (row < 0 || row >= this._data.length) return null;
|
|
1332
|
+
const candidateRow = this._data[row];
|
|
1333
|
+
const candidateColumn = this._columns[column];
|
|
1334
|
+
if (resolveEditable(candidateColumn, candidateRow) !== false) {
|
|
1335
|
+
return { id: this._idFor(candidateRow), columnId: candidateColumn.id, index: row };
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
return null;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** @param {Event} event @returns {void} */
|
|
1342
|
+
_handleEditFocusIn(event) {
|
|
1343
|
+
const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : null);
|
|
1344
|
+
const cell = target?.closest('td[data-editable="true"]');
|
|
1345
|
+
if (!cell || !this._tbody.contains(cell)) return;
|
|
1346
|
+
const meta = this._rowMeta.get(cell.parentElement);
|
|
1347
|
+
if (!meta) return;
|
|
1348
|
+
this._setRovingCell(/** @type {HTMLTableCellElement} */ (cell), meta.id, String(cell.dataset.column));
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** @param {Event} event @returns {void} @fires Table#editcommit */
|
|
1352
|
+
_handleDocumentPointerDown(event) {
|
|
1353
|
+
if (!this._edit) return;
|
|
1354
|
+
const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : event.target?.parentElement);
|
|
1355
|
+
if (target && isInsideEditor(target)) return;
|
|
1356
|
+
// Pointer events inside the body are resolved by the click handler, which commits there and
|
|
1357
|
+
// still reports `rowclick`.
|
|
1358
|
+
if (target && this._tbody.contains(target)) return;
|
|
1359
|
+
this.commitEdit();
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Keeps exactly one editable cell in the tab order, preferring the last focused one.
|
|
1364
|
+
* @param {HTMLTableCellElement} cell Newly rendered editable cell.
|
|
1365
|
+
* @param {unknown} id Row id.
|
|
1366
|
+
* @param {string} columnId Column id.
|
|
1367
|
+
* @returns {void}
|
|
1368
|
+
*/
|
|
1369
|
+
_offerRovingCell(cell, id, columnId) {
|
|
1370
|
+
if (this._rovingMatched) return;
|
|
1371
|
+
const preferred = this._rovingKey !== null
|
|
1372
|
+
&& Object.is(this._rovingKey.id, id) && this._rovingKey.columnId === columnId;
|
|
1373
|
+
if (!preferred && this._rovingCell) return;
|
|
1374
|
+
if (preferred && this._rovingCell) this._rovingCell.tabIndex = -1;
|
|
1375
|
+
cell.tabIndex = 0;
|
|
1376
|
+
this._rovingCell = cell;
|
|
1377
|
+
this._rovingMatched = preferred;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** @param {HTMLTableCellElement} cell @param {unknown} id @param {string} columnId @returns {void} */
|
|
1381
|
+
_setRovingCell(cell, id, columnId) {
|
|
1382
|
+
if (this._rovingCell && this._rovingCell !== cell) this._rovingCell.tabIndex = -1;
|
|
1383
|
+
cell.tabIndex = 0;
|
|
1384
|
+
this._rovingCell = cell;
|
|
1385
|
+
this._rovingMatched = true;
|
|
1386
|
+
this._rovingKey = { id, columnId };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Resolves a message key with an English fallback for hosts without a translator.
|
|
1391
|
+
* @param {string} key Message key.
|
|
1392
|
+
* @param {string} fallback Text used when the key is untranslated.
|
|
1393
|
+
* @returns {string}
|
|
1394
|
+
*/
|
|
1395
|
+
_msgOr(key, fallback) {
|
|
1396
|
+
const message = this.msg(key);
|
|
1397
|
+
return message === key ? fallback : message;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** @param {{row: TableRow, id: unknown, index: number}} meta @param {boolean} checked @param {boolean} shiftKey @returns {void} */
|
|
1401
|
+
_toggleMultiSelection(meta, checked, shiftKey) {
|
|
1402
|
+
const anchorIndex = this._data.findIndex((row) => Object.is(this._idFor(row), this._selectionAnchorId));
|
|
1403
|
+
if (shiftKey && anchorIndex >= 0) {
|
|
1404
|
+
const start = Math.min(anchorIndex, meta.index);
|
|
1405
|
+
const end = Math.max(anchorIndex, meta.index);
|
|
1406
|
+
for (let index = start; index <= end; index += 1) {
|
|
1407
|
+
const id = this._idFor(this._data[index]);
|
|
1408
|
+
if (checked) this._selected.add(id);
|
|
1409
|
+
else this._selected.delete(id);
|
|
1410
|
+
}
|
|
1411
|
+
} else if (checked) {
|
|
1412
|
+
this._selected.add(meta.id);
|
|
1413
|
+
} else {
|
|
1414
|
+
this._selected.delete(meta.id);
|
|
1415
|
+
}
|
|
1416
|
+
this._selectionAnchorId = meta.id;
|
|
1417
|
+
this._syncSelection();
|
|
1418
|
+
this._emitSelectionChange();
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** @returns {void} */
|
|
1422
|
+
_syncSelection() {
|
|
1423
|
+
for (const [id, rows] of this._rowElements) {
|
|
1424
|
+
const selected = this._selected.has(id);
|
|
1425
|
+
for (const row of rows) {
|
|
1426
|
+
row.setAttribute('aria-selected', String(selected));
|
|
1427
|
+
const checkbox = row.querySelector('.zx-table__row-checkbox');
|
|
1428
|
+
if (checkbox) checkbox.checked = selected;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
this._syncSelectAll();
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** @returns {void} */
|
|
1435
|
+
_syncSelectAll() {
|
|
1436
|
+
if (!this._selectAll) return;
|
|
1437
|
+
// Measured against what is rendered, so the box reflects the rows the reader can actually see.
|
|
1438
|
+
const visible = this._visibleData();
|
|
1439
|
+
const selectedRows = visible.reduce(
|
|
1440
|
+
(count, row) => count + Number(this._selected.has(this._idFor(row))),
|
|
1441
|
+
0
|
|
1442
|
+
);
|
|
1443
|
+
this._selectAll.disabled = visible.length === 0;
|
|
1444
|
+
this._selectAll.checked = visible.length > 0 && selectedRows === visible.length;
|
|
1445
|
+
this._selectAll.indeterminate = selectedRows > 0 && selectedRows < visible.length;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/** @returns {void} */
|
|
1449
|
+
_syncSortHeader() {
|
|
1450
|
+
if (!this._headers) return;
|
|
1451
|
+
for (const [id, header] of this._headers) {
|
|
1452
|
+
if (this._sort?.id === id) header.setAttribute('aria-sort', this._sort.dir === 'asc' ? 'ascending' : 'descending');
|
|
1453
|
+
else header.removeAttribute('aria-sort');
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** @returns {void} */
|
|
1458
|
+
_sortData() {
|
|
1459
|
+
if (!this._sort) return;
|
|
1460
|
+
const column = this._columns.find((candidate) => candidate.id === this._sort.id);
|
|
1461
|
+
if (!column) return;
|
|
1462
|
+
const getValue = typeof column.sortValue === 'function' ? column.sortValue : (row) => row?.[column.id];
|
|
1463
|
+
this._data = sortRows(this._data, getValue, this._sort.dir);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** @returns {boolean} Whether selection changed. */
|
|
1467
|
+
_pruneSelection() {
|
|
1468
|
+
const valid = new Set(this._data.map((row) => this._idFor(row)));
|
|
1469
|
+
const previousSize = this._selected.size;
|
|
1470
|
+
for (const id of this._selected) if (!valid.has(id)) this._selected.delete(id);
|
|
1471
|
+
if (!valid.has(this._selectionAnchorId)) this._selectionAnchorId = null;
|
|
1472
|
+
return previousSize !== this._selected.size;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/** @param {TableRow} row @returns {unknown} */
|
|
1476
|
+
_idFor(row) {
|
|
1477
|
+
return typeof this.options.rowId === 'function' ? this.options.rowId(row) : row?.[this.options.rowId];
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** @returns {void} @fires Table#selectionchange */
|
|
1481
|
+
_emitSelectionChange() {
|
|
1482
|
+
const rows = this.getSelection();
|
|
1483
|
+
this.emit('selectionchange', { rows, ids: rows.map((row) => this._idFor(row)) });
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/** @returns {void} @fires Table#datachange */
|
|
1487
|
+
_emitDataChange() {
|
|
1488
|
+
this.emit('datachange', { rows: this.getData() });
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/**
|
|
1493
|
+
* Row activation event.
|
|
1494
|
+
* @event Table#rowclick
|
|
1495
|
+
* @type {CustomEvent<TableRowClickDetail>}
|
|
1496
|
+
*/
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Row double-activation event.
|
|
1500
|
+
* @event Table#rowdblclick
|
|
1501
|
+
* @type {CustomEvent<TableRowClickDetail>}
|
|
1502
|
+
*/
|
|
1503
|
+
|
|
1504
|
+
/** @event Table#sort @type {CustomEvent<TableSortDetail>} */
|
|
1505
|
+
/** @event Table#selectionchange @type {CustomEvent<TableSelectionDetail>} */
|
|
1506
|
+
/** @event Table#datachange @type {CustomEvent<TableDataDetail>} */
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Editor opened for a cell (or, in row mode, for the row).
|
|
1510
|
+
* @event Table#editstart
|
|
1511
|
+
* @type {CustomEvent<TableEditStartDetail>}
|
|
1512
|
+
*/
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* Values are about to be written back. Cancelable: `preventDefault()` keeps the editor open, which
|
|
1516
|
+
* lets a server round-trip reject the change.
|
|
1517
|
+
* @event Table#editcommit
|
|
1518
|
+
* @type {CustomEvent<TableEditCommitDetail>}
|
|
1519
|
+
*/
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* Editing ended without writing anything back — Escape, `cancelEdit()`, or a data refresh
|
|
1523
|
+
* (`setData`, `setSort`, …) that replaced the edited rows.
|
|
1524
|
+
* @event Table#editcancel
|
|
1525
|
+
* @type {CustomEvent<TableEditCancelDetail>}
|
|
1526
|
+
*/
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* A column's `validate` rejected the commit.
|
|
1530
|
+
* @event Table#editinvalid
|
|
1531
|
+
* @type {CustomEvent<TableEditInvalidDetail>}
|
|
1532
|
+
*/
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* Resolves the editor type of a column for one row.
|
|
1536
|
+
*
|
|
1537
|
+
* `column.editor` wins over `column.editable`, except that a per-row `editable` function returning
|
|
1538
|
+
* `false` still marks the cell read-only. `editable: true` means `'text'`; unknown type names are
|
|
1539
|
+
* treated as not editable.
|
|
1540
|
+
* @param {TableColumn} column Column definition.
|
|
1541
|
+
* @param {TableRow} row Row the cell belongs to.
|
|
1542
|
+
* @returns {TableEditorType|false} Editor type, or false when the cell is read-only.
|
|
1543
|
+
*/
|
|
1544
|
+
export function resolveEditable(column, row) {
|
|
1545
|
+
if (!column || typeof column !== 'object') return false;
|
|
1546
|
+
if (typeof column.editor === 'function') {
|
|
1547
|
+
if (typeof column.editable === 'function' && column.editable(row) === false) return false;
|
|
1548
|
+
return 'custom';
|
|
1549
|
+
}
|
|
1550
|
+
const declared = typeof column.editable === 'function' ? column.editable(row) : column.editable;
|
|
1551
|
+
if (declared === true) return 'text';
|
|
1552
|
+
if (typeof declared === 'string' && EDITOR_TYPES.has(declared)) return /** @type {TableEditorType} */ (declared);
|
|
1553
|
+
return false;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* Normalizes `column.options` into `[{value, label}]`. Object maps always yield string values,
|
|
1558
|
+
* because object keys are strings; use the array form to keep other value types.
|
|
1559
|
+
* @param {TableEditorOption[]|Record<string, string>|((row: TableRow) => unknown)|undefined} source Option source.
|
|
1560
|
+
* @param {TableRow} row Row the options are resolved for.
|
|
1561
|
+
* @returns {TableEditorOption[]} Normalized options.
|
|
1562
|
+
*/
|
|
1563
|
+
export function normalizeEditorOptions(source, row) {
|
|
1564
|
+
const raw = typeof source === 'function' ? source(row) : source;
|
|
1565
|
+
if (Array.isArray(raw)) {
|
|
1566
|
+
return raw.map((item) => (item !== null && typeof item === 'object'
|
|
1567
|
+
? { value: item.value, label: String(item.label ?? item.value ?? '') }
|
|
1568
|
+
: { value: item, label: String(item ?? '') }));
|
|
1569
|
+
}
|
|
1570
|
+
if (raw !== null && typeof raw === 'object') {
|
|
1571
|
+
return Object.entries(raw).map(([value, label]) => ({ value, label: String(label ?? '') }));
|
|
1572
|
+
}
|
|
1573
|
+
return [];
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* Renders a value as the initial text of a text or textarea editor.
|
|
1578
|
+
* @param {TableColumn} column Column definition.
|
|
1579
|
+
* @param {unknown} value Cell value.
|
|
1580
|
+
* @param {TableRow} row Row the cell belongs to.
|
|
1581
|
+
* @returns {string} Editor text.
|
|
1582
|
+
*/
|
|
1583
|
+
export function formatCellValue(column, value, row) {
|
|
1584
|
+
if (typeof column?.format === 'function') {
|
|
1585
|
+
const formatted = column.format(value, row);
|
|
1586
|
+
return formatted == null ? '' : String(formatted);
|
|
1587
|
+
}
|
|
1588
|
+
return value == null ? '' : String(value);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* Converts an editor's raw output into the value written back into the row.
|
|
1593
|
+
* @param {TableColumn} column Column definition.
|
|
1594
|
+
* @param {unknown} raw Editor output.
|
|
1595
|
+
* @param {TableRow} row Row the cell belongs to.
|
|
1596
|
+
* @returns {unknown} Value for the row.
|
|
1597
|
+
*/
|
|
1598
|
+
export function parseCellValue(column, raw, row) {
|
|
1599
|
+
return typeof column?.parse === 'function' ? column.parse(raw, row) : raw;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* Compares two cell values, treating equal-timestamp dates as unchanged.
|
|
1604
|
+
* @param {unknown} left First value.
|
|
1605
|
+
* @param {unknown} right Second value.
|
|
1606
|
+
* @returns {boolean} Whether the values are equal.
|
|
1607
|
+
*/
|
|
1608
|
+
export function valuesEqual(left, right) {
|
|
1609
|
+
if (Object.is(left, right)) return true;
|
|
1610
|
+
if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
|
|
1611
|
+
return false;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Diffs edited values against a row and returns only what actually changed. This is the `changes`
|
|
1616
|
+
* map carried by `editcommit`; in row mode it holds every changed cell of the row.
|
|
1617
|
+
* @param {TableRow} row Row before the commit.
|
|
1618
|
+
* @param {Record<string, unknown>} values Edited values keyed by column id.
|
|
1619
|
+
* @returns {Record<string, unknown>} Changed values keyed by column id.
|
|
1620
|
+
*/
|
|
1621
|
+
export function diffChanges(row, values) {
|
|
1622
|
+
/** @type {Record<string, unknown>} */
|
|
1623
|
+
const changes = {};
|
|
1624
|
+
for (const [columnId, value] of Object.entries(values ?? {})) {
|
|
1625
|
+
if (!valuesEqual(row?.[columnId], value)) changes[columnId] = value;
|
|
1626
|
+
}
|
|
1627
|
+
return changes;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/**
|
|
1631
|
+
* Runs the columns' `validate` callbacks in column order and stops at the first rejection, so a
|
|
1632
|
+
* later validator never runs once one has failed.
|
|
1633
|
+
* @param {Record<string, unknown>} values Edited values keyed by column id.
|
|
1634
|
+
* @param {TableColumn[]} columns Columns to validate, in order.
|
|
1635
|
+
* @param {TableRow} row Row being edited.
|
|
1636
|
+
* @param {string} [fallbackMessage='Invalid value'] Message used when a validator returns `false`.
|
|
1637
|
+
* @returns {{columnId: string, message: string}|null} First rejection, or null when all pass.
|
|
1638
|
+
*/
|
|
1639
|
+
export function firstValidationError(values, columns, row, fallbackMessage = 'Invalid value') {
|
|
1640
|
+
for (const column of columns ?? []) {
|
|
1641
|
+
if (typeof column?.validate !== 'function') continue;
|
|
1642
|
+
if (!Object.prototype.hasOwnProperty.call(values ?? {}, column.id)) continue;
|
|
1643
|
+
const result = column.validate(values[column.id], row);
|
|
1644
|
+
if (typeof result === 'string') return { columnId: column.id, message: result };
|
|
1645
|
+
if (result === false) return { columnId: column.id, message: fallbackMessage };
|
|
1646
|
+
}
|
|
1647
|
+
return null;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/** @param {unknown} mode @returns {TableEditMode} */
|
|
1651
|
+
function normalizeEditMode(mode) {
|
|
1652
|
+
if (mode === 'cell' || mode === 'row') return mode;
|
|
1653
|
+
return mode === true ? 'cell' : false;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** @param {Element|null} target @returns {boolean} */
|
|
1657
|
+
function isInsideEditor(target) {
|
|
1658
|
+
return Boolean(target?.closest?.('.zx-table__editor'));
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* Whether a child component still owns Escape/Enter/Tab because one of its overlays is open.
|
|
1663
|
+
* @param {Element} target Key event target.
|
|
1664
|
+
* @returns {boolean}
|
|
1665
|
+
*/
|
|
1666
|
+
function deferToEditor(target) {
|
|
1667
|
+
if (target.closest('[popover]')) return true;
|
|
1668
|
+
return target.getAttribute?.('aria-expanded') === 'true';
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/** @param {HTMLTableRowElement} tr @param {string} columnId @returns {HTMLTableCellElement|null} */
|
|
1672
|
+
function cellIn(tr, columnId) {
|
|
1673
|
+
for (const cell of tr.cells) {
|
|
1674
|
+
if (cell.dataset.column === columnId) return /** @type {HTMLTableCellElement} */ (cell);
|
|
1675
|
+
}
|
|
1676
|
+
return null;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/** @param {unknown} sort @param {TableColumn[]} columns @returns {{id: string, dir: TableSortDirection}|null} */
|
|
1680
|
+
function normalizeSort(sort, columns) {
|
|
1681
|
+
if (!sort || typeof sort !== 'object') return null;
|
|
1682
|
+
if (!columns.some((column) => column.id === sort.id)) return null;
|
|
1683
|
+
return { id: sort.id, dir: sort.dir === 'desc' ? 'desc' : 'asc' };
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/** @param {unknown} width @returns {number} */
|
|
1687
|
+
function parseFraction(width) {
|
|
1688
|
+
if (typeof width !== 'string') return 0;
|
|
1689
|
+
const match = /^(\d*\.?\d+)fr$/.exec(width.trim());
|
|
1690
|
+
return match ? Math.max(0, Number(match[1])) : 0;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/** @param {unknown} rows @returns {asserts rows is TableRow[]} */
|
|
1694
|
+
function assertRows(rows) {
|
|
1695
|
+
if (!Array.isArray(rows)) throw new TypeError('Table data must be an array');
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** @param {Set<unknown>} left @param {Set<unknown>} right @returns {boolean} */
|
|
1699
|
+
function sameSet(left, right) {
|
|
1700
|
+
return left.size === right.size && [...left].every((value) => right.has(value));
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* Normalizes the `growing` option into a batch size.
|
|
1705
|
+
* @param {unknown} growing Option value.
|
|
1706
|
+
* @returns {number|null} Batch size, or null when the table renders everything at once.
|
|
1707
|
+
*/
|
|
1708
|
+
function growStep(growing) {
|
|
1709
|
+
const step = Math.trunc(Number(growing));
|
|
1710
|
+
return Number.isFinite(step) && step > 0 ? step : null;
|
|
1711
|
+
}
|