@getaster/elements 0.0.0-stage → 0.1.0-preview.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/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +6 -2
- package/bin/aster-theme-check.js +317 -0
- package/dist/base/drag.d.ts +45 -0
- package/dist/base/drag.js +139 -0
- package/dist/base/element.d.ts +51 -0
- package/dist/base/element.js +133 -0
- package/dist/breadcrumbs/breadcrumbs.d.ts +23 -0
- package/dist/breadcrumbs/breadcrumbs.js +51 -0
- package/dist/button/button.d.ts +37 -0
- package/dist/button/button.js +108 -0
- package/dist/caret/caret-group.d.ts +14 -0
- package/dist/caret/caret-group.js +71 -0
- package/dist/carousel/carousel.d.ts +28 -0
- package/dist/carousel/carousel.js +134 -0
- package/dist/component/component.d.ts +73 -0
- package/dist/component/component.js +190 -0
- package/dist/component/echarts.d.ts +46 -0
- package/dist/component/echarts.js +95 -0
- package/dist/composition/authoring.d.ts +157 -0
- package/dist/composition/authoring.js +291 -0
- package/dist/composition/builtins.d.ts +1130 -0
- package/dist/composition/builtins.js +318 -0
- package/dist/composition/command-bar.d.ts +18 -0
- package/dist/composition/command-bar.js +34 -0
- package/dist/composition/commands.d.ts +14 -0
- package/dist/composition/commands.js +59 -0
- package/dist/composition/composite.d.ts +48 -0
- package/dist/composition/composite.js +409 -0
- package/dist/composition/contracts.d.ts +63 -0
- package/dist/composition/contracts.js +97 -0
- package/dist/composition/definition.d.ts +94 -0
- package/dist/composition/definition.js +291 -0
- package/dist/composition/element.d.ts +35 -0
- package/dist/composition/element.js +51 -0
- package/dist/composition/registry.d.ts +83 -0
- package/dist/composition/registry.js +99 -0
- package/dist/composition/schema.d.ts +9 -0
- package/dist/composition/schema.js +139 -0
- package/dist/composition/sessions.d.ts +15 -0
- package/dist/composition/sessions.js +61 -0
- package/dist/dashboard/dashboard.d.ts +26 -0
- package/dist/dashboard/dashboard.js +296 -0
- package/dist/dashboard/layout.d.ts +19 -0
- package/dist/dashboard/layout.js +48 -0
- package/dist/dashboard/widget.d.ts +42 -0
- package/dist/dashboard/widget.js +165 -0
- package/dist/date/calendar.d.ts +34 -0
- package/dist/date/calendar.js +137 -0
- package/dist/date/date-field.d.ts +18 -0
- package/dist/date/date-field.js +81 -0
- package/dist/date/dates.d.ts +22 -0
- package/dist/date/dates.js +80 -0
- package/dist/dialog/dialog.d.ts +23 -0
- package/dist/dialog/dialog.js +70 -0
- package/dist/field/bound-field.d.ts +55 -0
- package/dist/field/bound-field.js +139 -0
- package/dist/field/check.d.ts +11 -0
- package/dist/field/check.js +26 -0
- package/dist/field/composite-field.d.ts +35 -0
- package/dist/field/composite-field.js +88 -0
- package/dist/field/field.d.ts +29 -0
- package/dist/field/field.js +75 -0
- package/dist/field/input.d.ts +15 -0
- package/dist/field/input.js +18 -0
- package/dist/field/number-field.d.ts +33 -0
- package/dist/field/number-field.js +179 -0
- package/dist/field/numeric.d.ts +4 -0
- package/dist/field/numeric.js +57 -0
- package/dist/field/values.d.ts +14 -0
- package/dist/field/values.js +60 -0
- package/dist/filter/filter-builder.d.ts +18 -0
- package/dist/filter/filter-builder.js +305 -0
- package/dist/form/controls.d.ts +20 -0
- package/dist/form/controls.js +34 -0
- package/dist/form/form.d.ts +46 -0
- package/dist/form/form.js +85 -0
- package/dist/format.d.ts +10 -0
- package/dist/format.js +57 -0
- package/dist/grid/filters.d.ts +16 -0
- package/dist/grid/filters.js +66 -0
- package/dist/grid/grid.d.ts +209 -0
- package/dist/grid/grid.js +2587 -0
- package/dist/grid/rows.d.ts +32 -0
- package/dist/grid/rows.js +89 -0
- package/dist/grid/view.d.ts +69 -0
- package/dist/grid/view.js +172 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +61 -0
- package/dist/layout/stack.d.ts +112 -0
- package/dist/layout/stack.js +21 -0
- package/dist/list-edit/list-edit.d.ts +42 -0
- package/dist/list-edit/list-edit.js +220 -0
- package/dist/listbox/listbox.d.ts +45 -0
- package/dist/listbox/listbox.js +187 -0
- package/dist/menu/menu.d.ts +55 -0
- package/dist/menu/menu.js +286 -0
- package/dist/motion.d.ts +8 -0
- package/dist/motion.js +23 -0
- package/dist/overlay/anchor.d.ts +18 -0
- package/dist/overlay/anchor.js +41 -0
- package/dist/picker/options.d.ts +59 -0
- package/dist/picker/options.js +97 -0
- package/dist/picker/picker.d.ts +46 -0
- package/dist/picker/picker.js +492 -0
- package/dist/picker/tree-choices.d.ts +38 -0
- package/dist/picker/tree-choices.js +72 -0
- package/dist/picker/tree-picker.d.ts +29 -0
- package/dist/picker/tree-picker.js +282 -0
- package/dist/progress/policy.d.ts +5 -0
- package/dist/progress/policy.js +28 -0
- package/dist/progress/status.d.ts +20 -0
- package/dist/progress/status.js +38 -0
- package/dist/rule/operators.d.ts +8 -0
- package/dist/rule/operators.js +9 -0
- package/dist/rule/rule-editor.d.ts +57 -0
- package/dist/rule/rule-editor.js +290 -0
- package/dist/section/accordion.d.ts +24 -0
- package/dist/section/accordion.js +90 -0
- package/dist/section/section.d.ts +30 -0
- package/dist/section/section.js +99 -0
- package/dist/select/choice-element.d.ts +20 -0
- package/dist/select/choice-element.js +55 -0
- package/dist/select/choice.d.ts +34 -0
- package/dist/select/choice.js +122 -0
- package/dist/select/many.d.ts +13 -0
- package/dist/select/many.js +20 -0
- package/dist/select/select.d.ts +16 -0
- package/dist/select/select.js +40 -0
- package/dist/sidebar/sidebar.d.ts +47 -0
- package/dist/sidebar/sidebar.js +105 -0
- package/dist/slicer/slicer.d.ts +26 -0
- package/dist/slicer/slicer.js +140 -0
- package/dist/split/split.d.ts +41 -0
- package/dist/split/split.js +237 -0
- package/dist/styles/contract.d.ts +36 -0
- package/dist/styles/contract.js +39 -0
- package/dist/switch/switch.d.ts +22 -0
- package/dist/switch/switch.js +69 -0
- package/dist/tabs/tabs.d.ts +30 -0
- package/dist/tabs/tabs.js +202 -0
- package/dist/tile-grid/scroll.d.ts +9 -0
- package/dist/tile-grid/scroll.js +21 -0
- package/dist/tile-grid/tile-grid.d.ts +64 -0
- package/dist/tile-grid/tile-grid.js +734 -0
- package/dist/toast/toast.d.ts +25 -0
- package/dist/toast/toast.js +92 -0
- package/dist/tree/navigator.d.ts +41 -0
- package/dist/tree/navigator.js +228 -0
- package/dist/window/chrome.d.ts +3 -0
- package/dist/window/chrome.js +7 -0
- package/dist/window/window.d.ts +46 -0
- package/dist/window/window.js +347 -0
- package/dist/window/workspace.d.ts +29 -0
- package/dist/window/workspace.js +137 -0
- package/package.json +45 -4
- package/styles/aster-ui.css +1147 -0
- package/styles/base.css +88 -0
- package/styles/contract.ts +41 -0
- package/styles/elements.css +1058 -0
- package/styles/harbour.css +126 -0
- package/styles/lamplight.css +118 -0
|
@@ -0,0 +1,2587 @@
|
|
|
1
|
+
import { computed, effect, signal, untracked } from "@preact/signals-core";
|
|
2
|
+
import { isRelative, progressPolicy, FilterDraft, describeCriteria, exportCsv, selectionPublication, EditModel, editPublication, keyText, Tree, } from "@getaster/data";
|
|
3
|
+
import { html, nothing } from "lit-html";
|
|
4
|
+
import { live } from "lit-html/directives/live.js";
|
|
5
|
+
import { styleMap } from "lit-html/directives/style-map.js";
|
|
6
|
+
import { AsterElement, define, each, scopeOf, uid } from "../base/element.js";
|
|
7
|
+
import { holds, pressDrag } from "../base/drag.js";
|
|
8
|
+
import { formatValue } from "../field/values.js";
|
|
9
|
+
import { controlFor } from "../form/controls.js";
|
|
10
|
+
import { announce } from "../toast/toast.js";
|
|
11
|
+
import { PickerOptions } from "../picker/picker.js";
|
|
12
|
+
import { CalendarState, calendarView, closeOutside } from "../date/calendar.js";
|
|
13
|
+
import { formatDate, today } from "../date/dates.js";
|
|
14
|
+
import { absoluteTime } from "../format.js";
|
|
15
|
+
import { anchorStyle, openWhen, placeStyle } from "../overlay/anchor.js";
|
|
16
|
+
import "../menu/menu.js";
|
|
17
|
+
import "../progress/status.js";
|
|
18
|
+
import "../select/choice-element.js";
|
|
19
|
+
import "../filter/filter-builder.js";
|
|
20
|
+
import "../dialog/dialog.js";
|
|
21
|
+
import { containsText, parseRange, rangeText, withDate } from "./filters.js";
|
|
22
|
+
import { RowHeights } from "./rows.js";
|
|
23
|
+
import { defaultView, groupFields, freezeThrough, isNumeric, loadViews, moveColumn, naturalWidth, nudgeColumn, reconcile, removeSort, setFilter, setGroup, setHidden, setWidth, sortBy, sortDirection, storeViews, track, viewQuery, } from "./view.js";
|
|
24
|
+
const OVERSCAN = 400;
|
|
25
|
+
/** A count that isn't exact: an estimate, or an uncounted collection's, which includes a page that may not exist. */
|
|
26
|
+
const atLeast = (c) => c.count.value === undefined || c.counted.value ? {}
|
|
27
|
+
: c.estimated.value ? { estimate: true }
|
|
28
|
+
: { atLeast: Math.max(0, c.count.value - c.pageSize) };
|
|
29
|
+
const FILTER_DELAY = 300;
|
|
30
|
+
/** A value the server hides from this person on this record: said, not left blank. */
|
|
31
|
+
const hiddenValue = html `<span class="aster-hidden" title="You can't see this value">Hidden</span>`;
|
|
32
|
+
/** An estimate to two significant figures: its last digits aren't known. */
|
|
33
|
+
function roughly(n) {
|
|
34
|
+
if (n < 100)
|
|
35
|
+
return n;
|
|
36
|
+
const step = 10 ** (Math.floor(Math.log10(n)) - 1);
|
|
37
|
+
return Math.round(n / step) * step;
|
|
38
|
+
}
|
|
39
|
+
/** Active-cell rows above the records: the header row, and the filter row when shown. */
|
|
40
|
+
const HEADER = -2;
|
|
41
|
+
const FILTER_ROW = -1;
|
|
42
|
+
/**
|
|
43
|
+
* A virtualized grid over an entity set, shaped by a view the person can
|
|
44
|
+
* change: sort, filter, hide, reorder, resize, freeze; their view is kept
|
|
45
|
+
* per browser with `persist`, and named views can be saved.
|
|
46
|
+
*
|
|
47
|
+
* Layout is CSS: the columns are one `grid-template-columns`, each row is a
|
|
48
|
+
* subgrid, the header and frozen columns are sticky. JS keeps only the rows
|
|
49
|
+
* near the viewport in the DOM (spacers stand for the rest), measures row
|
|
50
|
+
* heights with a ResizeObserver, and writes column tracks as a variable.
|
|
51
|
+
*
|
|
52
|
+
* The collection's query follows the view; rows load by page as they come
|
|
53
|
+
* into view, and ones not loaded yet show as placeholders. Focus is on the
|
|
54
|
+
* grid; its active cell is `aria-activedescendant`. Shared picks are separate
|
|
55
|
+
* from focus; an owned grid without shared selection chooses the active row.
|
|
56
|
+
*/
|
|
57
|
+
export class Grid extends AsterElement {
|
|
58
|
+
#filterDraft = signal(undefined);
|
|
59
|
+
#filterBusy = signal(false);
|
|
60
|
+
#filterNote = signal("");
|
|
61
|
+
#filterGeneration = 0;
|
|
62
|
+
#filterOpener = null;
|
|
63
|
+
#filterSnapshot = "";
|
|
64
|
+
#filterOwner;
|
|
65
|
+
#filterShow = false;
|
|
66
|
+
#criteriaCheck = computed(() => {
|
|
67
|
+
const criteria = this.#view.value?.criteria;
|
|
68
|
+
const ctx = this.#ctx.value;
|
|
69
|
+
if (!criteria || !ctx || !this.set)
|
|
70
|
+
return undefined;
|
|
71
|
+
try {
|
|
72
|
+
return new FilterDraft(ctx.schema, this.set, criteria).result.value;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return { valid: false, criteria: undefined, problems: [{ id: "", message: "This saved expression cannot be read. Clear it or restore a valid expression." }] };
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
#ctx = this.prop(undefined);
|
|
79
|
+
#set = this.prop("");
|
|
80
|
+
#binding = this.prop(undefined);
|
|
81
|
+
#selection = this.prop(undefined);
|
|
82
|
+
#selectable = this.prop(true);
|
|
83
|
+
#ownsCollection = false;
|
|
84
|
+
#bound;
|
|
85
|
+
#boundQuery;
|
|
86
|
+
#boundBase;
|
|
87
|
+
#selectionAnchor;
|
|
88
|
+
#selectionNote = signal("");
|
|
89
|
+
#selectionInside = signal(undefined);
|
|
90
|
+
#activeKey = signal(undefined);
|
|
91
|
+
#groupFocus = signal(undefined);
|
|
92
|
+
#focusRequest = 0;
|
|
93
|
+
#viewRequest = 0;
|
|
94
|
+
#pendingFocus = signal(undefined);
|
|
95
|
+
#focusBefore = null;
|
|
96
|
+
#groupToolFocus;
|
|
97
|
+
#base = this.prop(undefined);
|
|
98
|
+
#view = this.prop(undefined);
|
|
99
|
+
#label = this.prop("");
|
|
100
|
+
#publish = this.prop("");
|
|
101
|
+
#persist = this.prop("");
|
|
102
|
+
#renderers = this.prop({});
|
|
103
|
+
#formatters = this.prop({});
|
|
104
|
+
#lookups = this.prop({});
|
|
105
|
+
#summaries = this.prop([]);
|
|
106
|
+
#progressPolicy = this.prop({});
|
|
107
|
+
#summary;
|
|
108
|
+
#summaryConfig;
|
|
109
|
+
#summaryCollection;
|
|
110
|
+
#summaryContext;
|
|
111
|
+
#empty = this.prop(nothing);
|
|
112
|
+
#editable = this.prop(false);
|
|
113
|
+
#treeOptions = this.prop(undefined);
|
|
114
|
+
#rowControls = this.prop(undefined);
|
|
115
|
+
#expansion = this.prop(undefined);
|
|
116
|
+
/** Expanded records' keys, in the order they opened (not group headers). */
|
|
117
|
+
#open = signal([]);
|
|
118
|
+
#editors = new Map();
|
|
119
|
+
/** The expanded row being asked about unsaved changes. */
|
|
120
|
+
#asking = signal(undefined);
|
|
121
|
+
#tree;
|
|
122
|
+
/** The filter cell whose calendar is open. */
|
|
123
|
+
#filterCalendar = new CalendarState();
|
|
124
|
+
#filterCalendarField = this.prop(undefined);
|
|
125
|
+
#rowMenu = this.prop(() => []);
|
|
126
|
+
#rules = this.prop({});
|
|
127
|
+
#editing = signal(undefined);
|
|
128
|
+
#id = uid("aster-grid");
|
|
129
|
+
#heights = new RowHeights(37);
|
|
130
|
+
#version = signal(0);
|
|
131
|
+
#top = signal(0);
|
|
132
|
+
#viewport = signal(600);
|
|
133
|
+
#active = signal({ row: 0, col: 0 });
|
|
134
|
+
#panel = signal(undefined);
|
|
135
|
+
#showFilters = signal(false);
|
|
136
|
+
#filterText = signal({});
|
|
137
|
+
#filterErrors = signal({});
|
|
138
|
+
#saved = signal({ named: {} });
|
|
139
|
+
#offered;
|
|
140
|
+
#collection;
|
|
141
|
+
#selected = signal(undefined);
|
|
142
|
+
/** The row a touch or pen is dragging, drawn lifted. */
|
|
143
|
+
#lifted = signal(undefined);
|
|
144
|
+
#grouping;
|
|
145
|
+
#options = new Map();
|
|
146
|
+
#observer;
|
|
147
|
+
/** The scroll box the observer watches, and its width when last measured. */
|
|
148
|
+
#scrollBox;
|
|
149
|
+
#scrollWidth = 0;
|
|
150
|
+
/** The rows the observer watches. A removed row must go: it would report 0×0 out of the document. */
|
|
151
|
+
#observedRows = new Set();
|
|
152
|
+
#filterTimers = new Map();
|
|
153
|
+
#anchor;
|
|
154
|
+
#pendingSizes = [];
|
|
155
|
+
#sizeFrame = 0;
|
|
156
|
+
get ctx() {
|
|
157
|
+
return this.#ctx.value;
|
|
158
|
+
}
|
|
159
|
+
set ctx(v) {
|
|
160
|
+
this.#ctx.value = v;
|
|
161
|
+
}
|
|
162
|
+
/** The entity set the grid shows. */
|
|
163
|
+
get set() {
|
|
164
|
+
return this.#binding.value?.query.set ?? this.#selection.value?.collection.query.set ?? this.#set.value;
|
|
165
|
+
}
|
|
166
|
+
set set(v) {
|
|
167
|
+
this.#set.value = v;
|
|
168
|
+
}
|
|
169
|
+
/** Criteria every row meets, beneath the view's own filters. */
|
|
170
|
+
get base() {
|
|
171
|
+
return this.#base.value;
|
|
172
|
+
}
|
|
173
|
+
set base(v) {
|
|
174
|
+
this.#base.value = v;
|
|
175
|
+
}
|
|
176
|
+
/** The view in use. Set by the page, it's also what Reset goes back to. */
|
|
177
|
+
get view() {
|
|
178
|
+
return this.#view.value;
|
|
179
|
+
}
|
|
180
|
+
set view(v) {
|
|
181
|
+
if (groupFields(v).length && this.#external())
|
|
182
|
+
throw new Error("A shared collection grid supports flat rows; grouping uses an owned collection");
|
|
183
|
+
for (const column of v?.columns ?? [])
|
|
184
|
+
if (column.sortField !== undefined && (typeof column.sortField !== "string" || !column.sortField.trim()))
|
|
185
|
+
throw new TypeError("Column sortField must be a nonempty server field path");
|
|
186
|
+
this.#offered ??= v;
|
|
187
|
+
this.#viewRequest++;
|
|
188
|
+
this.#view.value = v;
|
|
189
|
+
}
|
|
190
|
+
get label() {
|
|
191
|
+
return this.#label.value;
|
|
192
|
+
}
|
|
193
|
+
set label(v) {
|
|
194
|
+
this.#label.value = v;
|
|
195
|
+
}
|
|
196
|
+
/** The page-scope key for the view and selection. */
|
|
197
|
+
get publish() {
|
|
198
|
+
return this.#publish.value;
|
|
199
|
+
}
|
|
200
|
+
set publish(v) {
|
|
201
|
+
this.#publish.value = v;
|
|
202
|
+
}
|
|
203
|
+
/** Keeps the person's views in this browser under this name. */
|
|
204
|
+
get persist() {
|
|
205
|
+
return this.#persist.value;
|
|
206
|
+
}
|
|
207
|
+
set persist(v) {
|
|
208
|
+
this.#persist.value = v;
|
|
209
|
+
}
|
|
210
|
+
/** Cell content by field, where the default formatting isn't enough. */
|
|
211
|
+
get renderers() {
|
|
212
|
+
return this.#renderers.value;
|
|
213
|
+
}
|
|
214
|
+
set renderers(v) {
|
|
215
|
+
this.#renderers.value = v;
|
|
216
|
+
}
|
|
217
|
+
/** Text-only display formatting by field; never changes server sorting or filtering. */
|
|
218
|
+
get formatters() { return this.#formatters.value; }
|
|
219
|
+
set formatters(value) { this.#formatters.value = value; }
|
|
220
|
+
/** Exact overall totals and, when grouped, totals on each group header. */
|
|
221
|
+
get summaries() { return this.#summaries.value; }
|
|
222
|
+
set summaries(value) {
|
|
223
|
+
if (value.length && this.#treeOptions.peek())
|
|
224
|
+
throw new Error("Tree summaries require an explicit query through the data summary API");
|
|
225
|
+
this.#summaries.value = value.map((item) => ({ ...item }));
|
|
226
|
+
}
|
|
227
|
+
/** Fresh server values; custom DOM renderers and lookup labels are not exported. */
|
|
228
|
+
async exportCsv(options = {}) {
|
|
229
|
+
const ctx = this.#ctx.peek();
|
|
230
|
+
const collection = this.#collection;
|
|
231
|
+
const view = this.#view.peek();
|
|
232
|
+
if (!ctx || !collection || !view)
|
|
233
|
+
throw new Error("Connect a grid before exporting");
|
|
234
|
+
if (this.#tree)
|
|
235
|
+
throw new Error("Tree export requires an explicit query through the data export API");
|
|
236
|
+
if (this.#editing.peek()?.edit.dirty.value)
|
|
237
|
+
throw new Error("Finish editing before exporting");
|
|
238
|
+
const { scope = "query", ...csv } = options;
|
|
239
|
+
if (scope !== "query" && scope !== "selection")
|
|
240
|
+
throw new Error("Unknown export scope");
|
|
241
|
+
const selection = this.#selection.peek();
|
|
242
|
+
if (scope === "selection" && !selection)
|
|
243
|
+
throw new Error("Selection export requires a shared Selection");
|
|
244
|
+
const columns = view.columns.filter((c) => !c.hidden).map((c) => ({ field: c.field }));
|
|
245
|
+
return exportCsv(ctx, collection.query, columns, { ...csv, ...(scope === "selection" ? { keys: selection.keys.peek() } : {}) });
|
|
246
|
+
}
|
|
247
|
+
/** Foreign keys shown by a related record's field, by column. */
|
|
248
|
+
get lookups() {
|
|
249
|
+
return this.#lookups.value;
|
|
250
|
+
}
|
|
251
|
+
set lookups(v) {
|
|
252
|
+
this.#lookups.value = v;
|
|
253
|
+
}
|
|
254
|
+
/** The page's actions for a row, shown first in its menu (right-click, Shift+F10, the Menu key). */
|
|
255
|
+
get rowMenu() {
|
|
256
|
+
return this.#rowMenu.value;
|
|
257
|
+
}
|
|
258
|
+
set rowMenu(v) {
|
|
259
|
+
this.#rowMenu.value = v;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* A tree grid: the first column shows each record's level with an opener;
|
|
263
|
+
* levels load as they open; a filter keeps the path to each match. Set it
|
|
264
|
+
* before the grid connects.
|
|
265
|
+
*/
|
|
266
|
+
get tree() {
|
|
267
|
+
return this.#treeOptions.value;
|
|
268
|
+
}
|
|
269
|
+
set tree(v) {
|
|
270
|
+
if (v && this.#external())
|
|
271
|
+
throw new Error("A shared collection grid supports flat rows");
|
|
272
|
+
if (v && this.#summaries.peek().length)
|
|
273
|
+
throw new Error("Tree summaries require an explicit query through the data summary API");
|
|
274
|
+
this.#treeOptions.value = v;
|
|
275
|
+
}
|
|
276
|
+
/** The lazy group hierarchy of an owned grouped grid. */
|
|
277
|
+
get groupingModel() { return this.#grouping; }
|
|
278
|
+
/** The tree model, in a tree grid: open state, reveal, move. */
|
|
279
|
+
get treeModel() {
|
|
280
|
+
return this.#tree;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Floating controls for each row, recomputed from its state. They show on
|
|
284
|
+
* hover, on the selected and the active row; Tab reaches them from the
|
|
285
|
+
* grid, ←/→ move between them, Escape goes back.
|
|
286
|
+
*/
|
|
287
|
+
get rowControls() {
|
|
288
|
+
return this.#rowControls.value;
|
|
289
|
+
}
|
|
290
|
+
set rowControls(v) {
|
|
291
|
+
this.#rowControls.value = v;
|
|
292
|
+
}
|
|
293
|
+
/** Rows expand in place into content the page supplies (double-click, or `expand`). */
|
|
294
|
+
get expansion() {
|
|
295
|
+
return this.#expansion.value;
|
|
296
|
+
}
|
|
297
|
+
set expansion(v) {
|
|
298
|
+
this.#expansion.value = v;
|
|
299
|
+
}
|
|
300
|
+
/** Keys of the expanded rows, in the order they opened. */
|
|
301
|
+
get expanded() {
|
|
302
|
+
return this.#open;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Expands a row and moves focus into its content. Refused (false) for a
|
|
306
|
+
* record that can't expand, or over the limit, which says so.
|
|
307
|
+
*/
|
|
308
|
+
expand(key) {
|
|
309
|
+
const expansion = this.#expansion.peek();
|
|
310
|
+
const entity = this.#entity(key);
|
|
311
|
+
if (!expansion || !entity || (expansion.canExpand && !expansion.canExpand(entity)))
|
|
312
|
+
return false;
|
|
313
|
+
const open = this.#open.peek();
|
|
314
|
+
if (open.includes(key))
|
|
315
|
+
return true;
|
|
316
|
+
if (expansion.single && open.length) {
|
|
317
|
+
const other = open[0];
|
|
318
|
+
if (this.#editors.get(other)?.dirty?.peek()) {
|
|
319
|
+
this.#asking.value = other;
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
this.#close(other, false);
|
|
323
|
+
}
|
|
324
|
+
else if (expansion.limit !== undefined && open.length >= expansion.limit) {
|
|
325
|
+
const noun = expansion.noun ?? "row";
|
|
326
|
+
announce(`Only ${expansion.limit} ${expansion.limit === 1 ? noun : `${noun}s`} can be open at once. Close one first.`);
|
|
327
|
+
this.#refused.value = `Only ${expansion.limit} ${expansion.limit === 1 ? noun : `${noun}s`} can be open at once. Close one first.`;
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
this.#refused.value = undefined;
|
|
331
|
+
this.#editors.set(key, expansion.content(this.#rowState(key, entity, true)));
|
|
332
|
+
this.#open.value = [...this.#open.peek(), key];
|
|
333
|
+
this.#focusExpansion(key);
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Focus goes to the content's first field, or else its first control, once
|
|
338
|
+
* it has rendered; unless focus has moved to something else meanwhile, as
|
|
339
|
+
* when the content focuses a control itself or the person has clicked. A
|
|
340
|
+
* control that opened the row and was then re-rendered leaves focus on the
|
|
341
|
+
* body, which isn't a move.
|
|
342
|
+
*/
|
|
343
|
+
#focusExpansion(key, frames = 10, from = document.activeElement) {
|
|
344
|
+
requestAnimationFrame(() => {
|
|
345
|
+
const now = document.activeElement;
|
|
346
|
+
if (now !== from && now !== null && now !== document.body)
|
|
347
|
+
return;
|
|
348
|
+
const content = this.querySelector(`.aster-grid-row[data-key="${CSS.escape(key)}"] .aster-grid-expansion`);
|
|
349
|
+
const target = content?.querySelector("input:not([type='hidden']), textarea, select, [role='combobox']") ??
|
|
350
|
+
(frames === 0 ? content?.querySelector("button, [tabindex='0']") : null);
|
|
351
|
+
if (target)
|
|
352
|
+
target.focus();
|
|
353
|
+
else if (frames > 0)
|
|
354
|
+
this.#focusExpansion(key, frames - 1, from);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Collapses a row. `ask` (the default) asks first when it has unsaved
|
|
359
|
+
* changes; `discard` drops them on purpose; `save` saves, and stays open
|
|
360
|
+
* if that fails.
|
|
361
|
+
*/
|
|
362
|
+
async collapse(key, how = "ask") {
|
|
363
|
+
const editor = this.#editors.get(key);
|
|
364
|
+
if (!editor)
|
|
365
|
+
return true;
|
|
366
|
+
if (how === "save") {
|
|
367
|
+
if (editor.save && !(await editor.save()))
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
else if (how === "discard") {
|
|
371
|
+
editor.discard?.();
|
|
372
|
+
}
|
|
373
|
+
else if (editor.dirty?.peek()) {
|
|
374
|
+
this.#asking.value = key;
|
|
375
|
+
requestAnimationFrame(() => this.querySelector(".aster-grid-asking button")?.focus());
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
this.#close(key, true);
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
#refused = signal(undefined);
|
|
382
|
+
#close(key, refocus) {
|
|
383
|
+
if (this.#asking.peek() === key)
|
|
384
|
+
this.#asking.value = undefined;
|
|
385
|
+
this.#editors.get(key)?.dispose?.();
|
|
386
|
+
this.#editors.delete(key);
|
|
387
|
+
this.#open.value = this.#open.peek().filter((k) => k !== key);
|
|
388
|
+
if (refocus) {
|
|
389
|
+
requestAnimationFrame(() => {
|
|
390
|
+
const row = this.querySelector(`.aster-grid-row[data-key="${CSS.escape(key)}"]`);
|
|
391
|
+
(row?.querySelector(".aster-grid-controls button") ?? this.querySelector(".aster-grid-scroll"))?.focus();
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
#entity(key) {
|
|
396
|
+
const set = this.#collection?.query.set;
|
|
397
|
+
return set ? this.#ctx.peek()?.store.get(set, key)?.entity : undefined;
|
|
398
|
+
}
|
|
399
|
+
#rowState(key, entity, expanded) {
|
|
400
|
+
return { key, entity, expanded, editable: this.#editable.value };
|
|
401
|
+
}
|
|
402
|
+
/** The row's floating controls, in a cell of their own past the last column. */
|
|
403
|
+
#controls(key, entity, expanded, activeRow, columns) {
|
|
404
|
+
const make = this.#rowControls.value;
|
|
405
|
+
if (!make)
|
|
406
|
+
return nothing;
|
|
407
|
+
const controls = make(this.#rowState(key, entity, expanded));
|
|
408
|
+
if (!controls.length)
|
|
409
|
+
return nothing;
|
|
410
|
+
return html `<div class="aster-grid-controls" role="gridcell" aria-colindex=${columns + 1} @keydown=${(e) => this.#controlsKey(e)}
|
|
411
|
+
@dblclick=${(e) => e.stopPropagation()}>
|
|
412
|
+
${controls.map((c) => html `<button type="button" aria-label=${c.label} title=${c.label} tabindex=${activeRow ? "0" : "-1"}
|
|
413
|
+
@click=${(e) => {
|
|
414
|
+
e.stopPropagation();
|
|
415
|
+
c.run();
|
|
416
|
+
}}>${c.icon}</button>`)}
|
|
417
|
+
</div>`;
|
|
418
|
+
}
|
|
419
|
+
#controlsKey(e) {
|
|
420
|
+
const buttons = [...e.currentTarget.querySelectorAll("button")];
|
|
421
|
+
const at = buttons.indexOf(document.activeElement);
|
|
422
|
+
switch (e.key) {
|
|
423
|
+
case "ArrowLeft":
|
|
424
|
+
case "ArrowRight":
|
|
425
|
+
e.preventDefault();
|
|
426
|
+
e.stopPropagation();
|
|
427
|
+
buttons[(at + (e.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length]?.focus();
|
|
428
|
+
return;
|
|
429
|
+
case "Escape":
|
|
430
|
+
e.preventDefault();
|
|
431
|
+
e.stopPropagation();
|
|
432
|
+
this.querySelector(".aster-grid-scroll")?.focus();
|
|
433
|
+
return;
|
|
434
|
+
default:
|
|
435
|
+
// Keys on a control are its own, not the grid's.
|
|
436
|
+
if (e.key !== "Tab")
|
|
437
|
+
e.stopPropagation();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/** The expanded row's content, with the question about unsaved changes when there's one. */
|
|
441
|
+
#expansionCell(key, columns) {
|
|
442
|
+
const editor = this.#editors.get(key);
|
|
443
|
+
if (!editor)
|
|
444
|
+
return nothing;
|
|
445
|
+
const asking = this.#asking.value === key;
|
|
446
|
+
const noun = this.#expansion.value?.noun ?? "row";
|
|
447
|
+
return html `<div class="aster-grid-expansion" role="gridcell" aria-colindex="1" aria-colspan=${columns}
|
|
448
|
+
@keydown=${(e) => this.#expansionKey(e, key)} @dblclick=${(e) => e.stopPropagation()}
|
|
449
|
+
@contextmenu=${(e) => e.stopPropagation()}>
|
|
450
|
+
${asking
|
|
451
|
+
? html `<div class="aster-grid-asking" role="alertdialog" aria-label="Unsaved changes">
|
|
452
|
+
<p>This ${noun} has unsaved changes.</p>
|
|
453
|
+
<button type="button" class="aster-button" data-variant="primary" @click=${() => void this.collapse(key, "save")}>Save</button>
|
|
454
|
+
<button type="button" class="aster-button" @click=${() => void this.collapse(key, "discard")}>Discard</button>
|
|
455
|
+
<button type="button" class="aster-button" data-variant="quiet" @click=${() => (this.#asking.value = undefined)}>Keep editing</button>
|
|
456
|
+
</div>`
|
|
457
|
+
: nothing}
|
|
458
|
+
${editor.view}
|
|
459
|
+
</div>`;
|
|
460
|
+
}
|
|
461
|
+
/** Keys inside expanded content belong to it; Escape asks to close. */
|
|
462
|
+
#expansionKey(e, key) {
|
|
463
|
+
e.stopPropagation();
|
|
464
|
+
if (e.key !== "Escape" || e.defaultPrevented)
|
|
465
|
+
return;
|
|
466
|
+
e.preventDefault();
|
|
467
|
+
if (this.#asking.peek() === key)
|
|
468
|
+
this.#asking.value = undefined;
|
|
469
|
+
else
|
|
470
|
+
void this.collapse(key);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* In a tree grid, opens the path to a record, makes its row active and
|
|
474
|
+
* scrolls it to the top. Resolves to whether the record is in the tree.
|
|
475
|
+
*/
|
|
476
|
+
async reveal(key) {
|
|
477
|
+
const tree = this.#tree;
|
|
478
|
+
if (!tree)
|
|
479
|
+
return false;
|
|
480
|
+
const index = await tree.reveal(key);
|
|
481
|
+
if (index < 0)
|
|
482
|
+
return false;
|
|
483
|
+
this.#activate(index, 0);
|
|
484
|
+
// A revealed row goes to the top of the view, not just inside it.
|
|
485
|
+
const scroll = this.querySelector(".aster-grid-scroll");
|
|
486
|
+
if (scroll)
|
|
487
|
+
scroll.scrollTop = this.#heights.offset(index);
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
/** Rows can be edited in place: Enter, F2 or a double-click start, Enter saves, Escape cancels. */
|
|
491
|
+
get editable() {
|
|
492
|
+
return this.#editable.value;
|
|
493
|
+
}
|
|
494
|
+
set editable(v) {
|
|
495
|
+
this.#editable.value = v;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Page rules for the cells of a row being edited. Each row has a child
|
|
499
|
+
* scope where it's published as `row`, so a rule reads its own record.
|
|
500
|
+
*/
|
|
501
|
+
get rules() {
|
|
502
|
+
return this.#rules.value;
|
|
503
|
+
}
|
|
504
|
+
set rules(v) {
|
|
505
|
+
this.#rules.value = v;
|
|
506
|
+
}
|
|
507
|
+
get progressPolicy() { return this.#progressPolicy.value; }
|
|
508
|
+
set progressPolicy(value) { progressPolicy(value); this.#progressPolicy.value = Object.freeze({ ...value }); }
|
|
509
|
+
/** Shown when the query matches nothing. */
|
|
510
|
+
get empty() {
|
|
511
|
+
return this.#empty.value;
|
|
512
|
+
}
|
|
513
|
+
set empty(v) {
|
|
514
|
+
this.#empty.value = v;
|
|
515
|
+
}
|
|
516
|
+
get collection() {
|
|
517
|
+
return this.#external() ?? this.#collection;
|
|
518
|
+
}
|
|
519
|
+
set collection(v) {
|
|
520
|
+
if (v && (this.#treeOptions.peek() || groupFields(this.#view.peek()).length))
|
|
521
|
+
throw new Error("A shared collection grid supports flat rows");
|
|
522
|
+
if (v && this.#selection.peek() && this.#selection.peek().collection !== v)
|
|
523
|
+
throw new Error("collection and selection must refer to the same collection");
|
|
524
|
+
this.#binding.value = v;
|
|
525
|
+
}
|
|
526
|
+
/** Shared selection; without collection, its collection supplies the binding. */
|
|
527
|
+
get selection() { return this.#selection.value; }
|
|
528
|
+
/** Allows this view to change selection; borrowed selection state remains visible. */
|
|
529
|
+
get selectable() { return this.#selectable.value; }
|
|
530
|
+
set selectable(value) {
|
|
531
|
+
const restore = !value && document.activeElement instanceof Element && this.contains(document.activeElement) && !!document.activeElement.closest(".aster-grid-selection");
|
|
532
|
+
this.#selectable.value = value;
|
|
533
|
+
if (restore) {
|
|
534
|
+
this.#selectionInside.value = undefined;
|
|
535
|
+
queueMicrotask(() => this.focus({ preventScroll: true }));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
set selection(v) {
|
|
539
|
+
if (v && (this.#treeOptions.peek() || groupFields(this.#view.peek()).length))
|
|
540
|
+
throw new Error("A shared selection grid supports flat rows");
|
|
541
|
+
if (v && this.#binding.peek() && v.collection !== this.#binding.peek())
|
|
542
|
+
throw new Error("collection and selection must refer to the same collection");
|
|
543
|
+
this.#selection.value = v;
|
|
544
|
+
this.#selectionNote.value = "";
|
|
545
|
+
this.#selectionAnchor = undefined;
|
|
546
|
+
}
|
|
547
|
+
/** Navigation key, independent of a shared selection. */
|
|
548
|
+
get activeKey() { return this.#activeKey.value; }
|
|
549
|
+
/** Saves an inline edit before an owner changes or removes this view. False keeps invalid/failed edits open. */
|
|
550
|
+
async finishEditing() { return this.#endEdit(true); }
|
|
551
|
+
/** The selected record's key. */
|
|
552
|
+
get selected() {
|
|
553
|
+
return this.#selection.value?.key.value ?? (this.#selection.value ? undefined : this.#selected.value);
|
|
554
|
+
}
|
|
555
|
+
/** Loads and focuses a flat collection position without changing shared selection. */
|
|
556
|
+
async focusIndex(index, column = 0) {
|
|
557
|
+
const collection = this.#collection;
|
|
558
|
+
if (!this.isConnected || !collection || this.#tree || groupFields(this.#view.peek()).length || !Number.isSafeInteger(index) || index < 0 || !Number.isSafeInteger(column))
|
|
559
|
+
return false;
|
|
560
|
+
const request = ++this.#focusRequest;
|
|
561
|
+
const query = collection.query;
|
|
562
|
+
this.#pendingFocus.value = index;
|
|
563
|
+
try {
|
|
564
|
+
await collection.view(index, index + 1, [index, this.#active.peek().row]);
|
|
565
|
+
if (!this.isConnected || request !== this.#focusRequest || this.#collection !== collection || collection.query !== query || collection.keys.peek()[index] === undefined)
|
|
566
|
+
return false;
|
|
567
|
+
const columns = this.#view.peek().columns.filter((c) => !c.hidden).length;
|
|
568
|
+
this.#activate(index, Math.max(0, Math.min(columns - 1, column)));
|
|
569
|
+
const scroll = this.querySelector(".aster-grid-scroll");
|
|
570
|
+
if (scroll)
|
|
571
|
+
this.#top.value = scroll.scrollTop;
|
|
572
|
+
queueMicrotask(() => this.focus({ preventScroll: true }));
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
this.#selectionNote.value = "Could not load that row. Move to another row or retry.";
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
finally {
|
|
580
|
+
if (request === this.#focusRequest)
|
|
581
|
+
this.#pendingFocus.value = undefined;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/** Reveals a key currently loaded in the flat collection. */
|
|
585
|
+
async focusKey(key, column = 0) {
|
|
586
|
+
const index = this.#collection?.keys.peek().indexOf(key) ?? -1;
|
|
587
|
+
return index < 0 ? false : this.focusIndex(index, column);
|
|
588
|
+
}
|
|
589
|
+
connectedCallback() {
|
|
590
|
+
super.connectedCallback();
|
|
591
|
+
// One observer for the scroll box and the rows. A new width can change
|
|
592
|
+
// any row's height, so the shown rows are measured in the callback and the
|
|
593
|
+
// re-render lands before paint: rows it adds are deeper than the scroll
|
|
594
|
+
// box, so the observer still reports them this frame. A row that resized
|
|
595
|
+
// alone applies next frame, as re-rendering for it here would add rows at
|
|
596
|
+
// its own depth, which the observer defers with a loop error.
|
|
597
|
+
this.#observer = new ResizeObserver((entries) => {
|
|
598
|
+
const rows = [];
|
|
599
|
+
let remeasured = false;
|
|
600
|
+
for (const entry of entries) {
|
|
601
|
+
if (entry.target !== this.#scrollBox)
|
|
602
|
+
rows.push(entry);
|
|
603
|
+
else
|
|
604
|
+
remeasured = this.#boxResized(entry.target) || remeasured;
|
|
605
|
+
}
|
|
606
|
+
if (remeasured || rows.length === 0)
|
|
607
|
+
return;
|
|
608
|
+
this.#pendingSizes.push(...rows);
|
|
609
|
+
this.#sizeFrame ||= requestAnimationFrame(() => {
|
|
610
|
+
this.#sizeFrame = 0;
|
|
611
|
+
const sizes = this.#pendingSizes.splice(0).map((e) => {
|
|
612
|
+
const row = e.target;
|
|
613
|
+
return [row, e.borderBoxSize[0]?.blockSize ?? row.offsetHeight];
|
|
614
|
+
});
|
|
615
|
+
this.#measured(sizes);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
this.onDisconnect(() => cancelAnimationFrame(this.#sizeFrame));
|
|
619
|
+
this.onDisconnect(() => {
|
|
620
|
+
this.#filterGeneration++;
|
|
621
|
+
this.querySelector(".aster-grid-advanced")?.close();
|
|
622
|
+
this.#filterDraft.value = undefined;
|
|
623
|
+
this.#observer?.disconnect();
|
|
624
|
+
this.#scrollBox = undefined;
|
|
625
|
+
this.#observedRows.clear();
|
|
626
|
+
});
|
|
627
|
+
this.onDisconnect(effect(() => this.#restore()));
|
|
628
|
+
this.onDisconnect(effect(() => this.#follow()));
|
|
629
|
+
this.onDisconnect(effect(() => this.#syncBound()));
|
|
630
|
+
this.onDisconnect(effect(() => this.#recoverActive()));
|
|
631
|
+
this.onDisconnect(effect(() => this.#group()));
|
|
632
|
+
this.onDisconnect(effect(() => this.#summarize()));
|
|
633
|
+
this.onDisconnect(() => {
|
|
634
|
+
this.#focusRequest++;
|
|
635
|
+
this.#nextLoad = undefined;
|
|
636
|
+
if (this.#ownsCollection && this.#collection) {
|
|
637
|
+
this.#ctx.peek()?.release(this.#collection);
|
|
638
|
+
this.#collection = undefined;
|
|
639
|
+
this.#ownsCollection = false;
|
|
640
|
+
}
|
|
641
|
+
this.#editing.peek()?.stop();
|
|
642
|
+
this.#editing.value = undefined;
|
|
643
|
+
this.#summary?.dispose();
|
|
644
|
+
this.#summary = undefined;
|
|
645
|
+
this.#summaryConfig = undefined;
|
|
646
|
+
this.#grouping?.dispose();
|
|
647
|
+
this.#grouping = undefined;
|
|
648
|
+
});
|
|
649
|
+
this.onDisconnect(effect(() => this.#load()));
|
|
650
|
+
this.onDisconnect(effect(() => this.#toTop()));
|
|
651
|
+
this.onDisconnect(effect(() => this.#keep()));
|
|
652
|
+
this.onDisconnect(effect(() => this.#announce()));
|
|
653
|
+
this.listen(document, "pointerdown", (e) => {
|
|
654
|
+
if (this.#panel.peek() && !e.target.closest(".aster-grid-panel, .aster-grid-tool"))
|
|
655
|
+
this.#panel.value = undefined;
|
|
656
|
+
});
|
|
657
|
+
// Where a click doesn't focus the button that opened a panel (WebKit), Escape arrives on the body.
|
|
658
|
+
this.listen(document, "keydown", (e) => {
|
|
659
|
+
if (e.target === document.body)
|
|
660
|
+
this.#panelKey(e);
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
focus(options) {
|
|
664
|
+
this.querySelector(".aster-grid-scroll")?.focus(options);
|
|
665
|
+
}
|
|
666
|
+
render() {
|
|
667
|
+
this.#focusBefore = this.#external() && this.contains(document.activeElement) ? document.activeElement : null;
|
|
668
|
+
this.#version.value;
|
|
669
|
+
const ctx = this.#ctx.value;
|
|
670
|
+
const collection = this.#collection;
|
|
671
|
+
const view = this.#view.value;
|
|
672
|
+
if (!ctx || !collection || !view)
|
|
673
|
+
return nothing;
|
|
674
|
+
const type = collection.type;
|
|
675
|
+
const columns = view.columns.filter((c) => !c.hidden);
|
|
676
|
+
const lookups = this.#lookups.value;
|
|
677
|
+
const fields = columns.map((c) => type.field(c.field));
|
|
678
|
+
const numeric = columns.map((c, i) => isNumeric(fields[i]) && !lookups[c.field]);
|
|
679
|
+
const titles = columns.map((c, i) => fields[i]?.title ?? c.field);
|
|
680
|
+
const layout = this.#layout();
|
|
681
|
+
const count = layout.keys.length === 0 && layout.total === undefined ? undefined : layout.keys.length;
|
|
682
|
+
this.#heights.setKeys(layout.keys);
|
|
683
|
+
const { start, end } = this.#heights.window(this.#top.value, this.#viewport.value, OVERSCAN);
|
|
684
|
+
const top = this.#heights.offset(start);
|
|
685
|
+
const bottom = this.#heights.total() - this.#heights.offset(end);
|
|
686
|
+
const active = this.#active.value;
|
|
687
|
+
const selection = this.#selection.value;
|
|
688
|
+
const selectedKeys = selection?.keys.value ?? [];
|
|
689
|
+
const selected = this.#selected.value;
|
|
690
|
+
const frozenLeft = this.#frozenOffsets(columns, view.frozen);
|
|
691
|
+
const style = styleMap({
|
|
692
|
+
"--grid-columns": columns.map((c, i) => track(c, lookups[c.field] ? undefined : fields[i])).join(" "),
|
|
693
|
+
...Object.fromEntries(frozenLeft.map((left, i) => [`--frozen-${i}`, `${left}px`])),
|
|
694
|
+
});
|
|
695
|
+
const rows = [];
|
|
696
|
+
for (let i = start; i < end; i++)
|
|
697
|
+
rows.push(i);
|
|
698
|
+
const cellId = (row, col) => `${this.#id}-${row}-${col}`;
|
|
699
|
+
const filters = this.#showFilters.value || Object.keys(view.filters).length > 0;
|
|
700
|
+
const activeId = active.row === HEADER ? `${this.#id}-h-${active.col}`
|
|
701
|
+
: active.row === FILTER_ROW ? `${this.#id}-f-${active.col}`
|
|
702
|
+
: !count ? undefined
|
|
703
|
+
: cellId(active.row, active.row < count && layout.at(active.row).kind === "group" ? 0 : active.col);
|
|
704
|
+
const frozenStyle = (i) => (i < view.frozen ? `left:var(--frozen-${i})` : nothing);
|
|
705
|
+
return html `${this.#toolbar(view, layout)}
|
|
706
|
+
<div class="aster-grid-scroll" role=${groupFields(view).length || this.#tree ? "treegrid" : "grid"} tabindex="0" aria-label=${this.#label.value}
|
|
707
|
+
?data-fixed-columns=${columns.length > 0 && columns.every((c) => c.width !== undefined)}
|
|
708
|
+
aria-rowcount=${layout.atLeast !== undefined || layout.estimate ? -1 : (count ?? 0) + (filters ? 2 : 1)} aria-colcount=${columns.length + (this.#rowControls.value ? 1 : 0)}
|
|
709
|
+
aria-describedby=${layout.atLeast !== undefined || layout.estimate ? `${this.#id}-total` : nothing}
|
|
710
|
+
aria-multiselectable=${this.#selectable.value && selection?.multiple ? "true" : nothing}
|
|
711
|
+
aria-activedescendant=${activeId && (active.row < 0 || (active.row >= start && active.row < end)) ? activeId : nothing} aria-busy=${this.#grouping ? this.#grouping.root.status.value === "loading" ? "true" : "false" : collection.status.value === "loading" || count === undefined && collection.status.value !== "error" ? "true" : "false"}
|
|
712
|
+
?data-draggable=${!!this.#treeOptions.value?.draggable && this.#editable.value}
|
|
713
|
+
@scroll=${(e) => this.#scrolled(e.currentTarget)}
|
|
714
|
+
@keydown=${(e) => this.#key(e, columns.length)}
|
|
715
|
+
@click=${(e) => this.#click(e)}
|
|
716
|
+
@focusout=${() => queueMicrotask(() => { if (!this.contains(document.activeElement))
|
|
717
|
+
this.#focusRequest++; })}
|
|
718
|
+
@dblclick=${(e) => this.#dblclick(e)}
|
|
719
|
+
@contextmenu=${(e) => this.#contextMenu(e)}
|
|
720
|
+
@pointerdown=${(e) => this.#dragRow(e)}>
|
|
721
|
+
<div class="aster-grid-table" style=${style}>
|
|
722
|
+
<div class="aster-grid-headgroup">
|
|
723
|
+
${this.#treeOptions.value?.draggable && this.#editable.value ? html `<div class="aster-grid-root-drop">Top level</div>` : nothing}
|
|
724
|
+
<div class="aster-grid-row aster-grid-head" role="row" aria-rowindex="1">
|
|
725
|
+
${columns.map((c, i) => this.#header(c, fields[i], i, view, numeric[i], frozenStyle(i), active.row === HEADER && active.col === i))}
|
|
726
|
+
</div>
|
|
727
|
+
${filters
|
|
728
|
+
? html `<div class="aster-grid-row aster-grid-filters" role="row" aria-rowindex="2" @keydown=${(e) => this.#filterKey(e)}>
|
|
729
|
+
${columns.map((c, i) => html `<div class="aster-grid-cell aster-grid-filter" role="gridcell" aria-colindex=${i + 1}
|
|
730
|
+
id=${`${this.#id}-f-${i}`} ?data-active=${active.row === FILTER_ROW && active.col === i}
|
|
731
|
+
?data-frozen=${i < view.frozen} style=${frozenStyle(i)}>${this.#filter(c, fields[i], view)}</div>`)}
|
|
732
|
+
</div>`
|
|
733
|
+
: nothing}
|
|
734
|
+
</div>
|
|
735
|
+
<div class="aster-grid-spacer" style=${`height:${top}px`}></div>
|
|
736
|
+
${each(rows.map((i) => ({ i, item: layout.at(i) })), ({ i, item }) => (item.kind === "group" ? item.id : (item.key ?? `?${i}`)), ({ i, item }) => {
|
|
737
|
+
if (item.kind === "group")
|
|
738
|
+
return this.#groupRow(item, i, i + (filters ? 3 : 2), columns.length, view, active.row === i);
|
|
739
|
+
const key = item.key;
|
|
740
|
+
const stored = key === undefined ? undefined : ctx.store.record(collection.query.set, key).value;
|
|
741
|
+
const entity = stored?.entity;
|
|
742
|
+
const editing = this.#editing.value;
|
|
743
|
+
const edit = editing && key !== undefined && editing.key === key ? editing.edit : undefined;
|
|
744
|
+
const conflict = edit?.conflict.value;
|
|
745
|
+
const formError = edit?.formError.value;
|
|
746
|
+
// A read-only reason shows once for the row, not under every cell it covers.
|
|
747
|
+
const reasons = edit
|
|
748
|
+
? [...new Set(columns.flatMap((c) => {
|
|
749
|
+
const st = fields.some((f) => f?.name === c.field) ? edit.state(c.field).value : undefined;
|
|
750
|
+
return st?.readOnly && st.reasons.readOnly && !fields.find((f) => f?.name === c.field)?.computed ? [st.reasons.readOnly] : [];
|
|
751
|
+
}))]
|
|
752
|
+
: [];
|
|
753
|
+
const shared = new Map(reasons.map((r, n) => [r, `${this.#id}-reason-${n}`]));
|
|
754
|
+
return html `<div class="aster-grid-row" role="row" aria-rowindex=${i + (filters ? 3 : 2)} data-index=${i}
|
|
755
|
+
aria-level=${item.node?.level ?? item.level ?? nothing}
|
|
756
|
+
aria-posinset=${item.node?.position ?? item.position ?? nothing} aria-setsize=${item.node?.siblings ?? item.siblings ?? nothing}
|
|
757
|
+
style=${item.node || item.level ? `--level:${(item.node?.level ?? item.level ?? 1) - 1}` : nothing}
|
|
758
|
+
aria-expanded=${item.node && item.node.folder !== false && item.key !== undefined ? (item.node.open ? "true" : "false") : nothing}
|
|
759
|
+
data-key=${key ?? nothing} aria-selected=${this.#external() && !selection ? nothing : key !== undefined && (selection ? selectedKeys.includes(key) : key === selected) ? "true" : "false"}
|
|
760
|
+
?data-grouped-row=${item.level !== undefined}
|
|
761
|
+
?data-active-row=${active.row === i} ?data-expanded=${key !== undefined && this.#open.value.includes(key)}
|
|
762
|
+
?data-placeholder=${!entity} ?data-editing=${edit !== undefined} ?data-lifted=${key !== undefined && key === this.#lifted.value}
|
|
763
|
+
@keydown=${edit ? (e) => this.#editKey(e) : nothing}
|
|
764
|
+
@mousedown=${edit ? (e) => this.#focusPressed(e) : nothing}
|
|
765
|
+
@focusout=${edit ? (e) => this.#editBlur(e) : nothing}>
|
|
766
|
+
${columns.map((c, col) => {
|
|
767
|
+
const content = !entity ? html `<span class="aster-grid-skeleton"></span>`
|
|
768
|
+
: edit && !fields[col]?.computed
|
|
769
|
+
? controlFor(edit, c.field, { options: lookups[c.field] ? this.#optionsFor(c.field, lookups[c.field]) : undefined, labelled: false, sharedReasons: shared })
|
|
770
|
+
: stored?.hidden?.has(c.field) ? hiddenValue : this.#cell(c, fields[col], entity, key, ctx);
|
|
771
|
+
return html `<div class="aster-grid-cell" role="gridcell" id=${cellId(i, col)}
|
|
772
|
+
aria-colindex=${col + 1} ?data-active=${active.row === i && active.col === col}
|
|
773
|
+
?data-numeric=${numeric[col]} ?data-frozen=${col < view.frozen} style=${`grid-column:${col + 1};${col < view.frozen ? `left:var(--frozen-${col})` : ""}`} data-label=${titles[col]}>
|
|
774
|
+
${col === 0 && item.node ? this.#treeLead(item.node) : nothing}
|
|
775
|
+
${col === 0 && key !== undefined && selection && this.#selectable.value ? html `<div class="aster-grid-selection-value">
|
|
776
|
+
${this.#selectionControl(key, entity, selectedKeys, stored?.hidden)}<div class="aster-grid-selection-content">${content}</div>
|
|
777
|
+
</div>` : content}
|
|
778
|
+
</div>`;
|
|
779
|
+
})}
|
|
780
|
+
${key !== undefined && entity ? this.#controls(key, entity, this.#open.value.includes(key), active.row === i, columns.length) : nothing}
|
|
781
|
+
${key !== undefined && this.#open.value.includes(key) ? this.#expansionCell(key, columns.length) : nothing}
|
|
782
|
+
${conflict
|
|
783
|
+
? html `<p class="aster-grid-row-note" role="alert">Someone else changed ${conflict.fields.map((f) => type.field(f)?.title ?? f).join(", ")} while you were editing.
|
|
784
|
+
<button type="button" @pointerdown=${keepFocus} @click=${() => edit.rebase()}>Keep mine</button>
|
|
785
|
+
<button type="button" @pointerdown=${keepFocus} @click=${() => (edit.discard(), this.#endEdit(true))}>Take theirs</button></p>`
|
|
786
|
+
: formError
|
|
787
|
+
? html `<p class="aster-grid-row-note aster-error" role="alert">${formError}</p>`
|
|
788
|
+
: nothing}
|
|
789
|
+
${[...shared].map(([reason, id]) => html `<p class="aster-grid-row-note aster-hint" id=${id}>${reason}</p>`)}
|
|
790
|
+
</div>`;
|
|
791
|
+
})}
|
|
792
|
+
<div class="aster-grid-spacer" style=${`height:${bottom}px`}></div>
|
|
793
|
+
</div>
|
|
794
|
+
${count === 0 ? html `<div class="aster-grid-empty">${this.#empty.value}</div>` : nothing}
|
|
795
|
+
</div>
|
|
796
|
+
${this.#summaryView()}
|
|
797
|
+
<aster-menu class="aster-grid-menu"></aster-menu>
|
|
798
|
+
<aster-dialog class="aster-grid-advanced" .labelledBy=${`${this.#id}-advanced-title`}
|
|
799
|
+
.content=${this.#advancedContent()} @close=${() => { this.#filterGeneration++; this.#filterDraft.value = undefined; }}></aster-dialog>`;
|
|
800
|
+
}
|
|
801
|
+
updated() {
|
|
802
|
+
if (this.#filterShow) {
|
|
803
|
+
this.#filterShow = false;
|
|
804
|
+
const generation = this.#filterGeneration;
|
|
805
|
+
queueMicrotask(() => {
|
|
806
|
+
if (this.isConnected && this.#filterDraft.peek() && generation === this.#filterGeneration)
|
|
807
|
+
this.querySelector(".aster-grid-advanced")?.show(this.#filterOpener);
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
const focusBefore = this.#focusBefore;
|
|
811
|
+
const scroll = this.querySelector(".aster-grid-scroll");
|
|
812
|
+
if (!scroll || !this.#observer)
|
|
813
|
+
return;
|
|
814
|
+
if (this.#scrollBox !== scroll) {
|
|
815
|
+
if (this.#scrollBox)
|
|
816
|
+
this.#observer.unobserve(this.#scrollBox);
|
|
817
|
+
this.#scrollBox = scroll;
|
|
818
|
+
this.#scrollWidth = scroll.clientWidth;
|
|
819
|
+
this.#observer.observe(scroll);
|
|
820
|
+
this.#viewport.value = scroll.clientHeight;
|
|
821
|
+
}
|
|
822
|
+
const rows = [...this.querySelectorAll(".aster-grid-row[data-key]")];
|
|
823
|
+
const shown = new Set(rows);
|
|
824
|
+
for (const row of this.#observedRows) {
|
|
825
|
+
if (shown.has(row))
|
|
826
|
+
continue;
|
|
827
|
+
this.#observer.unobserve(row);
|
|
828
|
+
this.#observedRows.delete(row);
|
|
829
|
+
}
|
|
830
|
+
for (const row of rows) {
|
|
831
|
+
if (this.#observedRows.has(row))
|
|
832
|
+
continue;
|
|
833
|
+
this.#observer.observe(row);
|
|
834
|
+
this.#observedRows.add(row);
|
|
835
|
+
}
|
|
836
|
+
// Heights replaced estimates above the viewport: keep the same row at the same place.
|
|
837
|
+
const anchor = this.#anchor;
|
|
838
|
+
if (anchor) {
|
|
839
|
+
this.#anchor = undefined;
|
|
840
|
+
const want = this.#heights.offset(anchor.index) + anchor.delta;
|
|
841
|
+
if (Math.abs(scroll.scrollTop - want) >= 1)
|
|
842
|
+
scroll.scrollTop = want;
|
|
843
|
+
}
|
|
844
|
+
// Rows new to the grid are measured now rather than when the observer
|
|
845
|
+
// reports them, so the window and spacers settle in the frame they appear.
|
|
846
|
+
const fresh = rows.filter((r) => !this.#heights.has(r.dataset.key));
|
|
847
|
+
if (fresh.length > 0)
|
|
848
|
+
this.#measured(fresh.map((r) => [r, r.getBoundingClientRect().height]));
|
|
849
|
+
const groupTool = this.#groupToolFocus;
|
|
850
|
+
if (groupTool) {
|
|
851
|
+
this.#groupToolFocus = undefined;
|
|
852
|
+
const tools = groupTool.field ? this.querySelector(`.aster-grid-group-tools[data-group-field="${CSS.escape(groupTool.field)}"]`) : undefined;
|
|
853
|
+
const target = tools?.querySelector(`[data-group-action="${groupTool.action}"]:not(:disabled)`) ?? tools?.querySelector("button");
|
|
854
|
+
if (target)
|
|
855
|
+
target.focus({ preventScroll: true });
|
|
856
|
+
else
|
|
857
|
+
this.focus({ preventScroll: true });
|
|
858
|
+
}
|
|
859
|
+
if (focusBefore && !focusBefore.isConnected && document.activeElement === document.body)
|
|
860
|
+
this.focus({ preventScroll: true });
|
|
861
|
+
}
|
|
862
|
+
// ── toolbar and panels ──
|
|
863
|
+
#filterContext() {
|
|
864
|
+
return JSON.stringify([this.set, this.#view.value, this.#base.value, this.#external()?.query]);
|
|
865
|
+
}
|
|
866
|
+
#filterStale() {
|
|
867
|
+
return this.#filterOwner !== this.#ctx.value || this.#filterSnapshot !== this.#filterContext();
|
|
868
|
+
}
|
|
869
|
+
#openAdvanced(opener) {
|
|
870
|
+
const ctx = this.#ctx.peek();
|
|
871
|
+
if (!ctx || !this.set)
|
|
872
|
+
return;
|
|
873
|
+
this.#filterGeneration++;
|
|
874
|
+
this.#filterShow = true;
|
|
875
|
+
this.#filterOpener = opener;
|
|
876
|
+
this.#filterOwner = ctx;
|
|
877
|
+
this.#filterBusy.value = false;
|
|
878
|
+
this.#filterNote.value = "";
|
|
879
|
+
this.#filterSnapshot = this.#filterContext();
|
|
880
|
+
try {
|
|
881
|
+
this.#filterDraft.value = new FilterDraft(ctx.schema, this.set, this.#view.peek()?.criteria);
|
|
882
|
+
}
|
|
883
|
+
catch {
|
|
884
|
+
this.#filterDraft.value = new FilterDraft(ctx.schema, this.set);
|
|
885
|
+
this.#filterNote.value = "This saved expression cannot be read. Build a replacement or cancel to keep it.";
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
#advancedContent() {
|
|
889
|
+
const model = this.#filterDraft.value;
|
|
890
|
+
if (!model)
|
|
891
|
+
return nothing;
|
|
892
|
+
const stale = this.#filterStale();
|
|
893
|
+
const reason = this.#filterBusy.value ? "Saving the current row before applying…" : stale ? "The grid query changed while you were editing. Reload its current expression before applying." : undefined;
|
|
894
|
+
return html `<h2 id=${`${this.#id}-advanced-title`}>${this.#label.value}: advanced filter</h2>
|
|
895
|
+
<p class="aster-hint">This expression combines with column filters and the page’s query constraints.</p>
|
|
896
|
+
${this.#filterNote.value ? html `<p class="aster-error" role="status">${this.#filterNote.value}</p>` : nothing}
|
|
897
|
+
<aster-filter-builder .model=${model} .readOnlyReason=${reason}
|
|
898
|
+
@apply=${(e) => { e.preventDefault(); void this.#applyAdvanced(model); }}
|
|
899
|
+
@cancel=${() => this.querySelector(".aster-grid-advanced")?.close()}></aster-filter-builder>
|
|
900
|
+
${stale ? html `<button type="button" class="aster-button" @click=${() => this.#openAdvanced(this.#filterOpener)}>Reload current expression</button>` : nothing}
|
|
901
|
+
${reason ? html `<button type="button" class="aster-button" @click=${() => this.querySelector(".aster-grid-advanced")?.close()}>Close</button>` : nothing}`;
|
|
902
|
+
}
|
|
903
|
+
async #applyAdvanced(model) {
|
|
904
|
+
if (this.#filterBusy.peek() || !model.result.peek().valid || this.#filterStale())
|
|
905
|
+
return;
|
|
906
|
+
const generation = this.#filterGeneration;
|
|
907
|
+
this.#filterBusy.value = true;
|
|
908
|
+
const saved = !this.#editing.peek() || await this.#endEdit(true);
|
|
909
|
+
if (generation !== this.#filterGeneration || !this.isConnected)
|
|
910
|
+
return;
|
|
911
|
+
this.#filterBusy.value = false;
|
|
912
|
+
if (!saved) {
|
|
913
|
+
this.#filterNote.value = "The current row could not be saved. Your filter draft is still available.";
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (this.#filterStale() || !model.apply())
|
|
917
|
+
return;
|
|
918
|
+
const view = this.#view.peek();
|
|
919
|
+
if (!view)
|
|
920
|
+
return;
|
|
921
|
+
const { criteria: _, ...rest } = view;
|
|
922
|
+
const criteria = model.applied.peek();
|
|
923
|
+
this.#setView({ ...rest, ...(criteria ? { criteria } : {}) });
|
|
924
|
+
this.querySelector(".aster-grid-advanced")?.close();
|
|
925
|
+
announce(criteria ? "Advanced filter applied" : "Advanced filter cleared");
|
|
926
|
+
}
|
|
927
|
+
#query(set, view, base, paths = {}) {
|
|
928
|
+
const checked = this.#criteriaCheck.value;
|
|
929
|
+
return viewQuery(set, checked?.valid === false ? { ...view, criteria: { op: "or", args: [] } } : view, base, paths);
|
|
930
|
+
}
|
|
931
|
+
#toolbar(view, layout) {
|
|
932
|
+
const panel = this.#panel.value;
|
|
933
|
+
const filters = Object.keys(view.filters).length;
|
|
934
|
+
const count = layout.total;
|
|
935
|
+
const fmt = (n) => new Intl.NumberFormat("en-GB").format(n);
|
|
936
|
+
const grouped = this.#treeOptions.value ? [] : groupFields(view);
|
|
937
|
+
const groupTitle = (field) => this.#collection?.type.field(field)?.title ?? field;
|
|
938
|
+
const chooseSort = (field, descending) => {
|
|
939
|
+
this.querySelector(".aster-grid-menu")?.close();
|
|
940
|
+
this.#setView(sortDirection(view, field, descending, false));
|
|
941
|
+
};
|
|
942
|
+
const tool = (name, label) => html `<button type="button" class="aster-grid-tool"
|
|
943
|
+
style=${anchorStyle(`${this.#id}-tool-${name}`)}
|
|
944
|
+
aria-expanded=${panel === name ? "true" : "false"} aria-controls=${`${this.#id}-${name}`}
|
|
945
|
+
@click=${() => (this.#panel.value = panel === name ? undefined : name)}>${label}</button>`;
|
|
946
|
+
return html `<div class="aster-grid-toolbar" role="toolbar" aria-label=${`${this.#label.value} view`}
|
|
947
|
+
@keydown=${(e) => this.#panelKey(e)}>
|
|
948
|
+
<span class="aster-grid-total" id=${`${this.#id}-total`} aria-live="polite">${count === undefined
|
|
949
|
+
? "Loading…"
|
|
950
|
+
: layout.estimate
|
|
951
|
+
? `About ${fmt(roughly(count))} rows`
|
|
952
|
+
: layout.atLeast !== undefined
|
|
953
|
+
? `${fmt(layout.atLeast)}+ rows`
|
|
954
|
+
: `${fmt(count)} ${count === 1 ? "row" : "rows"}${layout.groups === undefined ? "" : ` in ${fmt(layout.groups)} ${layout.groups === 1 ? "group" : "groups"}`}`}</span>
|
|
955
|
+
<button type="button" class="aster-grid-tool" aria-haspopup="menu" @click=${(event) => {
|
|
956
|
+
const items = view.columns.filter(column => !column.hidden && this.#sortable(column.field)).map(column => {
|
|
957
|
+
const sorted = view.sort.find(key => key.field === column.field);
|
|
958
|
+
return { kind: "group", label: groupTitle(column.field), items: [
|
|
959
|
+
{ kind: "radio", label: "Ascending", checked: !!sorted && !sorted.descending, run: () => chooseSort(column.field, false) },
|
|
960
|
+
{ kind: "radio", label: "Descending", checked: !!sorted?.descending, run: () => chooseSort(column.field, true) },
|
|
961
|
+
] };
|
|
962
|
+
});
|
|
963
|
+
items.push({ label: "Clear sorting", run: () => this.#setView({ ...view, sort: [] }) });
|
|
964
|
+
this.querySelector(".aster-grid-menu")?.openFrom(event.currentTarget, items, "Sort rows");
|
|
965
|
+
}}>Sort</button>
|
|
966
|
+
${this.#selectionNote.value ? html `<span class="aster-grid-note" role="status">${this.#selectionNote.value}</span>` : nothing}
|
|
967
|
+
${this.#grouping?.root.error.value !== undefined ? html `<span class="aster-grid-note aster-error" role="status">Couldn’t load groups.</span>
|
|
968
|
+
<button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void this.#grouping?.retry(); }}>Retry groups</button>` : nothing}
|
|
969
|
+
${this.#refused.value ? html `<span class="aster-grid-note aster-grid-refused">${this.#refused.value}</span>` : nothing}
|
|
970
|
+
${this.#tree?.searching.value
|
|
971
|
+
? html `<span class="aster-grid-note" role="status">Finding where the matches are…</span>`
|
|
972
|
+
: this.#tree?.tooBroad.value
|
|
973
|
+
? html `<span class="aster-grid-note" role="status">Too many matches to show where they are. Narrow the filter.</span>`
|
|
974
|
+
: this.#tree?.error.value !== undefined
|
|
975
|
+
? html `<span class="aster-grid-note aster-error" role="status">Couldn’t find where the matches are. Change the filter to try again.</span>`
|
|
976
|
+
: nothing}
|
|
977
|
+
${grouped.map((field, i) => html `<span class="aster-grid-group-tools" data-group-field=${field}>
|
|
978
|
+
<button type="button" class="aster-grid-tool" data-group-action="remove" aria-pressed="true" title=${grouped.length === 1 ? "Stop grouping" : `Remove grouping ${groupTitle(field)}`}
|
|
979
|
+
@click=${() => this.#setView(setGroup(view, grouped.filter((f) => f !== field)))}>${i ? "Then by" : "Grouped by"} ${groupTitle(field)} ✕</button>
|
|
980
|
+
${grouped.length > 1 ? html `<button type="button" class="aster-grid-tool" data-group-action="earlier" aria-label=${`Move ${groupTitle(field)} grouping earlier`} ?disabled=${i === 0}
|
|
981
|
+
@click=${() => this.#reorderGroup(i, -1)}>↑</button>
|
|
982
|
+
<button type="button" class="aster-grid-tool" data-group-action="later" aria-label=${`Move ${groupTitle(field)} grouping later`} ?disabled=${i === grouped.length - 1}
|
|
983
|
+
@click=${() => this.#reorderGroup(i, 1)}>↓</button>` : nothing}
|
|
984
|
+
</span>`)}
|
|
985
|
+
<button type="button" class="aster-grid-tool" aria-pressed=${this.#showFilters.value || filters > 0 ? "true" : "false"}
|
|
986
|
+
@click=${() => this.#toggleFilters(view)}>${filters ? `Filters (${filters})` : "Filters"}</button>
|
|
987
|
+
<button type="button" class="aster-grid-tool" aria-haspopup="dialog" aria-pressed=${view.criteria ? "true" : "false"}
|
|
988
|
+
@click=${(e) => this.#openAdvanced(e.currentTarget)}>Advanced filter</button>
|
|
989
|
+
${view.criteria ? html `<span class="aster-grid-expression" role="status">${this.#criteriaCheck.value?.valid === false
|
|
990
|
+
? "Saved filter needs repair. No matches will be returned."
|
|
991
|
+
: describeCriteria(view.criteria, this.#collection.type)}</span>` : nothing}
|
|
992
|
+
${tool("columns", "Columns")}
|
|
993
|
+
${this.#persist.value ? tool("views", "Views") : nothing}
|
|
994
|
+
${panel === "columns" ? this.#columnsPanel(view) : panel === "views" ? this.#viewsPanel(view) : nothing}
|
|
995
|
+
</div>`;
|
|
996
|
+
}
|
|
997
|
+
#columnsPanel(view) {
|
|
998
|
+
const type = this.#collection.type;
|
|
999
|
+
const title = (field) => type.field(field)?.title ?? field;
|
|
1000
|
+
const visible = view.columns.filter((c) => !c.hidden).length;
|
|
1001
|
+
return html `<div class="aster-grid-panel aster-popover" popover="manual" ${openWhen(true)} style=${placeStyle(`${this.#id}-tool-columns`)}
|
|
1002
|
+
id=${`${this.#id}-columns`} role="group" aria-label="Columns"
|
|
1003
|
+
@keydown=${(e) => this.#panelKey(e)}>
|
|
1004
|
+
<p class="aster-grid-panel-title">Columns</p>
|
|
1005
|
+
<ul>
|
|
1006
|
+
${view.columns.map((c, i) => html `<li>
|
|
1007
|
+
<label><input type="checkbox" .checked=${!c.hidden} ?disabled=${!c.hidden && visible === 1}
|
|
1008
|
+
@change=${(e) => this.#setView(setHidden(view, c.field, !e.target.checked))}>
|
|
1009
|
+
${title(c.field)}</label>
|
|
1010
|
+
<button type="button" aria-label=${`Move ${title(c.field)} up`} ?disabled=${i === 0}
|
|
1011
|
+
@click=${() => this.#setView(moveColumn(view, c.field, i - 1))}>↑</button>
|
|
1012
|
+
<button type="button" aria-label=${`Move ${title(c.field)} down`} ?disabled=${i === view.columns.length - 1}
|
|
1013
|
+
@click=${() => this.#setView(moveColumn(view, c.field, i + 1))}>↓</button>
|
|
1014
|
+
</li>`)}
|
|
1015
|
+
</ul>
|
|
1016
|
+
<button type="button" class="aster-grid-panel-action" @click=${() => this.#reset()}>Reset to the page’s view</button>
|
|
1017
|
+
</div>`;
|
|
1018
|
+
}
|
|
1019
|
+
#viewsPanel(view) {
|
|
1020
|
+
const saved = this.#saved.value;
|
|
1021
|
+
const names = Object.keys(saved.named).sort();
|
|
1022
|
+
// The name is read from the input on submit: a re-render mustn't lose what was typed.
|
|
1023
|
+
const save = (form) => {
|
|
1024
|
+
const n = (form.querySelector("input")?.value ?? "").trim();
|
|
1025
|
+
if (!n)
|
|
1026
|
+
return;
|
|
1027
|
+
this.#saved.value = { ...saved, named: { ...saved.named, [n]: view } };
|
|
1028
|
+
this.#panel.value = undefined;
|
|
1029
|
+
};
|
|
1030
|
+
return html `<div class="aster-grid-panel aster-popover" popover="manual" ${openWhen(true)} style=${placeStyle(`${this.#id}-tool-views`)}
|
|
1031
|
+
id=${`${this.#id}-views`} role="group" aria-label="Views"
|
|
1032
|
+
@keydown=${(e) => this.#panelKey(e)}>
|
|
1033
|
+
<p class="aster-grid-panel-title">Saved views</p>
|
|
1034
|
+
${names.length === 0 ? html `<p class="aster-grid-panel-note">None yet. Shape the grid, then save it here by name.</p>` : nothing}
|
|
1035
|
+
<ul>
|
|
1036
|
+
${names.map((n) => html `<li>
|
|
1037
|
+
<button type="button" class="aster-grid-panel-view" @click=${() => {
|
|
1038
|
+
this.#setView(reconcile(saved.named[n], this.#offered ?? view));
|
|
1039
|
+
this.#panel.value = undefined;
|
|
1040
|
+
}}>${n}</button>
|
|
1041
|
+
<button type="button" aria-label=${`Delete the view ${n}`} @click=${() => {
|
|
1042
|
+
const { [n]: _, ...rest } = saved.named;
|
|
1043
|
+
this.#saved.value = { ...saved, named: rest };
|
|
1044
|
+
}}>Delete</button>
|
|
1045
|
+
</li>`)}
|
|
1046
|
+
</ul>
|
|
1047
|
+
<form class="aster-grid-panel-save" @submit=${(e) => (e.preventDefault(), save(e.currentTarget))}>
|
|
1048
|
+
<label class="aster-field" data-ring-owner>
|
|
1049
|
+
<input aria-label="Name for this view" placeholder="Name this view">
|
|
1050
|
+
</label>
|
|
1051
|
+
<button type="submit" class="aster-grid-panel-action">Save</button>
|
|
1052
|
+
</form>
|
|
1053
|
+
<button type="button" class="aster-grid-panel-action" @click=${() => this.#reset()}>Reset to the page’s view</button>
|
|
1054
|
+
</div>`;
|
|
1055
|
+
}
|
|
1056
|
+
#reset() {
|
|
1057
|
+
if (this.#offered)
|
|
1058
|
+
this.#setView(this.#offered);
|
|
1059
|
+
this.#panel.value = undefined;
|
|
1060
|
+
}
|
|
1061
|
+
/** Escape in an open panel, or on the toolbar while one is open, closes it; focus goes to its button. */
|
|
1062
|
+
#panelKey(e) {
|
|
1063
|
+
const panel = this.#panel.peek();
|
|
1064
|
+
if (e.key !== "Escape" || !panel)
|
|
1065
|
+
return;
|
|
1066
|
+
e.stopPropagation();
|
|
1067
|
+
this.#panel.value = undefined;
|
|
1068
|
+
this.querySelector(`.aster-grid-tool[aria-controls="${this.#id}-${panel}"]`)?.focus();
|
|
1069
|
+
}
|
|
1070
|
+
// ── header ──
|
|
1071
|
+
#header(column, field, i, view, numeric, frozen, active) {
|
|
1072
|
+
const sort = view.sort.findIndex((s) => s.field === column.field);
|
|
1073
|
+
const key = sort >= 0 ? view.sort[sort] : undefined;
|
|
1074
|
+
const ariaSort = sort === 0 ? (key.descending ? "descending" : "ascending") : nothing;
|
|
1075
|
+
const title = field?.title ?? column.field;
|
|
1076
|
+
return html `<div class="aster-grid-cell aster-grid-header" role="columnheader" aria-colindex=${i + 1} id=${`${this.#id}-h-${i}`}
|
|
1077
|
+
aria-sort=${ariaSort} ?data-numeric=${numeric} ?data-frozen=${i < view.frozen} ?data-active=${active} style=${frozen}
|
|
1078
|
+
data-field=${column.field}>
|
|
1079
|
+
<button type="button" class="aster-grid-sort" tabindex="-1"
|
|
1080
|
+
title=${this.#sortable(column.field) ? `Sort by ${title}; Shift adds it to the sort; drag to move` : "Drag to move"}
|
|
1081
|
+
@pointerdown=${(e) => this.#dragColumn(e, column.field)}
|
|
1082
|
+
@click=${(e) => this.#sort(column.field, e.shiftKey)}>
|
|
1083
|
+
<span>${title}</span>
|
|
1084
|
+
${key
|
|
1085
|
+
? html `<svg class="aster-grid-sort-mark" viewBox="0 0 10 10" aria-hidden="true">
|
|
1086
|
+
<path d=${key.descending ? "M2 3.5 5 6.5l3-3" : "M2 6.5 5 3.5l3 3"}/></svg>
|
|
1087
|
+
${view.sort.length > 1 ? html `<small>${sort + 1}</small>` : nothing}`
|
|
1088
|
+
: nothing}
|
|
1089
|
+
</button>
|
|
1090
|
+
<button type="button" class="aster-grid-header-menu" tabindex="-1" aria-label=${`${title} column options`}
|
|
1091
|
+
@click=${(e) => this.#openMenu(column.field, e.currentTarget)}>
|
|
1092
|
+
<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.8 5 6.8l3-3"/></svg>
|
|
1093
|
+
</button>
|
|
1094
|
+
<span class="aster-grid-resize" aria-hidden="true" @pointerdown=${(e) => this.#resize(e, column.field)}
|
|
1095
|
+
@dblclick=${() => this.#setView(setWidth(this.#view.peek(), column.field, undefined))}></span>
|
|
1096
|
+
</div>`;
|
|
1097
|
+
}
|
|
1098
|
+
#menuItems(field) {
|
|
1099
|
+
const view = this.#view.peek();
|
|
1100
|
+
const type = this.#collection.type;
|
|
1101
|
+
const title = type.field(field)?.title ?? field;
|
|
1102
|
+
const sorted = view.sort.find((s) => s.field === field);
|
|
1103
|
+
const visible = view.columns.filter((c) => !c.hidden);
|
|
1104
|
+
const at = visible.findIndex((c) => c.field === field);
|
|
1105
|
+
const set = (v) => this.#setView(v);
|
|
1106
|
+
const width = (delta) => set(setWidth(view, field, (visible[at]?.width ?? this.#measuredWidth(field)) + delta));
|
|
1107
|
+
const frozenHere = at + 1 === view.frozen;
|
|
1108
|
+
const move = (to) => set(moveColumn(view, field, view.columns.findIndex((c) => c.field === visible[to]?.field)));
|
|
1109
|
+
return [
|
|
1110
|
+
...(this.#sortable(field)
|
|
1111
|
+
? [
|
|
1112
|
+
{
|
|
1113
|
+
kind: "group",
|
|
1114
|
+
label: "Sort",
|
|
1115
|
+
items: [
|
|
1116
|
+
{ kind: "radio", label: "Ascending", checked: !!sorted && !sorted.descending, run: () => set(sortDirection(view, field, false, false)) },
|
|
1117
|
+
{ kind: "radio", label: "Descending", checked: !!sorted?.descending, run: () => set(sortDirection(view, field, true, false)) },
|
|
1118
|
+
{ kind: "radio", label: "Not sorted", checked: !sorted, run: () => set(removeSort(view, field)) },
|
|
1119
|
+
...(view.sort.length > 0 && !sorted ? [{ label: "Add to the sort", run: () => set(sortDirection(view, field, false, true)) }] : []),
|
|
1120
|
+
],
|
|
1121
|
+
},
|
|
1122
|
+
{ kind: "separator" },
|
|
1123
|
+
]
|
|
1124
|
+
: []),
|
|
1125
|
+
...(this.#groupable(field)
|
|
1126
|
+
? [{ kind: "check", label: `Group by ${title}`, checked: groupFields(view).includes(field), run: () => set(setGroup(view, groupFields(view).includes(field) ? groupFields(view).filter((f) => f !== field) : [...groupFields(view), field])) }]
|
|
1127
|
+
: []),
|
|
1128
|
+
...(at < visible.length - 1
|
|
1129
|
+
? [{
|
|
1130
|
+
kind: "check",
|
|
1131
|
+
label: `Freeze up to ${title}`,
|
|
1132
|
+
checked: frozenHere,
|
|
1133
|
+
run: () => set(frozenHere ? { ...view, frozen: 0 } : freezeThrough(view, field, (f) => this.#measuredWidth(f))),
|
|
1134
|
+
}]
|
|
1135
|
+
: []),
|
|
1136
|
+
{
|
|
1137
|
+
kind: "submenu",
|
|
1138
|
+
label: "Move",
|
|
1139
|
+
items: [
|
|
1140
|
+
{ label: "Left", run: () => set(nudgeColumn(view, field, -1)), ...(at > 0 ? {} : { disabled: "It's the first column." }) },
|
|
1141
|
+
{ label: "Right", run: () => set(nudgeColumn(view, field, 1)), ...(at < visible.length - 1 ? {} : { disabled: "It's the last column." }) },
|
|
1142
|
+
{ label: "To the start", run: () => move(0), ...(at > 0 ? {} : { disabled: "It's the first column." }) },
|
|
1143
|
+
{ label: "To the end", run: () => move(visible.length - 1), ...(at < visible.length - 1 ? {} : { disabled: "It's the last column." }) },
|
|
1144
|
+
],
|
|
1145
|
+
},
|
|
1146
|
+
{
|
|
1147
|
+
kind: "submenu",
|
|
1148
|
+
label: "Width",
|
|
1149
|
+
items: [
|
|
1150
|
+
{ label: "Wider", run: () => width(40) },
|
|
1151
|
+
{ label: "Narrower", run: () => width(-40) },
|
|
1152
|
+
{ label: "Fit the space", run: () => set(setWidth(view, field, undefined)) },
|
|
1153
|
+
],
|
|
1154
|
+
},
|
|
1155
|
+
{ kind: "separator" },
|
|
1156
|
+
...(visible.length > 1 ? [{ label: `Hide ${title}`, run: () => set(setHidden(view, field, true)) }] : []),
|
|
1157
|
+
{ label: "Columns…", run: () => (this.#panel.value = "columns") },
|
|
1158
|
+
];
|
|
1159
|
+
}
|
|
1160
|
+
#openMenu(field, anchor, returnTo = anchor) {
|
|
1161
|
+
const title = this.#collection?.type.field(field)?.title ?? field;
|
|
1162
|
+
this.querySelector(".aster-grid-menu")?.openFrom(anchor, this.#menuItems(field), `${title} column`, returnTo);
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* The row's menu: the page's actions for the record, then the column's
|
|
1166
|
+
* own options as a submenu. At the pointer, or under the active cell.
|
|
1167
|
+
*/
|
|
1168
|
+
#openRowMenu(row, col, point) {
|
|
1169
|
+
const item = untracked(() => this.#layout()).at(row);
|
|
1170
|
+
const field = this.#view.peek().columns.filter((c) => !c.hidden)[col]?.field;
|
|
1171
|
+
if (!field)
|
|
1172
|
+
return;
|
|
1173
|
+
const title = this.#collection.type.field(field)?.title ?? field;
|
|
1174
|
+
const key = item.kind === "row" ? item.key : undefined;
|
|
1175
|
+
const entity = key === undefined ? undefined : this.#ctx.peek()?.store.get(this.#collection.query.set, key)?.entity;
|
|
1176
|
+
const actions = key !== undefined && entity ? this.#rowMenu.peek()(entity, key) : [];
|
|
1177
|
+
const items = [
|
|
1178
|
+
...actions,
|
|
1179
|
+
...(actions.length ? [{ kind: "separator" }] : []),
|
|
1180
|
+
{ kind: "submenu", label: `${title} column`, items: this.#menuItems(field) },
|
|
1181
|
+
];
|
|
1182
|
+
const cell = this.querySelector(`#${CSS.escape(`${this.#id}-${row}-${col}`)}`);
|
|
1183
|
+
const rect = cell?.getBoundingClientRect();
|
|
1184
|
+
const at = point ?? { x: rect?.left ?? 0, y: rect?.bottom ?? 0 };
|
|
1185
|
+
this.querySelector(".aster-grid-menu")?.openAt(at, items, key !== undefined ? "Row" : `${title} column`, this.querySelector(".aster-grid-scroll"));
|
|
1186
|
+
}
|
|
1187
|
+
// ── filters ──
|
|
1188
|
+
#filter(column, field, view) {
|
|
1189
|
+
if (!field)
|
|
1190
|
+
return nothing;
|
|
1191
|
+
const title = field.title ?? column.field;
|
|
1192
|
+
const current = view.filters[column.field];
|
|
1193
|
+
const lookup = this.#lookups.value[column.field];
|
|
1194
|
+
const pick = (options, value, to) => html `<aster-choice .label=${`Filter ${title}`} .placeholder=${"Any"} .options=${[{ value: "\u0000any", label: "Any" }, ...options]}
|
|
1195
|
+
.value=${value ?? "\u0000any"}
|
|
1196
|
+
@change=${(e) => this.#setView(setFilter(this.#view.peek(), column.field, e.detail === "\u0000any" ? undefined : to(e.detail)))}></aster-choice>`;
|
|
1197
|
+
const eq = (v) => ({ op: "eq", field: column.field, value: v });
|
|
1198
|
+
// The grid's own filters are plain values; a relative date shows as no choice.
|
|
1199
|
+
const chosen = current && current.op === "eq" && "value" in current && !isRelative(current.value) ? current.value : undefined;
|
|
1200
|
+
if (lookup) {
|
|
1201
|
+
// Any of several: chips, and an `in` filter.
|
|
1202
|
+
const values = !current ? [] : current.op === "in" ? current.values : chosen !== undefined ? [chosen] : [];
|
|
1203
|
+
return html `<aster-picker .options=${this.#optionsFor(column.field, lookup)} .multiple=${true} .values=${values}
|
|
1204
|
+
.label=${`Filter ${title}`} .placeholder=${"Any"}
|
|
1205
|
+
@change=${(e) => this.#setView(setFilter(this.#view.peek(), column.field, e.detail.length ? { op: "in", field: column.field, values: e.detail } : undefined))}></aster-picker>`;
|
|
1206
|
+
}
|
|
1207
|
+
if (field.type === "Edm.Boolean")
|
|
1208
|
+
return pick([{ value: true, label: "Yes" }, { value: false, label: "No" }], chosen, eq);
|
|
1209
|
+
if (field.allowedValues)
|
|
1210
|
+
return pick(field.allowedValues.map((a) => ({ value: a.value, label: a.label ?? String(a.value) })), chosen, eq);
|
|
1211
|
+
const ranged = isNumeric(field) || field.type === "Edm.Date";
|
|
1212
|
+
const text = this.#filterText.value[column.field] ?? (ranged ? rangeText(current) : current && "value" in current ? String(current.value) : "");
|
|
1213
|
+
const error = this.#filterErrors.value[column.field];
|
|
1214
|
+
const errorId = `${this.#id}-filter-${column.field}`;
|
|
1215
|
+
const date = field.type === "Edm.Date";
|
|
1216
|
+
const anchor = `${this.#id}-filter-anchor-${column.field}`;
|
|
1217
|
+
const dialog = `${this.#id}-filter-calendar`;
|
|
1218
|
+
const open = date && this.#filterCalendar.open.value && this.#filterCalendarField.value === column.field;
|
|
1219
|
+
const button = `${this.#id}-filter-date-${column.field}`;
|
|
1220
|
+
const close = (refocus) => {
|
|
1221
|
+
this.#filterCalendar.open.value = false;
|
|
1222
|
+
if (refocus)
|
|
1223
|
+
this.querySelector(`#${CSS.escape(button)}`)?.focus();
|
|
1224
|
+
};
|
|
1225
|
+
return html `<label class="aster-field" data-ring-owner style=${date ? anchorStyle(anchor) : nothing}>
|
|
1226
|
+
<input .value=${live(text)} aria-label=${`Filter ${title}`} aria-invalid=${error ? "true" : nothing}
|
|
1227
|
+
aria-describedby=${error ? errorId : nothing}
|
|
1228
|
+
placeholder=${ranged ? (date ? ">=1 Oct 2026" : ">5 or 1..3") : "Contains"}
|
|
1229
|
+
@input=${(e) => this.#typeFilter(column.field, field, e.target.value, ranged)}
|
|
1230
|
+
@keydown=${(e) => e.key === "Enter" && this.#flushFilter(column.field)}
|
|
1231
|
+
@blur=${() => this.#flushFilter(column.field)}>
|
|
1232
|
+
${date
|
|
1233
|
+
? html `<button type="button" class="aster-date-button" id=${button} aria-label=${`Choose a date to filter ${title}`}
|
|
1234
|
+
aria-haspopup="dialog" aria-expanded=${open ? "true" : "false"} aria-controls=${dialog}
|
|
1235
|
+
@click=${(e) => {
|
|
1236
|
+
e.preventDefault();
|
|
1237
|
+
if (open)
|
|
1238
|
+
return close(false);
|
|
1239
|
+
this.#filterCalendarField.value = column.field;
|
|
1240
|
+
this.#filterCalendar.show(/\d{4}-\d{2}-\d{2}/.exec(text)?.[0], today());
|
|
1241
|
+
closeOutside(this.#filterCalendar, this);
|
|
1242
|
+
}}>
|
|
1243
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2.5" y="3.5" width="11" height="10" rx="1.5"/><path d="M2.5 6.5h11M5.5 2v3M10.5 2v3"/></svg>
|
|
1244
|
+
</button>`
|
|
1245
|
+
: nothing}
|
|
1246
|
+
</label>
|
|
1247
|
+
${open
|
|
1248
|
+
? calendarView(this.#filterCalendar, {
|
|
1249
|
+
id: dialog,
|
|
1250
|
+
anchor,
|
|
1251
|
+
value: undefined,
|
|
1252
|
+
today: today(),
|
|
1253
|
+
label: `Choose a date to filter ${title}`,
|
|
1254
|
+
choose: (d) => {
|
|
1255
|
+
close(true);
|
|
1256
|
+
this.#typeFilter(column.field, field, withDate(text, d), ranged);
|
|
1257
|
+
},
|
|
1258
|
+
dismiss: close,
|
|
1259
|
+
})
|
|
1260
|
+
: nothing}
|
|
1261
|
+
${error ? html `<p class="aster-error" id=${errorId}>${error}</p>` : nothing}`;
|
|
1262
|
+
}
|
|
1263
|
+
/** Applies a filter being typed now, rather than after the pause: Enter or leaving the input. */
|
|
1264
|
+
#flushFilter(name) {
|
|
1265
|
+
const pending = this.#filterApply.get(name);
|
|
1266
|
+
if (!pending)
|
|
1267
|
+
return;
|
|
1268
|
+
clearTimeout(this.#filterTimers.get(name));
|
|
1269
|
+
pending();
|
|
1270
|
+
}
|
|
1271
|
+
#filterApply = new Map();
|
|
1272
|
+
#typeFilter(name, field, text, ranged) {
|
|
1273
|
+
this.#filterText.value = { ...this.#filterText.peek(), [name]: text };
|
|
1274
|
+
clearTimeout(this.#filterTimers.get(name));
|
|
1275
|
+
const apply = () => {
|
|
1276
|
+
this.#filterApply.delete(name);
|
|
1277
|
+
const parsed = ranged
|
|
1278
|
+
? parseRange(field, text)
|
|
1279
|
+
: { criteria: containsText(name, text) };
|
|
1280
|
+
const { [name]: _, ...errors } = this.#filterErrors.peek();
|
|
1281
|
+
this.#filterErrors.value = parsed.error ? { ...errors, [name]: parsed.error } : errors;
|
|
1282
|
+
if (!parsed.error)
|
|
1283
|
+
this.#setView(setFilter(this.#view.peek(), name, parsed.criteria));
|
|
1284
|
+
};
|
|
1285
|
+
this.#filterApply.set(name, apply);
|
|
1286
|
+
this.#filterTimers.set(name, setTimeout(apply, FILTER_DELAY));
|
|
1287
|
+
}
|
|
1288
|
+
#toggleFilters(view) {
|
|
1289
|
+
const showing = this.#showFilters.peek() || Object.keys(view.filters).length > 0;
|
|
1290
|
+
if (showing && Object.keys(view.filters).length > 0) {
|
|
1291
|
+
this.#filterText.value = {};
|
|
1292
|
+
this.#filterErrors.value = {};
|
|
1293
|
+
this.#setView({ ...view, filters: {} });
|
|
1294
|
+
}
|
|
1295
|
+
this.#showFilters.value = !showing;
|
|
1296
|
+
}
|
|
1297
|
+
#optionsFor(field, lookup) {
|
|
1298
|
+
let options = this.#options.get(field);
|
|
1299
|
+
if (!options) {
|
|
1300
|
+
const ctx = this.#ctx.peek();
|
|
1301
|
+
options = new PickerOptions(ctx, { set: lookup.set, display: lookup.display, pageSize: 500 });
|
|
1302
|
+
void options.all.loadAll();
|
|
1303
|
+
this.#options.set(field, options);
|
|
1304
|
+
const all = options.all;
|
|
1305
|
+
this.onDisconnect(() => ctx.release(all));
|
|
1306
|
+
}
|
|
1307
|
+
return options;
|
|
1308
|
+
}
|
|
1309
|
+
// ── cells ──
|
|
1310
|
+
#cell(column, field, entity, key, ctx) {
|
|
1311
|
+
const render = this.#renderers.value[column.field];
|
|
1312
|
+
if (render)
|
|
1313
|
+
return render(entity, key);
|
|
1314
|
+
const value = entity[column.field];
|
|
1315
|
+
const formatter = this.#formatters.value[column.field];
|
|
1316
|
+
if (formatter)
|
|
1317
|
+
return formatter(value, entity, field);
|
|
1318
|
+
const lookup = this.#lookups.value[column.field];
|
|
1319
|
+
if (lookup && value !== null && value !== undefined) {
|
|
1320
|
+
const related = ctx.lookup(lookup.set, keyText([value])).value;
|
|
1321
|
+
return related ? String(related[lookup.display] ?? "") : html `<span class="aster-grid-skeleton"></span>`;
|
|
1322
|
+
}
|
|
1323
|
+
if (field?.type === "Edm.Boolean")
|
|
1324
|
+
return value ? "Yes" : "";
|
|
1325
|
+
if (field?.type === "Edm.Date" && typeof value === "string")
|
|
1326
|
+
return formatDate(value);
|
|
1327
|
+
if (field?.type === "Edm.DateTimeOffset" && typeof value === "string")
|
|
1328
|
+
return absoluteTime(value);
|
|
1329
|
+
return formatValue(field, value);
|
|
1330
|
+
}
|
|
1331
|
+
#external() {
|
|
1332
|
+
return this.#binding.value ?? this.#selection.value?.collection;
|
|
1333
|
+
}
|
|
1334
|
+
#syncBound() {
|
|
1335
|
+
const collection = this.#external();
|
|
1336
|
+
const view = this.#view.value;
|
|
1337
|
+
const base = this.#base.value;
|
|
1338
|
+
const paths = this.#sortPaths.value;
|
|
1339
|
+
if (!collection) {
|
|
1340
|
+
this.#bound = undefined;
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
const query = collection.query;
|
|
1344
|
+
untracked(() => {
|
|
1345
|
+
if (collection !== this.#bound || query !== this.#boundQuery) {
|
|
1346
|
+
const sameFilter = collection === this.#bound && JSON.stringify(query.filter) === JSON.stringify(this.#boundQuery?.filter);
|
|
1347
|
+
const initial = this.#bound === undefined;
|
|
1348
|
+
this.#bound = collection;
|
|
1349
|
+
this.#boundQuery = query;
|
|
1350
|
+
if (!sameFilter)
|
|
1351
|
+
this.#boundBase = query.filter;
|
|
1352
|
+
const reverse = new Map(Object.entries(paths).map(([field, path]) => [path, field]));
|
|
1353
|
+
const { criteria: _, ...rest } = view ?? defaultView(collection.type);
|
|
1354
|
+
this.view = { ...(sameFilter || initial ? (view ?? rest) : rest), ...(!sameFilter && !initial ? { filters: {} } : {}),
|
|
1355
|
+
sort: (query.orderBy ?? []).map((sort) => ({ ...sort, field: reverse.get(sort.field) ?? sort.field })) };
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
if (!view)
|
|
1359
|
+
return;
|
|
1360
|
+
const parts = [this.#boundBase, base, ...Object.values(view.filters)].filter((c) => c !== undefined);
|
|
1361
|
+
const filter = parts.length > 1 ? { op: "and", args: parts } : parts[0];
|
|
1362
|
+
const projected = this.#query(query.set, { ...view, filters: {} }, filter, paths);
|
|
1363
|
+
if (JSON.stringify([query.filter, query.orderBy]) === JSON.stringify([projected.filter, projected.orderBy]))
|
|
1364
|
+
return;
|
|
1365
|
+
const { filter: _filter, orderBy: _orderBy, ...rest } = query;
|
|
1366
|
+
const next = { ...rest, ...projected };
|
|
1367
|
+
this.#boundQuery = next;
|
|
1368
|
+
collection.setQuery(next);
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
#recoverActive() {
|
|
1372
|
+
this.#version.value;
|
|
1373
|
+
const grouping = this.#grouping;
|
|
1374
|
+
if (grouping) {
|
|
1375
|
+
const rows = grouping.rows.value;
|
|
1376
|
+
const total = grouping.total.value;
|
|
1377
|
+
const key = this.#activeKey.value;
|
|
1378
|
+
const header = this.#groupFocus.value;
|
|
1379
|
+
untracked(() => {
|
|
1380
|
+
const { row, col } = this.#active.peek();
|
|
1381
|
+
if (row < 0 || total === undefined)
|
|
1382
|
+
return;
|
|
1383
|
+
let next = rows.findIndex((r) => r.kind === "row" ? key !== undefined && r.key === key : key === undefined && r.id === header?.id);
|
|
1384
|
+
if (next < 0 && header) {
|
|
1385
|
+
for (let n = header.path.length; n > 0 && next < 0; n--)
|
|
1386
|
+
next = rows.findIndex((r) => r.kind === "group" && JSON.stringify(r.path) === JSON.stringify(header.path.slice(0, n)));
|
|
1387
|
+
}
|
|
1388
|
+
if (next < 0)
|
|
1389
|
+
next = Math.min(row, Math.max(0, rows.length - 1));
|
|
1390
|
+
if (next !== row)
|
|
1391
|
+
this.#active.value = { row: next, col };
|
|
1392
|
+
const target = rows[next];
|
|
1393
|
+
if (target?.kind === "group" && (key !== undefined || header?.id !== target.id)) {
|
|
1394
|
+
this.#activeKey.value = undefined;
|
|
1395
|
+
this.#groupFocus.value = { id: target.id, path: target.path };
|
|
1396
|
+
}
|
|
1397
|
+
});
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
const collection = this.#collection;
|
|
1401
|
+
if (!collection || !this.#external())
|
|
1402
|
+
return;
|
|
1403
|
+
const keys = collection.keys.value;
|
|
1404
|
+
if (collection.count.value === undefined)
|
|
1405
|
+
return;
|
|
1406
|
+
const active = this.#activeKey.value;
|
|
1407
|
+
untracked(() => {
|
|
1408
|
+
const { row, col } = this.#active.peek();
|
|
1409
|
+
if (row < 0)
|
|
1410
|
+
return;
|
|
1411
|
+
const found = active === undefined ? -1 : keys.indexOf(active);
|
|
1412
|
+
const next = found >= 0 ? found : Math.min(row, Math.max(0, keys.length - 1));
|
|
1413
|
+
if (next !== row)
|
|
1414
|
+
this.#active.value = { row: next, col };
|
|
1415
|
+
// An evicted position keeps its navigation key until that position is loaded again.
|
|
1416
|
+
if (keys[next] !== undefined || keys.length === 0)
|
|
1417
|
+
this.#activeKey.value = keys[next];
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
#choose(key, index, extend, toggle) {
|
|
1421
|
+
if (!this.#selectable.peek())
|
|
1422
|
+
return;
|
|
1423
|
+
const selection = this.#selection.peek();
|
|
1424
|
+
if (!selection)
|
|
1425
|
+
return;
|
|
1426
|
+
let accepted = true;
|
|
1427
|
+
if (extend && selection.multiple) {
|
|
1428
|
+
const anchor = this.#selectionAnchor;
|
|
1429
|
+
const slots = selection.collection.keys.peek();
|
|
1430
|
+
const loaded = anchor ? slots.indexOf(anchor.key) : -1;
|
|
1431
|
+
const from = loaded >= 0 ? loaded : anchor?.query === selection.collection.query ? anchor.index : index;
|
|
1432
|
+
const range = slots.slice(Math.min(from, index), Math.max(from, index) + 1).filter((k) => k !== undefined);
|
|
1433
|
+
accepted = selection.selectAll(range);
|
|
1434
|
+
}
|
|
1435
|
+
else {
|
|
1436
|
+
if (toggle || selection.ordered)
|
|
1437
|
+
accepted = selection.toggle(key);
|
|
1438
|
+
else
|
|
1439
|
+
selection.select(key);
|
|
1440
|
+
this.#selectionAnchor = { key, index, query: selection.collection.query };
|
|
1441
|
+
}
|
|
1442
|
+
this.#selectionNote.value = accepted ? "" : `Select at most ${selection.maxOrdered} ordered items. Deselect an item to add another.`;
|
|
1443
|
+
}
|
|
1444
|
+
#moveSelection(key, delta) {
|
|
1445
|
+
if (!this.#selectable.peek())
|
|
1446
|
+
return;
|
|
1447
|
+
const selection = this.#selection.peek();
|
|
1448
|
+
if (!selection)
|
|
1449
|
+
return;
|
|
1450
|
+
const index = selection.keys.peek().indexOf(key) + delta;
|
|
1451
|
+
if (selection.move(key, index))
|
|
1452
|
+
this.#selectionNote.value = `Moved to selection position ${index + 1}.`;
|
|
1453
|
+
}
|
|
1454
|
+
#selectionControl(key, entity, chosen, hidden) {
|
|
1455
|
+
const selection = this.#selection.value;
|
|
1456
|
+
const index = chosen.indexOf(key);
|
|
1457
|
+
const field = this.#collection.type.titleField;
|
|
1458
|
+
const name = field && hidden?.has(field) ? "Hidden" : String((entity && field ? entity[field] : undefined) ?? key);
|
|
1459
|
+
return html `<span class="aster-grid-selection" @click=${(e) => e.stopPropagation()}
|
|
1460
|
+
@dblclick=${(e) => e.stopPropagation()}
|
|
1461
|
+
@focusout=${(e) => {
|
|
1462
|
+
const node = e.currentTarget;
|
|
1463
|
+
queueMicrotask(() => { if (!node.contains(document.activeElement))
|
|
1464
|
+
this.#selectionInside.value = undefined; });
|
|
1465
|
+
}} @keydown=${(e) => {
|
|
1466
|
+
if (e.key === "Escape") {
|
|
1467
|
+
e.preventDefault();
|
|
1468
|
+
this.#selectionInside.value = undefined;
|
|
1469
|
+
this.focus();
|
|
1470
|
+
}
|
|
1471
|
+
else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
|
1472
|
+
e.preventDefault();
|
|
1473
|
+
const buttons = [...e.currentTarget.querySelectorAll("button:not(:disabled)")];
|
|
1474
|
+
const index = buttons.indexOf(document.activeElement);
|
|
1475
|
+
buttons[(index + (e.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length]?.focus();
|
|
1476
|
+
}
|
|
1477
|
+
if (e.key !== "Tab")
|
|
1478
|
+
e.stopPropagation();
|
|
1479
|
+
}}>
|
|
1480
|
+
<button type="button" class="aster-grid-pick" tabindex=${this.#selectionInside.value === key ? 0 : -1} aria-pressed=${String(index >= 0)}
|
|
1481
|
+
aria-label=${`${selection.multiple && index >= 0 ? "Deselect" : "Select"} ${name}`}
|
|
1482
|
+
@click=${() => this.#choose(key, this.#collection.keys.peek().indexOf(key), false, selection.multiple)}>
|
|
1483
|
+
${index < 0 ? "○" : selection.ordered ? String(index + 1) : "✓"}</button>
|
|
1484
|
+
${selection.ordered && index >= 0 ? html `<span class="aster-sr-only">Selection ${index + 1} of ${chosen.length}.</span>
|
|
1485
|
+
<button type="button" tabindex=${this.#selectionInside.value === key ? 0 : -1} class="aster-grid-pick" aria-label=${`Move ${name} earlier in selection`}
|
|
1486
|
+
?disabled=${index === 0} @click=${() => this.#moveSelection(key, -1)}>↑</button>
|
|
1487
|
+
<button type="button" tabindex=${this.#selectionInside.value === key ? 0 : -1} class="aster-grid-pick" aria-label=${`Move ${name} later in selection`}
|
|
1488
|
+
?disabled=${index === chosen.length - 1} @click=${() => this.#moveSelection(key, 1)}>↓</button>` : nothing}
|
|
1489
|
+
</span>`;
|
|
1490
|
+
}
|
|
1491
|
+
// ── data and view ──
|
|
1492
|
+
/** With `persist`, the person's last view replaces the page's, reconciled with today's columns. */
|
|
1493
|
+
#restore() {
|
|
1494
|
+
const key = this.#persist.value;
|
|
1495
|
+
const offered = this.#offered;
|
|
1496
|
+
if (!key || !offered)
|
|
1497
|
+
return;
|
|
1498
|
+
untracked(() => {
|
|
1499
|
+
const saved = loadViews(key);
|
|
1500
|
+
this.#restored = true;
|
|
1501
|
+
this.#saved.value = saved;
|
|
1502
|
+
if (saved.current)
|
|
1503
|
+
this.#view.value = reconcile(saved.current, offered);
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
/** The one place views are written: the current view and the named ones, whenever either changes. */
|
|
1507
|
+
#keep() {
|
|
1508
|
+
const view = this.#view.value;
|
|
1509
|
+
const saved = this.#saved.value;
|
|
1510
|
+
const key = this.#persist.value;
|
|
1511
|
+
if (!key || !view || !this.#restored)
|
|
1512
|
+
return;
|
|
1513
|
+
storeViews(key, { named: saved.named, current: view });
|
|
1514
|
+
}
|
|
1515
|
+
/** Stored views have been read; until then, writing would replace them. */
|
|
1516
|
+
#restored = false;
|
|
1517
|
+
/** Publishes the shared selection or active row, plus view and total. */
|
|
1518
|
+
#announce() {
|
|
1519
|
+
const key = this.#publish.value;
|
|
1520
|
+
const scope = scopeOf(this);
|
|
1521
|
+
this.#version.value;
|
|
1522
|
+
const ctx = this.#ctx.peek();
|
|
1523
|
+
const collection = this.#collection;
|
|
1524
|
+
if (!key || !scope || !ctx || !collection)
|
|
1525
|
+
return undefined;
|
|
1526
|
+
const set = collection.query.set;
|
|
1527
|
+
const shared = this.#selection.value;
|
|
1528
|
+
const publication = shared ? selectionPublication(shared) : undefined;
|
|
1529
|
+
return scope.publish(key, {
|
|
1530
|
+
values: {
|
|
1531
|
+
key: this.#selected,
|
|
1532
|
+
selected: computed(() => {
|
|
1533
|
+
const k = this.#selected.value;
|
|
1534
|
+
return k === undefined ? undefined : ctx.store.record(set, k).value?.entity;
|
|
1535
|
+
}),
|
|
1536
|
+
any: computed(() => this.#selected.value !== undefined),
|
|
1537
|
+
...publication?.values,
|
|
1538
|
+
view: computed(() => this.#view.value),
|
|
1539
|
+
total: computed(() => this.#layout().total),
|
|
1540
|
+
},
|
|
1541
|
+
types: { selected: collection.type },
|
|
1542
|
+
...(publication?.hidden ? { hidden: publication.hidden } : {}),
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
#setView(view) {
|
|
1546
|
+
const request = ++this.#viewRequest;
|
|
1547
|
+
if (this.#editing.peek() && (JSON.stringify(groupFields(view)) !== JSON.stringify(groupFields(this.#view.peek())) || JSON.stringify(view.criteria) !== JSON.stringify(this.#view.peek()?.criteria))) {
|
|
1548
|
+
void this.#endEdit(true).then((saved) => {
|
|
1549
|
+
if (saved && this.isConnected && request === this.#viewRequest)
|
|
1550
|
+
this.#commitView(view);
|
|
1551
|
+
});
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
this.#commitView(view);
|
|
1555
|
+
}
|
|
1556
|
+
#commitView(view) {
|
|
1557
|
+
const beforeGroups = groupFields(this.#view.peek());
|
|
1558
|
+
const afterGroups = groupFields(view);
|
|
1559
|
+
const focused = this.contains(document.activeElement) ? document.activeElement?.closest(".aster-grid-group-tools") : null;
|
|
1560
|
+
if (focused && JSON.stringify(beforeGroups) !== JSON.stringify(afterGroups)) {
|
|
1561
|
+
const field = focused.dataset.groupField;
|
|
1562
|
+
this.#groupToolFocus = { field: afterGroups.includes(field) ? field : afterGroups[Math.min(beforeGroups.indexOf(field), afterGroups.length - 1)],
|
|
1563
|
+
action: document.activeElement?.dataset.groupAction ?? "remove" };
|
|
1564
|
+
}
|
|
1565
|
+
const before = this.#view.peek()?.sort;
|
|
1566
|
+
this.#view.value = view;
|
|
1567
|
+
if (before && JSON.stringify(before) !== JSON.stringify(view.sort))
|
|
1568
|
+
announce(this.#sortText(view));
|
|
1569
|
+
}
|
|
1570
|
+
/** Uses a supplied collection, or owns one whose query follows the view. */
|
|
1571
|
+
#follow() {
|
|
1572
|
+
const ctx = this.#ctx.value;
|
|
1573
|
+
const set = this.set;
|
|
1574
|
+
const binding = this.#external();
|
|
1575
|
+
if (!ctx || !set)
|
|
1576
|
+
return;
|
|
1577
|
+
if (binding) {
|
|
1578
|
+
untracked(() => {
|
|
1579
|
+
this.#selected.value = undefined;
|
|
1580
|
+
if (this.#collection === binding) {
|
|
1581
|
+
this.#ownsCollection = false;
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
if (this.#ownsCollection && this.#collection)
|
|
1585
|
+
ctx.release(this.#collection);
|
|
1586
|
+
this.#ownsCollection = false;
|
|
1587
|
+
this.#collection = binding;
|
|
1588
|
+
this.#version.value++;
|
|
1589
|
+
});
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
untracked(() => {
|
|
1593
|
+
if (!this.#view.peek())
|
|
1594
|
+
this.view = defaultView(ctx.schema.set(set).type);
|
|
1595
|
+
});
|
|
1596
|
+
if (this.#collection?.query.set === set && (this.#ownsCollection || this.#tree))
|
|
1597
|
+
return;
|
|
1598
|
+
const treeOptions = untracked(() => this.#treeOptions.value);
|
|
1599
|
+
if (treeOptions) {
|
|
1600
|
+
untracked(() => {
|
|
1601
|
+
this.#tree?.dispose();
|
|
1602
|
+
const filters = computed(() => {
|
|
1603
|
+
return this.#query(set, this.#view.value ?? { columns: [], sort: [], filters: {}, frozen: 0 }).filter;
|
|
1604
|
+
});
|
|
1605
|
+
this.#tree = new Tree(ctx, {
|
|
1606
|
+
set,
|
|
1607
|
+
...treeOptions,
|
|
1608
|
+
base: computed(() => this.#base.value),
|
|
1609
|
+
filter: filters,
|
|
1610
|
+
orderBy: computed(() => viewQuery(set, this.#view.value ?? { columns: [], sort: [], filters: {}, frozen: 0 }, undefined, this.#sortPaths.value).orderBy ?? []),
|
|
1611
|
+
});
|
|
1612
|
+
this.#collection = this.#tree.level(undefined);
|
|
1613
|
+
this.#version.value++;
|
|
1614
|
+
});
|
|
1615
|
+
this.onDisconnect(() => {
|
|
1616
|
+
this.#tree?.dispose();
|
|
1617
|
+
this.#tree = undefined;
|
|
1618
|
+
this.#collection = undefined;
|
|
1619
|
+
});
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
const query = computed(() => this.#query(set, this.#view.value ?? { columns: [], sort: [], filters: {}, frozen: 0 }, this.#base.value, this.#sortPaths.value));
|
|
1623
|
+
untracked(() => {
|
|
1624
|
+
if (this.#ownsCollection && this.#collection)
|
|
1625
|
+
ctx.release(this.#collection);
|
|
1626
|
+
this.#collection = ctx.collection(query, { pageSize: 100 });
|
|
1627
|
+
this.#ownsCollection = true;
|
|
1628
|
+
this.#version.value++;
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
/** A new query (filter, sort, grouping, base) starts at the top, with its first row active. */
|
|
1632
|
+
#toTop() {
|
|
1633
|
+
const view = this.#view.value;
|
|
1634
|
+
// Before the view arrives there's no query to compare with.
|
|
1635
|
+
if (!view)
|
|
1636
|
+
return;
|
|
1637
|
+
const key = JSON.stringify([view.filters, view.criteria, view.sort, view.groupBy, this.#base.value, this.#external()?.query]);
|
|
1638
|
+
untracked(() => {
|
|
1639
|
+
if (this.#lastQuery !== undefined && this.#lastQuery !== key) {
|
|
1640
|
+
const scroll = this.querySelector(".aster-grid-scroll");
|
|
1641
|
+
if (scroll)
|
|
1642
|
+
scroll.scrollTop = 0;
|
|
1643
|
+
this.#top.value = 0;
|
|
1644
|
+
// A sort or filter made from the header or filter row keeps the active cell there.
|
|
1645
|
+
const { row, col } = this.#active.peek();
|
|
1646
|
+
this.#active.value = { row: row < 0 ? row : 0, col };
|
|
1647
|
+
}
|
|
1648
|
+
this.#lastQuery = key;
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
#lastQuery;
|
|
1652
|
+
/**
|
|
1653
|
+
* Loads the pages the viewport shows, once scrolling pauses: of the
|
|
1654
|
+
* collection, or of each expanded group in view.
|
|
1655
|
+
*/
|
|
1656
|
+
#load() {
|
|
1657
|
+
const collection = this.#collection;
|
|
1658
|
+
this.#version.value;
|
|
1659
|
+
if (!collection)
|
|
1660
|
+
return;
|
|
1661
|
+
const layout = this.#layout();
|
|
1662
|
+
this.#heights.setKeys(layout.keys);
|
|
1663
|
+
const { start, end } = this.#heights.window(this.#top.value, this.#viewport.value, OVERSCAN);
|
|
1664
|
+
const wanted = new Map();
|
|
1665
|
+
if (!groupFields(this.#view.value).length && !this.#tree)
|
|
1666
|
+
wanted.set(collection, { from: start, to: Math.max(end, 1) });
|
|
1667
|
+
else {
|
|
1668
|
+
// A tree's top level loads before it has lines to look at.
|
|
1669
|
+
if (this.#tree && collection.count.value === undefined)
|
|
1670
|
+
wanted.set(collection, { from: 0, to: 1 });
|
|
1671
|
+
for (let i = start; i < end; i++) {
|
|
1672
|
+
const item = layout.at(i);
|
|
1673
|
+
if (item.kind !== "row" || !item.collection)
|
|
1674
|
+
continue;
|
|
1675
|
+
const range = wanted.get(item.collection) ?? { from: item.local, to: item.local + 1 };
|
|
1676
|
+
wanted.set(item.collection, { from: Math.min(range.from, item.local), to: Math.max(range.to, item.local + 1) });
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
// One load at a time, always for the latest position: while one is in
|
|
1680
|
+
// flight, the newest wanted range waits and the ones scrolled past drop.
|
|
1681
|
+
const pending = this.#pendingFocus.value;
|
|
1682
|
+
const retain = [this.#active.peek().row, ...(pending === undefined ? [] : [pending])];
|
|
1683
|
+
const run = () => {
|
|
1684
|
+
this.#loadingRows = true;
|
|
1685
|
+
void Promise.all([...wanted].map(([c, r]) => c.view(r.from, r.to, c === this.#external() ? retain : []).catch(() => { }))).finally(() => {
|
|
1686
|
+
this.#loadingRows = false;
|
|
1687
|
+
const next = this.#nextLoad;
|
|
1688
|
+
this.#nextLoad = undefined;
|
|
1689
|
+
next?.();
|
|
1690
|
+
});
|
|
1691
|
+
};
|
|
1692
|
+
if (this.#loadingRows)
|
|
1693
|
+
this.#nextLoad = run;
|
|
1694
|
+
else
|
|
1695
|
+
run();
|
|
1696
|
+
}
|
|
1697
|
+
#loadingRows = false;
|
|
1698
|
+
#nextLoad;
|
|
1699
|
+
/** The grid's lines: the collection's keys, or groups with the rows of those expanded. */
|
|
1700
|
+
#layout() {
|
|
1701
|
+
const collection = this.#collection;
|
|
1702
|
+
const view = this.#view.value;
|
|
1703
|
+
const tree = this.#tree;
|
|
1704
|
+
if (tree) {
|
|
1705
|
+
const nodes = tree.rows.value;
|
|
1706
|
+
const keys = nodes.map((n) => n.key);
|
|
1707
|
+
return {
|
|
1708
|
+
keys,
|
|
1709
|
+
total: collection.count.value,
|
|
1710
|
+
...atLeast(collection),
|
|
1711
|
+
groups: undefined,
|
|
1712
|
+
at: (i) => {
|
|
1713
|
+
const n = nodes[i];
|
|
1714
|
+
return { kind: "row", key: n.key, collection: n.collection, local: n.index, node: n };
|
|
1715
|
+
},
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
if (!groupFields(view).length) {
|
|
1719
|
+
const keys = collection.keys.value;
|
|
1720
|
+
const total = collection.count.value;
|
|
1721
|
+
return { keys, total, ...atLeast(collection), groups: undefined, at: (i) => ({ kind: "row", key: keys[i], collection, local: i }) };
|
|
1722
|
+
}
|
|
1723
|
+
const grouping = this.#grouping;
|
|
1724
|
+
const rows = grouping?.rows.value ?? [];
|
|
1725
|
+
const keys = rows.map((row) => row.kind === "group" ? row.id : row.key);
|
|
1726
|
+
return { keys, total: grouping?.total.value, groups: grouping?.root.rows.value?.length, at: (i) => rows[i] };
|
|
1727
|
+
}
|
|
1728
|
+
#groupRow(item, index, rowIndex, columns, _view, active) {
|
|
1729
|
+
const field = this.#collection.type.field(item.field);
|
|
1730
|
+
const label = this.#groupLabel(item.field, field, item.group.value);
|
|
1731
|
+
const path = item.path.map((value, i) => String(this.#groupLabel(groupFields(this.#view.value)[i], this.#collection.type.field(groupFields(this.#view.value)[i]), value))).join(" / ");
|
|
1732
|
+
return html `<div class="aster-grid-row aster-grid-group" role="row" aria-rowindex=${rowIndex} data-index=${index}
|
|
1733
|
+
data-key=${item.id} aria-level=${item.level} aria-posinset=${item.position} aria-setsize=${item.siblings}
|
|
1734
|
+
aria-expanded=${item.expanded ? "true" : "false"} aria-busy=${item.loading ? "true" : "false"} style=${`--level:${item.level - 1}`}>
|
|
1735
|
+
<div class="aster-grid-cell" role="gridcell" id=${`${this.#id}-${index}-0`} aria-colindex="1" aria-colspan=${columns}
|
|
1736
|
+
?data-active=${active} @click=${(e) => { if (!e.target.closest("button"))
|
|
1737
|
+
this.#toggleGroup(item.id); }}>
|
|
1738
|
+
<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M3.5 2 6.5 5l-3 3"/></svg>
|
|
1739
|
+
<span class="aster-sr-only">${field?.title ?? item.field}: </span>
|
|
1740
|
+
<span class="aster-grid-group-label">${label}</span>
|
|
1741
|
+
<span class="aster-grid-group-count">${new Intl.NumberFormat("en-GB").format(item.group.count)}</span>
|
|
1742
|
+
${item.totalsAvailable ? this.#summaries.value.map((summary, i) => summary.method === "count" ? nothing : html `<span class="aster-grid-group-summary">${this.#summaryLabel(summary)}: ${this.#summaryValue(summary, item.group.totals[`s${i}`] ?? null)}</span>`) : nothing}
|
|
1743
|
+
${item.loading ? html `<span class="aster-grid-group-loading" role="status">Loading…</span>` : nothing}
|
|
1744
|
+
${item.error !== undefined ? html `<span class="aster-error" role="status">Couldn’t load group.</span>
|
|
1745
|
+
<button type="button" class="aster-grid-tool" aria-label=${`Retry group ${path}`} @click=${() => { this.#activate(index, 0); this.focus({ preventScroll: true }); void this.#grouping?.retry(item.id).catch(() => { }); }}>Retry</button>` : nothing}
|
|
1746
|
+
</div>
|
|
1747
|
+
</div>`;
|
|
1748
|
+
}
|
|
1749
|
+
#aggregates(summaries) {
|
|
1750
|
+
return summaries.flatMap((s, i) => s.method === "count" ? [] : [{ field: s.field, method: s.method, as: `s${i}` }]);
|
|
1751
|
+
}
|
|
1752
|
+
#summarize() {
|
|
1753
|
+
this.#version.value;
|
|
1754
|
+
const ctx = this.#ctx.value;
|
|
1755
|
+
const collection = this.#collection;
|
|
1756
|
+
const config = this.#summaries.value;
|
|
1757
|
+
untracked(() => {
|
|
1758
|
+
if (this.#summaryConfig !== undefined && this.#summaryCollection === collection && this.#summaryConfig === config && this.#summaryContext === ctx)
|
|
1759
|
+
return;
|
|
1760
|
+
this.#summary?.dispose();
|
|
1761
|
+
this.#summary = undefined;
|
|
1762
|
+
this.#summaryCollection = collection;
|
|
1763
|
+
this.#summaryConfig = config;
|
|
1764
|
+
this.#summaryContext = ctx;
|
|
1765
|
+
if (ctx && collection && config.length) {
|
|
1766
|
+
this.#summary = ctx.summary(computed(() => collection.query), this.#aggregates(config));
|
|
1767
|
+
}
|
|
1768
|
+
this.#version.value++;
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
#summaryLabel(summary) {
|
|
1772
|
+
if (summary.label)
|
|
1773
|
+
return summary.label;
|
|
1774
|
+
if (summary.method === "count")
|
|
1775
|
+
return "Rows";
|
|
1776
|
+
const title = this.#collection?.type.field(summary.field)?.title ?? summary.field;
|
|
1777
|
+
const names = { sum: "Sum", min: "Minimum", max: "Maximum", average: "Average", countdistinct: "Distinct" };
|
|
1778
|
+
return `${names[summary.method]} ${title}`;
|
|
1779
|
+
}
|
|
1780
|
+
#summaryValue(summary, value) {
|
|
1781
|
+
if (value === null)
|
|
1782
|
+
return "—";
|
|
1783
|
+
if (summary.method === "count" || summary.method === "countdistinct")
|
|
1784
|
+
return new Intl.NumberFormat("en-GB").format(Number(value));
|
|
1785
|
+
return formatValue(this.#collection?.type.field(summary.field), value);
|
|
1786
|
+
}
|
|
1787
|
+
#summaryView() {
|
|
1788
|
+
const summary = this.#summary;
|
|
1789
|
+
if (!summary)
|
|
1790
|
+
return nothing;
|
|
1791
|
+
const values = summary.values.value;
|
|
1792
|
+
const status = summary.status.value;
|
|
1793
|
+
return html `<section class="aster-grid-summary" aria-label=${`${this.#label.value} summaries`} aria-busy=${status === "loading" ? "true" : "false"}>
|
|
1794
|
+
<aster-progress-status class="aster-grid-summary-status" .busy=${status === "loading"}
|
|
1795
|
+
.error=${status === "error" ? "Couldn’t load summaries." : undefined} .progressPolicy=${this.#progressPolicy.value}
|
|
1796
|
+
.pendingText=${"Loading summaries…"} .completeText=${"Summaries updated"}></aster-progress-status>
|
|
1797
|
+
${status === "error" ? html `<button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void summary.refresh(); }}>Retry summaries</button>` : nothing}
|
|
1798
|
+
${values ? html `<dl>${this.#summaries.value.map((s, i) => html `<div><dt>${this.#summaryLabel(s)}</dt><dd>${this.#summaryValue(s, values[s.method === "count" ? "count" : `s${i}`] ?? null)}</dd></div>`)}</dl>` : nothing}
|
|
1799
|
+
</section>`;
|
|
1800
|
+
}
|
|
1801
|
+
#groupLabel(name, field, value) {
|
|
1802
|
+
if (value === null)
|
|
1803
|
+
return "None";
|
|
1804
|
+
const lookup = this.#lookups.value[name];
|
|
1805
|
+
if (lookup)
|
|
1806
|
+
return String(this.#ctx.peek().lookup(lookup.set, keyText([value])).value?.[lookup.display] ?? "…");
|
|
1807
|
+
if (field?.type === "Edm.Boolean")
|
|
1808
|
+
return value ? "Yes" : "No";
|
|
1809
|
+
if (field?.type === "Edm.Date" && typeof value === "string")
|
|
1810
|
+
return formatDate(value);
|
|
1811
|
+
if (field?.type === "Edm.DateTimeOffset" && typeof value === "string")
|
|
1812
|
+
return absoluteTime(value);
|
|
1813
|
+
return formatValue(field, value);
|
|
1814
|
+
}
|
|
1815
|
+
#groupable(name) {
|
|
1816
|
+
if (this.#tree || this.#external())
|
|
1817
|
+
return false;
|
|
1818
|
+
const field = this.#collection?.type.field(name);
|
|
1819
|
+
if (!field)
|
|
1820
|
+
return false;
|
|
1821
|
+
return !(field.type === "Edm.String" && !field.allowedValues && (field.maxLength ?? 0) > 500);
|
|
1822
|
+
}
|
|
1823
|
+
async #toggleGroup(id) {
|
|
1824
|
+
const grouping = this.#grouping;
|
|
1825
|
+
if (!grouping)
|
|
1826
|
+
return;
|
|
1827
|
+
const editing = this.#editing.peek();
|
|
1828
|
+
if (editing && grouping.open.peek().has(id)) {
|
|
1829
|
+
const rows = grouping.rows.peek();
|
|
1830
|
+
const parent = rows.find((r) => r.kind === "group" && r.id === id);
|
|
1831
|
+
const record = rows.find((r) => r.kind === "row" && r.key === editing.key);
|
|
1832
|
+
const leaf = record && rows.find((r) => r.kind === "group" && r.id === record.parent);
|
|
1833
|
+
if (parent && leaf && JSON.stringify(leaf.path.slice(0, parent.path.length)) === JSON.stringify(parent.path)) {
|
|
1834
|
+
if (!(await this.#endEdit(true)) || !this.isConnected || this.#grouping !== grouping)
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
const rows = grouping.rows.peek();
|
|
1839
|
+
const index = rows.findIndex((r) => r.kind === "group" && r.id === id);
|
|
1840
|
+
if (index < 0)
|
|
1841
|
+
return;
|
|
1842
|
+
// Collapse can remove the active descendant. Keep its parent header active.
|
|
1843
|
+
const active = rows[this.#active.peek().row];
|
|
1844
|
+
if (grouping.open.peek().has(id) && active && active !== rows[index]) {
|
|
1845
|
+
const parent = active.kind === "group" ? active.path : rows.find((r) => r.kind === "group" && r.id === active.parent)?.path;
|
|
1846
|
+
const target = rows[index].path;
|
|
1847
|
+
if (parent && JSON.stringify(parent.slice(0, target.length)) === JSON.stringify(target))
|
|
1848
|
+
this.#activate(index, 0);
|
|
1849
|
+
}
|
|
1850
|
+
void grouping.toggle(id).catch(() => { });
|
|
1851
|
+
}
|
|
1852
|
+
#reorderGroup(index, step) {
|
|
1853
|
+
const view = this.#view.peek();
|
|
1854
|
+
if (!view)
|
|
1855
|
+
return;
|
|
1856
|
+
const fields = [...groupFields(view)];
|
|
1857
|
+
const to = index + step;
|
|
1858
|
+
if (to < 0 || to >= fields.length)
|
|
1859
|
+
return;
|
|
1860
|
+
[fields[index], fields[to]] = [fields[to], fields[index]];
|
|
1861
|
+
this.#setView(setGroup(view, fields));
|
|
1862
|
+
}
|
|
1863
|
+
#groupSummaryConfig;
|
|
1864
|
+
#groupFieldsKey = "";
|
|
1865
|
+
#groupContext;
|
|
1866
|
+
/** Grouping order creates the model; query signals update its open branches. */
|
|
1867
|
+
#group() {
|
|
1868
|
+
const ctx = this.#ctx.value;
|
|
1869
|
+
const set = this.set;
|
|
1870
|
+
const fields = this.#treeOptions.value ? [] : groupFields(this.#view.value);
|
|
1871
|
+
const key = JSON.stringify([set, fields]);
|
|
1872
|
+
const summaries = this.#summaries.value;
|
|
1873
|
+
untracked(() => {
|
|
1874
|
+
if (this.#grouping && this.#groupContext === ctx && this.#groupFieldsKey === key) {
|
|
1875
|
+
if (this.#groupSummaryConfig !== summaries) {
|
|
1876
|
+
this.#groupSummaryConfig = summaries;
|
|
1877
|
+
this.#grouping.setAggregates(this.#aggregates(summaries));
|
|
1878
|
+
}
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
this.#grouping?.dispose();
|
|
1882
|
+
this.#grouping = undefined;
|
|
1883
|
+
this.#groupContext = ctx;
|
|
1884
|
+
this.#groupFieldsKey = key;
|
|
1885
|
+
this.#groupSummaryConfig = summaries;
|
|
1886
|
+
if (ctx && set && fields.length) {
|
|
1887
|
+
const query = computed(() => this.#query(set, this.#view.value ?? { columns: [], sort: [], filters: {}, frozen: 0 }, this.#base.value, this.#sortPaths.value));
|
|
1888
|
+
this.#grouping = ctx.grouping(query, fields, this.#aggregates(summaries), { pageSize: 100, keepPages: 4 });
|
|
1889
|
+
}
|
|
1890
|
+
this.#version.value++;
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
#scrolled(scroll) {
|
|
1894
|
+
this.#top.value = scroll.scrollTop;
|
|
1895
|
+
}
|
|
1896
|
+
/** The scroll box resized: the viewport follows, and a new width re-measures the rows. Returns whether it did. */
|
|
1897
|
+
#boxResized(scroll) {
|
|
1898
|
+
// Retained views have no geometry while an ancestor is hidden.
|
|
1899
|
+
if (scroll.clientWidth === 0 || scroll.clientHeight === 0)
|
|
1900
|
+
return false;
|
|
1901
|
+
this.#viewport.value = scroll.clientHeight;
|
|
1902
|
+
if (scroll.clientWidth === this.#scrollWidth)
|
|
1903
|
+
return false;
|
|
1904
|
+
this.#scrollWidth = scroll.clientWidth;
|
|
1905
|
+
const rows = [...this.querySelectorAll(".aster-grid-row[data-key]")];
|
|
1906
|
+
this.#measured(rows.map((r) => [r, r.getBoundingClientRect().height]));
|
|
1907
|
+
return true;
|
|
1908
|
+
}
|
|
1909
|
+
/** Records rows' heights; when any changed, re-renders with the row at the viewport's top held in place. */
|
|
1910
|
+
#measured(sizes) {
|
|
1911
|
+
const scroll = this.querySelector(".aster-grid-scroll");
|
|
1912
|
+
if (!scroll)
|
|
1913
|
+
return;
|
|
1914
|
+
const index = this.#heights.indexAt(scroll.scrollTop);
|
|
1915
|
+
const delta = scroll.scrollTop - this.#heights.offset(index);
|
|
1916
|
+
let changed = false;
|
|
1917
|
+
for (const [row, height] of sizes) {
|
|
1918
|
+
const key = row.dataset.key;
|
|
1919
|
+
if (!key || !row.isConnected || height <= 0)
|
|
1920
|
+
continue;
|
|
1921
|
+
if (this.#heights.measure(key, Math.round(height * 100) / 100))
|
|
1922
|
+
changed = true;
|
|
1923
|
+
}
|
|
1924
|
+
if (!changed)
|
|
1925
|
+
return;
|
|
1926
|
+
this.#anchor = { index, delta };
|
|
1927
|
+
this.#version.value++;
|
|
1928
|
+
}
|
|
1929
|
+
#frozenOffsets(columns, frozen) {
|
|
1930
|
+
const out = [];
|
|
1931
|
+
let left = 0;
|
|
1932
|
+
for (let i = 0; i < Math.min(frozen, columns.length); i++) {
|
|
1933
|
+
out.push(left);
|
|
1934
|
+
left += columns[i].width ?? 0;
|
|
1935
|
+
}
|
|
1936
|
+
return out;
|
|
1937
|
+
}
|
|
1938
|
+
/** A column's rendered width, for fixing it when it's frozen or nudged. */
|
|
1939
|
+
#measuredWidth(field) {
|
|
1940
|
+
const header = this.querySelector(`.aster-grid-header[data-field="${CSS.escape(field)}"]`);
|
|
1941
|
+
return Math.round(header?.getBoundingClientRect().width ?? naturalWidth(this.#collection?.type.field(field)));
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* A column sorts by its own values. A foreign key shows a related record's
|
|
1945
|
+
* field, and sorting it by the key would look sorted but isn't, so it
|
|
1946
|
+
* doesn't sort until the server can order by the related field.
|
|
1947
|
+
*/
|
|
1948
|
+
#sortable(field) {
|
|
1949
|
+
return !this.#lookups.peek()[field] || this.#sortPaths.peek()[field] !== undefined;
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Lookup columns sort by the related record's display field, through the
|
|
1953
|
+
* navigation whose key is the column (`personId` sorts by `person/name`).
|
|
1954
|
+
*/
|
|
1955
|
+
#sortPaths = computed(() => {
|
|
1956
|
+
const ctx = this.#ctx.value;
|
|
1957
|
+
const type = ctx && this.set ? ctx.schema.set(this.set).type : undefined;
|
|
1958
|
+
if (!type)
|
|
1959
|
+
return {};
|
|
1960
|
+
const paths = {};
|
|
1961
|
+
for (const [field, lookup] of Object.entries(this.#lookups.value)) {
|
|
1962
|
+
const nav = type.navigations.find((n) => !n.collection && n.keys.length === 1 && n.keys[0][0] === field);
|
|
1963
|
+
if (nav)
|
|
1964
|
+
paths[field] = `${nav.name}/${lookup.display}`;
|
|
1965
|
+
}
|
|
1966
|
+
for (const column of this.#view.value?.columns ?? [])
|
|
1967
|
+
if (column.sortField)
|
|
1968
|
+
paths[column.field] = column.sortField;
|
|
1969
|
+
return paths;
|
|
1970
|
+
});
|
|
1971
|
+
#sort(field, add) {
|
|
1972
|
+
if (this.#dragged || !this.#sortable(field))
|
|
1973
|
+
return;
|
|
1974
|
+
const view = this.#view.peek();
|
|
1975
|
+
if (view)
|
|
1976
|
+
this.#setView(sortBy(view, field, add));
|
|
1977
|
+
}
|
|
1978
|
+
/** Drag the right edge of a header; JS writes only the column's width. */
|
|
1979
|
+
#resize(e, field) {
|
|
1980
|
+
if (e.button !== 0)
|
|
1981
|
+
return;
|
|
1982
|
+
e.preventDefault();
|
|
1983
|
+
e.stopPropagation();
|
|
1984
|
+
const handle = e.currentTarget;
|
|
1985
|
+
handle.setPointerCapture(e.pointerId);
|
|
1986
|
+
const from = e.clientX;
|
|
1987
|
+
const width = this.#measuredWidth(field);
|
|
1988
|
+
const move = (m) => this.#setView(setWidth(this.#view.peek(), field, width + m.clientX - from));
|
|
1989
|
+
const up = () => handle.removeEventListener("pointermove", move);
|
|
1990
|
+
handle.addEventListener("pointermove", move);
|
|
1991
|
+
handle.addEventListener("pointerup", up, { once: true });
|
|
1992
|
+
handle.addEventListener("pointercancel", up, { once: true });
|
|
1993
|
+
}
|
|
1994
|
+
#dragged = false;
|
|
1995
|
+
/** Drag a header sideways to move its column; a press without moving sorts. */
|
|
1996
|
+
#dragColumn(e, field) {
|
|
1997
|
+
if (e.button !== 0)
|
|
1998
|
+
return;
|
|
1999
|
+
const button = e.currentTarget;
|
|
2000
|
+
const from = e.clientX;
|
|
2001
|
+
this.#dragged = false;
|
|
2002
|
+
button.setPointerCapture(e.pointerId);
|
|
2003
|
+
const move = (m) => {
|
|
2004
|
+
if (!this.#dragged && Math.abs(m.clientX - from) < 6)
|
|
2005
|
+
return;
|
|
2006
|
+
this.#dragged = true;
|
|
2007
|
+
this.toggleAttribute("data-dragging", true);
|
|
2008
|
+
};
|
|
2009
|
+
const up = (u) => {
|
|
2010
|
+
button.removeEventListener("pointermove", move);
|
|
2011
|
+
this.toggleAttribute("data-dragging", false);
|
|
2012
|
+
if (!this.#dragged)
|
|
2013
|
+
return;
|
|
2014
|
+
const headers = [...this.querySelectorAll(".aster-grid-header")];
|
|
2015
|
+
const target = headers.find((h) => {
|
|
2016
|
+
const r = h.getBoundingClientRect();
|
|
2017
|
+
return u.clientX >= r.left && u.clientX < r.right;
|
|
2018
|
+
});
|
|
2019
|
+
const view = this.#view.peek();
|
|
2020
|
+
if (target?.dataset.field && target.dataset.field !== field) {
|
|
2021
|
+
this.#setView(moveColumn(view, field, view.columns.findIndex((c) => c.field === target.dataset.field)));
|
|
2022
|
+
}
|
|
2023
|
+
// The click that follows the drag isn't a sort.
|
|
2024
|
+
setTimeout(() => (this.#dragged = false));
|
|
2025
|
+
};
|
|
2026
|
+
button.addEventListener("pointermove", move);
|
|
2027
|
+
button.addEventListener("pointerup", up, { once: true });
|
|
2028
|
+
button.addEventListener("pointercancel", up, { once: true });
|
|
2029
|
+
}
|
|
2030
|
+
/**
|
|
2031
|
+
* Dragging a row in a tree grid: a mouse lifts it past a few pixels, a
|
|
2032
|
+
* touch or pen by holding still (`pressDrag`). A label follows the
|
|
2033
|
+
* pointer; the row under it becomes the new parent (the header: the top
|
|
2034
|
+
* level). A row can't go under itself or its own subtree; that drop says
|
|
2035
|
+
* why. Letting go anywhere else, or an interrupted pointer, moves nothing.
|
|
2036
|
+
*/
|
|
2037
|
+
#dragRow(e) {
|
|
2038
|
+
const tree = this.#tree;
|
|
2039
|
+
const target = e.target;
|
|
2040
|
+
if (!tree || !this.#treeOptions.peek()?.draggable || e.button !== 0 || !this.#editable.peek())
|
|
2041
|
+
return;
|
|
2042
|
+
if (target.closest("button, input, textarea, select, a, [data-editing], .aster-grid-expansion, .aster-grid-controls"))
|
|
2043
|
+
return;
|
|
2044
|
+
const row = target.closest(".aster-grid-row[data-key]:not(.aster-grid-head)");
|
|
2045
|
+
const key = row?.dataset.key;
|
|
2046
|
+
if (!row || !key)
|
|
2047
|
+
return;
|
|
2048
|
+
const scroll = this.querySelector(".aster-grid-scroll");
|
|
2049
|
+
const touch = holds(e);
|
|
2050
|
+
const title = (row.querySelector(".aster-grid-cell")?.textContent ?? "").trim();
|
|
2051
|
+
let label;
|
|
2052
|
+
let over;
|
|
2053
|
+
// The node's ancestors from stored records: a drop is refused where its own key is among them.
|
|
2054
|
+
const why = (to) => {
|
|
2055
|
+
for (let k = to; k !== undefined;) {
|
|
2056
|
+
if (k === key)
|
|
2057
|
+
return "A row can’t move under itself or its own subtree.";
|
|
2058
|
+
const parent = tree.entity(k)?.[tree.options.parentField];
|
|
2059
|
+
k = parent === null || parent === undefined ? undefined : keyText([parent]);
|
|
2060
|
+
}
|
|
2061
|
+
return undefined;
|
|
2062
|
+
};
|
|
2063
|
+
pressDrag(e, { distance: 6, escape: true, capture: scroll, scroller: () => scroll, inset: () => this.querySelector(".aster-grid-headgroup")?.offsetHeight ?? 0 }, {
|
|
2064
|
+
lift: () => {
|
|
2065
|
+
label = document.createElement("div");
|
|
2066
|
+
label.className = "aster-grid-drag";
|
|
2067
|
+
label.textContent = title;
|
|
2068
|
+
label.setAttribute("aria-hidden", "true");
|
|
2069
|
+
label.toggleAttribute("data-touch", touch);
|
|
2070
|
+
this.append(label);
|
|
2071
|
+
this.toggleAttribute("data-dragging", true);
|
|
2072
|
+
if (touch)
|
|
2073
|
+
this.#lifted.value = key;
|
|
2074
|
+
announce(`Picked up ${title}. Drop it on another row to move it under that row, or on Top level to move it to the top level.`);
|
|
2075
|
+
},
|
|
2076
|
+
move: (x, y) => {
|
|
2077
|
+
// Above a finger, where it can be seen (below it near the top); beside a mouse pointer.
|
|
2078
|
+
label.style.translate = touch ? `${x - 24}px ${y < 72 ? y + 32 : y - 64}px` : `${x + 12}px ${y + 8}px`;
|
|
2079
|
+
over?.el.removeAttribute("data-drop");
|
|
2080
|
+
const under = document.elementFromPoint(x, y);
|
|
2081
|
+
const head = under?.closest(".aster-grid-head, .aster-grid-root-drop");
|
|
2082
|
+
const into = under?.closest(".aster-grid-row[data-key]:not(.aster-grid-head)");
|
|
2083
|
+
// A finger starts over its own row; there it is only being held.
|
|
2084
|
+
over = head
|
|
2085
|
+
? { el: head, to: undefined, why: undefined }
|
|
2086
|
+
: into?.dataset.key && !(touch && into.dataset.key === key)
|
|
2087
|
+
? { el: into, to: into.dataset.key, why: why(into.dataset.key) }
|
|
2088
|
+
: undefined;
|
|
2089
|
+
over?.el.setAttribute("data-drop", over.why ? "refused" : "ok");
|
|
2090
|
+
},
|
|
2091
|
+
end: (drop) => {
|
|
2092
|
+
this.toggleAttribute("data-dragging", false);
|
|
2093
|
+
this.#lifted.value = undefined;
|
|
2094
|
+
label?.remove();
|
|
2095
|
+
over?.el.removeAttribute("data-drop");
|
|
2096
|
+
void this.#dropRow(tree, key, title, drop ? over : undefined);
|
|
2097
|
+
},
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
/** Ends a row drag: moves the row under `over`, says why it can't, or that nothing moved. */
|
|
2101
|
+
async #dropRow(tree, key, title, over) {
|
|
2102
|
+
if (over?.why) {
|
|
2103
|
+
this.#refused.value = over.why;
|
|
2104
|
+
announce(over.why);
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
const parent = tree.entity(key)?.[tree.options.parentField];
|
|
2108
|
+
const from = parent === null || parent === undefined ? undefined : keyText([parent]);
|
|
2109
|
+
if (!over || over.to === from) {
|
|
2110
|
+
announce(`${title} not moved.`);
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
try {
|
|
2114
|
+
await tree.move(key, over.to);
|
|
2115
|
+
this.#refused.value = undefined;
|
|
2116
|
+
if (over.to !== undefined)
|
|
2117
|
+
await tree.expand(over.to);
|
|
2118
|
+
this.emit("moved", { key, from, to: over.to });
|
|
2119
|
+
}
|
|
2120
|
+
catch (err) {
|
|
2121
|
+
this.#refused.value = err instanceof Error ? err.message : String(err);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
// ── keyboard and focus ──
|
|
2125
|
+
#dblclick(e) {
|
|
2126
|
+
if (e.target.closest("button, input, select, textarea, a[href], [contenteditable], [data-editing]"))
|
|
2127
|
+
return;
|
|
2128
|
+
const row = e.target.closest('[role="row"][data-index]:not(.aster-grid-group)');
|
|
2129
|
+
if (row?.dataset.key && !this.emit("row-open", { key: row.dataset.key }))
|
|
2130
|
+
return;
|
|
2131
|
+
// With expansion, a double-click opens the row rather than editing it in
|
|
2132
|
+
// place, and closes it again unless it has unsaved changes.
|
|
2133
|
+
if (this.#expansion.peek() && row?.dataset.key && !e.target.closest("[data-editing]")) {
|
|
2134
|
+
const key = row.dataset.key;
|
|
2135
|
+
if (!this.#open.peek().includes(key))
|
|
2136
|
+
this.expand(key);
|
|
2137
|
+
else if (!this.#editors.get(key)?.dirty?.peek())
|
|
2138
|
+
void this.collapse(key);
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
if (!this.#editable.peek() || e.target.closest("[data-editing]"))
|
|
2142
|
+
return;
|
|
2143
|
+
const cell = e.target.closest('[role="gridcell"]');
|
|
2144
|
+
if (row?.dataset.key)
|
|
2145
|
+
this.#startEdit(row.dataset.key, Number(cell?.getAttribute("aria-colindex") ?? 1) - 1);
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* Starts editing a row: an edit model with the grid's rules in a child
|
|
2149
|
+
* scope where the row is published as `row`. Any other row being edited
|
|
2150
|
+
* saves first, and stays if it can't.
|
|
2151
|
+
*/
|
|
2152
|
+
async #startEdit(key, col) {
|
|
2153
|
+
if (this.#editing.peek()?.key === key)
|
|
2154
|
+
return;
|
|
2155
|
+
if (!(await this.#endEdit(true)))
|
|
2156
|
+
return;
|
|
2157
|
+
const ctx = this.#ctx.peek();
|
|
2158
|
+
const set = this.#collection.query.set;
|
|
2159
|
+
const edit = new EditModel(ctx, set, key);
|
|
2160
|
+
const scope = (scopeOf(this) ?? undefined)?.child();
|
|
2161
|
+
const withdraw = scope?.publish("row", editPublication(edit));
|
|
2162
|
+
const rules = this.#rules.peek();
|
|
2163
|
+
if (scope && Object.keys(rules).length)
|
|
2164
|
+
edit.applyRules(scope, `${this.#publish.peek() || "grid"}.row`, rules);
|
|
2165
|
+
this.#editing.value = { key, edit, stop: () => (withdraw?.(), edit.dispose()) };
|
|
2166
|
+
requestAnimationFrame(() => this.#focusEditor(key, col));
|
|
2167
|
+
}
|
|
2168
|
+
#focusEditor(key, col) {
|
|
2169
|
+
const row = this.querySelector(`.aster-grid-row[data-key="${CSS.escape(key)}"]`);
|
|
2170
|
+
// A user may choose another control before deferred initial focus runs.
|
|
2171
|
+
if (row?.contains(document.activeElement))
|
|
2172
|
+
return;
|
|
2173
|
+
const cells = row ? [...row.querySelectorAll('[role="gridcell"]')] : [];
|
|
2174
|
+
const target = cells[col]?.querySelector("input:not([readonly]), textarea:not([readonly]), [role=combobox]:not([aria-readonly])") ??
|
|
2175
|
+
row?.querySelector("input:not([readonly]), textarea:not([readonly]), [role=combobox]:not([aria-readonly])");
|
|
2176
|
+
target?.focus();
|
|
2177
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
|
|
2178
|
+
target.select();
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Ends editing. Saving: a row with errors or a conflict stays in edit and
|
|
2182
|
+
* returns false. The grid takes focus back when `refocus`, on the row,
|
|
2183
|
+
* wherever the save moved it.
|
|
2184
|
+
*/
|
|
2185
|
+
async #endEdit(save, refocus = false, only) {
|
|
2186
|
+
const editing = this.#editing.peek();
|
|
2187
|
+
if (!editing || (only && editing !== only))
|
|
2188
|
+
return true;
|
|
2189
|
+
if (save && editing.edit.dirty.peek()) {
|
|
2190
|
+
const saved = await editing.edit.save();
|
|
2191
|
+
if (!saved) {
|
|
2192
|
+
requestAnimationFrame(() => this.querySelector(`.aster-grid-row[data-editing] [aria-invalid="true"]`)?.focus());
|
|
2193
|
+
return false;
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
if (this.#editing.peek() !== editing)
|
|
2197
|
+
return true;
|
|
2198
|
+
editing.stop();
|
|
2199
|
+
this.#editing.value = undefined;
|
|
2200
|
+
if (refocus) {
|
|
2201
|
+
this.focus();
|
|
2202
|
+
requestAnimationFrame(() => {
|
|
2203
|
+
const index = untracked(() => this.#layout()).keys.indexOf(editing.key);
|
|
2204
|
+
if (index >= 0)
|
|
2205
|
+
this.#activate(index, this.#active.peek().col);
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
return true;
|
|
2209
|
+
}
|
|
2210
|
+
#editKey(e) {
|
|
2211
|
+
if (e.target.closest('[aria-expanded="true"]'))
|
|
2212
|
+
return;
|
|
2213
|
+
// Shift+Enter is a new line in long text; Enter alone saves the row.
|
|
2214
|
+
if (e.key === "Enter" && e.shiftKey && e.target.matches("textarea"))
|
|
2215
|
+
return;
|
|
2216
|
+
if (e.key === "Enter") {
|
|
2217
|
+
e.preventDefault();
|
|
2218
|
+
e.stopPropagation();
|
|
2219
|
+
void this.#endEdit(true, true);
|
|
2220
|
+
}
|
|
2221
|
+
else if (e.key === "Escape") {
|
|
2222
|
+
e.preventDefault();
|
|
2223
|
+
e.stopPropagation();
|
|
2224
|
+
this.#editing.peek()?.edit.revert();
|
|
2225
|
+
void this.#endEdit(false, true);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* WebKit doesn't focus a checkbox, radio or button that's pressed: its
|
|
2230
|
+
* mousedown blurs the row's field, which reads as focus leaving the row and
|
|
2231
|
+
* saves it before the click lands. Here the press focuses the control
|
|
2232
|
+
* instead, and its click still toggles or activates it.
|
|
2233
|
+
*/
|
|
2234
|
+
#focusPressed(e) {
|
|
2235
|
+
const control = e.target.closest('input[type="checkbox"], input[type="radio"], button, [role="checkbox"], [role="switch"]');
|
|
2236
|
+
if (!control || control.matches(":disabled"))
|
|
2237
|
+
return;
|
|
2238
|
+
e.preventDefault();
|
|
2239
|
+
control.focus({ preventScroll: true });
|
|
2240
|
+
}
|
|
2241
|
+
/** Focus leaving the row saves it; within the row, or into a list it opened, it doesn't. */
|
|
2242
|
+
#editBlur(e) {
|
|
2243
|
+
const row = e.currentTarget;
|
|
2244
|
+
const next = e.relatedTarget;
|
|
2245
|
+
if (next && row.contains(next))
|
|
2246
|
+
return;
|
|
2247
|
+
const editing = this.#editing.peek();
|
|
2248
|
+
requestAnimationFrame(() => {
|
|
2249
|
+
if (row.contains(document.activeElement) || !editing)
|
|
2250
|
+
return;
|
|
2251
|
+
void this.#endEdit(true, false, editing);
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
#contextMenu(e) {
|
|
2255
|
+
const cell = e.target.closest('[role="gridcell"]');
|
|
2256
|
+
const row = cell?.closest('[role="row"][data-index]:not(.aster-grid-group)');
|
|
2257
|
+
if (!cell || !row || e.target.closest("[data-editing]"))
|
|
2258
|
+
return;
|
|
2259
|
+
e.preventDefault();
|
|
2260
|
+
const index = Number(row.dataset.index);
|
|
2261
|
+
const col = Number(cell.getAttribute("aria-colindex")) - 1;
|
|
2262
|
+
this.#activate(index, col);
|
|
2263
|
+
this.#openRowMenu(index, col, { x: e.clientX, y: e.clientY });
|
|
2264
|
+
}
|
|
2265
|
+
#click(e) {
|
|
2266
|
+
const cell = e.target.closest('[role="gridcell"]');
|
|
2267
|
+
const row = cell?.closest('[role="row"][data-index]');
|
|
2268
|
+
if (!cell || !row)
|
|
2269
|
+
return;
|
|
2270
|
+
if (e.target.closest("button, input, select, textarea, a[href], [contenteditable], [data-editing]"))
|
|
2271
|
+
return;
|
|
2272
|
+
const index = Number(row.dataset.index);
|
|
2273
|
+
const item = untracked(() => this.#layout()).at(index);
|
|
2274
|
+
if (item.kind === "row" && item.key !== undefined)
|
|
2275
|
+
this.#choose(item.key, index, e.shiftKey, e.ctrlKey || e.metaKey);
|
|
2276
|
+
this.#activate(index, row.classList.contains("aster-grid-group") ? this.#active.peek().col : Number(cell.getAttribute("aria-colindex")) - 1);
|
|
2277
|
+
}
|
|
2278
|
+
#key(e, columns) {
|
|
2279
|
+
if (e.target.closest(".aster-grid-filters, .aster-grid-panel, .aster-grid-selection, [data-editing], .aster-grid-group button"))
|
|
2280
|
+
return;
|
|
2281
|
+
const layout = untracked(() => this.#layout());
|
|
2282
|
+
const count = layout.keys.length;
|
|
2283
|
+
const { row, col } = this.#active.peek();
|
|
2284
|
+
if (row < 0)
|
|
2285
|
+
return this.#headKey(e, row, col, columns, count);
|
|
2286
|
+
if (!count)
|
|
2287
|
+
return;
|
|
2288
|
+
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "Enter" || e.key === " ") && this.#groupKey(e, row, col))
|
|
2289
|
+
return;
|
|
2290
|
+
if (col === 0 && (e.key === "ArrowRight" || e.key === "ArrowLeft") && this.#treeKey(e, row))
|
|
2291
|
+
return;
|
|
2292
|
+
const item = layout.at(row);
|
|
2293
|
+
if (this.#selection.peek()?.ordered && e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown") && item.kind === "row" && item.key !== undefined) {
|
|
2294
|
+
e.preventDefault();
|
|
2295
|
+
this.#moveSelection(item.key, e.key === "ArrowUp" ? -1 : 1);
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
if (e.key === " " && this.#selection.peek() && item.kind === "row" && item.key !== undefined) {
|
|
2299
|
+
e.preventDefault();
|
|
2300
|
+
this.#choose(item.key, row, e.shiftKey, this.#selection.peek().multiple);
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
const page = Math.max(1, Math.floor(this.#viewport.peek() / this.#heights.estimate) - 1);
|
|
2304
|
+
let next;
|
|
2305
|
+
switch (e.key) {
|
|
2306
|
+
case "ArrowDown":
|
|
2307
|
+
next = { row: row + 1, col };
|
|
2308
|
+
break;
|
|
2309
|
+
case "ArrowUp":
|
|
2310
|
+
if (row === 0) {
|
|
2311
|
+
e.preventDefault();
|
|
2312
|
+
this.#active.value = { row: this.#filtersShown() ? FILTER_ROW : HEADER, col };
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
next = { row: row - 1, col };
|
|
2316
|
+
break;
|
|
2317
|
+
case "ArrowRight":
|
|
2318
|
+
next = { row, col: col + 1 };
|
|
2319
|
+
break;
|
|
2320
|
+
case "ArrowLeft":
|
|
2321
|
+
next = { row, col: col - 1 };
|
|
2322
|
+
break;
|
|
2323
|
+
case "PageDown":
|
|
2324
|
+
next = { row: row + page, col };
|
|
2325
|
+
break;
|
|
2326
|
+
case "PageUp":
|
|
2327
|
+
next = { row: row - page, col };
|
|
2328
|
+
break;
|
|
2329
|
+
case "Home":
|
|
2330
|
+
next = e.ctrlKey || e.metaKey ? { row: 0, col: 0 } : { row, col: 0 };
|
|
2331
|
+
break;
|
|
2332
|
+
case "End":
|
|
2333
|
+
next = e.ctrlKey || e.metaKey ? { row: count - 1, col: columns - 1 } : { row, col: columns - 1 };
|
|
2334
|
+
break;
|
|
2335
|
+
case "Enter":
|
|
2336
|
+
case "F2": {
|
|
2337
|
+
if (item.kind !== "row" || item.key === undefined)
|
|
2338
|
+
return;
|
|
2339
|
+
if (e.key === "F2" && this.#selectable.peek() && this.#selection.peek() && (!this.#editable.peek() || e.shiftKey)) {
|
|
2340
|
+
e.preventDefault();
|
|
2341
|
+
this.#selectionInside.value = item.key;
|
|
2342
|
+
queueMicrotask(() => this.querySelector(`.aster-grid-row[data-key="${CSS.escape(item.key)}"] .aster-grid-pick`)?.focus());
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
if (e.key === "Enter" && !this.emit("row-open", { key: item.key })) {
|
|
2346
|
+
e.preventDefault();
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
// With expansion, Enter expands the row, as a double-click does; F2 still edits in place.
|
|
2350
|
+
if (e.key === "Enter" && this.#expansion.peek()) {
|
|
2351
|
+
e.preventDefault();
|
|
2352
|
+
this.expand(item.key);
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
if (!this.#editable.peek())
|
|
2356
|
+
return;
|
|
2357
|
+
e.preventDefault();
|
|
2358
|
+
this.#startEdit(item.key, col);
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
case "Escape": {
|
|
2362
|
+
// Closes the active row's expansion, as Escape inside it does.
|
|
2363
|
+
if (item.kind !== "row" || item.key === undefined || !this.#open.peek().includes(item.key))
|
|
2364
|
+
return;
|
|
2365
|
+
e.preventDefault();
|
|
2366
|
+
void this.collapse(item.key);
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
case "F10":
|
|
2370
|
+
case "ContextMenu": {
|
|
2371
|
+
if (e.key === "F10" && !e.shiftKey)
|
|
2372
|
+
return;
|
|
2373
|
+
e.preventDefault();
|
|
2374
|
+
this.#openRowMenu(row, col);
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
default:
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
e.preventDefault();
|
|
2381
|
+
const target = Math.max(0, Math.min(count - 1, next.row));
|
|
2382
|
+
if (this.#selection.peek() && e.shiftKey && target !== row) {
|
|
2383
|
+
const destination = layout.at(target);
|
|
2384
|
+
if (destination.kind === "row" && destination.key !== undefined)
|
|
2385
|
+
this.#choose(destination.key, target, true, false);
|
|
2386
|
+
}
|
|
2387
|
+
this.#activate(target, Math.max(0, Math.min(columns - 1, next.col)));
|
|
2388
|
+
}
|
|
2389
|
+
#sortText(view) {
|
|
2390
|
+
const title = (f) => this.#collection?.type.field(f)?.title ?? f;
|
|
2391
|
+
if (!view.sort.length)
|
|
2392
|
+
return "Not sorted";
|
|
2393
|
+
return `Sorted by ${view.sort.map((k) => `${title(k.field)}, ${k.descending ? "descending" : "ascending"}`).join(", then ")}`;
|
|
2394
|
+
}
|
|
2395
|
+
/** The first column's indent and opener in a tree grid. */
|
|
2396
|
+
#treeLead(node) {
|
|
2397
|
+
const folder = node.folder !== false && node.key !== undefined;
|
|
2398
|
+
return html `<span class="aster-grid-tree" style=${`--level:${node.level - 1}`}>${folder
|
|
2399
|
+
? html `<button type="button" class="aster-grid-opener" tabindex="-1" aria-hidden="true" ?data-open=${node.open}
|
|
2400
|
+
@pointerdown=${(e) => e.stopPropagation()}
|
|
2401
|
+
@click=${(e) => {
|
|
2402
|
+
e.stopPropagation();
|
|
2403
|
+
void this.#tree?.toggle(node.key);
|
|
2404
|
+
}}><svg viewBox="0 0 10 10"><path d="M3.5 2 6.5 5l-3 3"/></svg></button>`
|
|
2405
|
+
: html `<span class="aster-grid-opener" aria-hidden="true"></span>`}</span>`;
|
|
2406
|
+
}
|
|
2407
|
+
/**
|
|
2408
|
+
* → on a closed folder opens it, on an open one goes to its first child;
|
|
2409
|
+
* ← on an open folder closes it, otherwise goes to the parent. Only in the
|
|
2410
|
+
* tree's column; elsewhere the arrows move between cells.
|
|
2411
|
+
*/
|
|
2412
|
+
#treeKey(e, row) {
|
|
2413
|
+
const tree = this.#tree;
|
|
2414
|
+
const item = untracked(() => this.#layout()).at(row);
|
|
2415
|
+
if (!tree || item.kind !== "row" || !item.node || item.key === undefined)
|
|
2416
|
+
return false;
|
|
2417
|
+
const node = item.node;
|
|
2418
|
+
e.preventDefault();
|
|
2419
|
+
if (e.key === "ArrowRight") {
|
|
2420
|
+
if (node.folder === false)
|
|
2421
|
+
return true;
|
|
2422
|
+
if (!node.open)
|
|
2423
|
+
void tree.expand(item.key);
|
|
2424
|
+
else
|
|
2425
|
+
this.#activate(row + 1, 0);
|
|
2426
|
+
return true;
|
|
2427
|
+
}
|
|
2428
|
+
if (node.open)
|
|
2429
|
+
tree.collapse(item.key);
|
|
2430
|
+
else if (node.parent !== undefined) {
|
|
2431
|
+
const at = untracked(() => tree.rows.value).findIndex((r) => r.key === node.parent);
|
|
2432
|
+
if (at >= 0)
|
|
2433
|
+
this.#activate(at, 0);
|
|
2434
|
+
}
|
|
2435
|
+
return true;
|
|
2436
|
+
}
|
|
2437
|
+
#filtersShown() {
|
|
2438
|
+
return this.#showFilters.peek() || Object.keys(this.#view.peek()?.filters ?? {}).length > 0;
|
|
2439
|
+
}
|
|
2440
|
+
#groupKey(e, row, col) {
|
|
2441
|
+
const grouping = this.#grouping;
|
|
2442
|
+
if (!grouping)
|
|
2443
|
+
return false;
|
|
2444
|
+
const rows = grouping.rows.peek();
|
|
2445
|
+
const item = rows[row];
|
|
2446
|
+
if (!item)
|
|
2447
|
+
return false;
|
|
2448
|
+
if (item.kind === "group" && (e.key === "Enter" || e.key === " ")) {
|
|
2449
|
+
e.preventDefault();
|
|
2450
|
+
if (item.error !== undefined)
|
|
2451
|
+
void grouping.retry(item.id).catch(() => { });
|
|
2452
|
+
else
|
|
2453
|
+
this.#toggleGroup(item.id);
|
|
2454
|
+
return true;
|
|
2455
|
+
}
|
|
2456
|
+
if (item.kind !== "group" && col !== 0)
|
|
2457
|
+
return false;
|
|
2458
|
+
e.preventDefault();
|
|
2459
|
+
if (e.key === "ArrowRight") {
|
|
2460
|
+
if (item.kind === "group") {
|
|
2461
|
+
if (!item.expanded)
|
|
2462
|
+
this.#toggleGroup(item.id);
|
|
2463
|
+
else if (rows[row + 1]?.parent === item.id)
|
|
2464
|
+
this.#activate(row + 1, 0);
|
|
2465
|
+
}
|
|
2466
|
+
else
|
|
2467
|
+
return false;
|
|
2468
|
+
}
|
|
2469
|
+
else if (e.key === "ArrowLeft") {
|
|
2470
|
+
if (item.kind === "group" && item.expanded)
|
|
2471
|
+
this.#toggleGroup(item.id);
|
|
2472
|
+
else if (item.parent !== undefined) {
|
|
2473
|
+
const parent = rows.findIndex((r) => r.kind === "group" && r.id === item.parent);
|
|
2474
|
+
if (parent >= 0)
|
|
2475
|
+
this.#activate(parent, 0);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
else
|
|
2479
|
+
return false;
|
|
2480
|
+
return true;
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Keys on the header row (Enter or Space sorts, with Shift adding to the
|
|
2484
|
+
* sort; Shift+F10 or the Menu key opens the column's menu) and the filter
|
|
2485
|
+
* row (Enter or F2 goes into the filter; Escape there comes back).
|
|
2486
|
+
*/
|
|
2487
|
+
#headKey(e, row, col, columns, count) {
|
|
2488
|
+
const field = this.#view.peek().columns.filter((c) => !c.hidden)[col]?.field;
|
|
2489
|
+
const move = (to) => {
|
|
2490
|
+
e.preventDefault();
|
|
2491
|
+
this.#active.value = { row: to.row, col: Math.max(0, Math.min(columns - 1, to.col)) };
|
|
2492
|
+
};
|
|
2493
|
+
switch (e.key) {
|
|
2494
|
+
case "ArrowLeft":
|
|
2495
|
+
return move({ row, col: col - 1 });
|
|
2496
|
+
case "ArrowRight":
|
|
2497
|
+
return move({ row, col: col + 1 });
|
|
2498
|
+
case "Home":
|
|
2499
|
+
return move({ row, col: 0 });
|
|
2500
|
+
case "End":
|
|
2501
|
+
return move({ row, col: columns - 1 });
|
|
2502
|
+
case "ArrowUp":
|
|
2503
|
+
return row === FILTER_ROW ? move({ row: HEADER, col }) : undefined;
|
|
2504
|
+
case "ArrowDown":
|
|
2505
|
+
if (row === HEADER && this.#filtersShown())
|
|
2506
|
+
return move({ row: FILTER_ROW, col });
|
|
2507
|
+
if (!count)
|
|
2508
|
+
return;
|
|
2509
|
+
e.preventDefault();
|
|
2510
|
+
this.#activate(0, col);
|
|
2511
|
+
return;
|
|
2512
|
+
case "Enter":
|
|
2513
|
+
case " ":
|
|
2514
|
+
case "F2":
|
|
2515
|
+
if (!field)
|
|
2516
|
+
return;
|
|
2517
|
+
e.preventDefault();
|
|
2518
|
+
if (row === FILTER_ROW) {
|
|
2519
|
+
this.querySelector(`#${CSS.escape(`${this.#id}-f-${col}`)}`)?.querySelector("input, [role='combobox']")?.focus();
|
|
2520
|
+
}
|
|
2521
|
+
else if (e.key !== "F2") {
|
|
2522
|
+
this.#sort(field, e.shiftKey);
|
|
2523
|
+
}
|
|
2524
|
+
return;
|
|
2525
|
+
case "F10":
|
|
2526
|
+
case "ContextMenu": {
|
|
2527
|
+
if ((e.key === "F10" && !e.shiftKey) || row !== HEADER || !field)
|
|
2528
|
+
return;
|
|
2529
|
+
e.preventDefault();
|
|
2530
|
+
const opener = this.querySelector(`.aster-grid-header[data-field="${CSS.escape(field)}"] .aster-grid-header-menu`);
|
|
2531
|
+
if (opener)
|
|
2532
|
+
this.#openMenu(field, opener, this.querySelector(".aster-grid-scroll") ?? opener);
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
/** Escape in a filter (its list closed) returns to the grid, on that filter's cell. */
|
|
2538
|
+
#filterKey(e) {
|
|
2539
|
+
if (e.key !== "Escape" || e.defaultPrevented)
|
|
2540
|
+
return;
|
|
2541
|
+
const cell = e.target.closest(".aster-grid-filter");
|
|
2542
|
+
if (!cell)
|
|
2543
|
+
return;
|
|
2544
|
+
e.preventDefault();
|
|
2545
|
+
this.#active.value = { row: FILTER_ROW, col: Number(cell.getAttribute("aria-colindex")) - 1 };
|
|
2546
|
+
this.querySelector(".aster-grid-scroll")?.focus();
|
|
2547
|
+
}
|
|
2548
|
+
/** Reveals the active cell; only an owned grid without shared selection chooses its row. */
|
|
2549
|
+
#activate(row, col) {
|
|
2550
|
+
this.#focusRequest++;
|
|
2551
|
+
this.#pendingFocus.value = undefined;
|
|
2552
|
+
this.#anchor = undefined;
|
|
2553
|
+
this.#active.value = { row, col };
|
|
2554
|
+
const item = untracked(() => this.#layout()).at(row);
|
|
2555
|
+
if (this.#grouping && item) {
|
|
2556
|
+
const header = item.kind === "group" ? item : this.#grouping.rows.peek().find((r) => r.kind === "group" && r.id === item.parent);
|
|
2557
|
+
this.#groupFocus.value = header ? { id: header.id, path: header.path } : undefined;
|
|
2558
|
+
}
|
|
2559
|
+
if (item.kind === "row" && item.key !== undefined) {
|
|
2560
|
+
this.#activeKey.value = item.key;
|
|
2561
|
+
if (this.#selectable.peek() && !this.#selection.peek() && !this.#external())
|
|
2562
|
+
this.#selected.value = item.key;
|
|
2563
|
+
}
|
|
2564
|
+
else
|
|
2565
|
+
this.#activeKey.value = undefined;
|
|
2566
|
+
const scroll = this.querySelector(".aster-grid-scroll");
|
|
2567
|
+
const head = this.querySelector(".aster-grid-headgroup");
|
|
2568
|
+
if (!scroll)
|
|
2569
|
+
return;
|
|
2570
|
+
const headHeight = head?.offsetHeight ?? 0;
|
|
2571
|
+
const top = this.#heights.offset(row);
|
|
2572
|
+
const bottom = this.#heights.offset(row + 1);
|
|
2573
|
+
if (top < scroll.scrollTop)
|
|
2574
|
+
scroll.scrollTop = top;
|
|
2575
|
+
else if (bottom + headHeight > scroll.scrollTop + scroll.clientHeight)
|
|
2576
|
+
scroll.scrollTop = bottom + headHeight - scroll.clientHeight;
|
|
2577
|
+
this.#top.value = scroll.scrollTop;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
/** A press that leaves focus in the editor, so it doesn't end the edit (a click doesn't focus a button everywhere). */
|
|
2581
|
+
function keepFocus(e) {
|
|
2582
|
+
e.preventDefault();
|
|
2583
|
+
}
|
|
2584
|
+
function and(a, b) {
|
|
2585
|
+
return a ? { op: "and", args: [a, b] } : b;
|
|
2586
|
+
}
|
|
2587
|
+
define("aster-grid", Grid);
|