@getaster/elements 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +4 -0
- package/README.md +13 -4
- package/bin/aster-theme-check.js +35 -4
- package/dist/base/element.d.ts +8 -1
- package/dist/base/element.js +7 -2
- package/dist/button/button.d.ts +17 -2
- package/dist/button/button.js +42 -8
- package/dist/composition/builtins.d.ts +623 -1
- package/dist/composition/builtins.js +232 -4
- package/dist/date/calendar.d.ts +3 -0
- package/dist/date/calendar.js +8 -2
- package/dist/date/date-field.d.ts +23 -3
- package/dist/date/date-field.js +77 -9
- package/dist/date/dates.d.ts +10 -2
- package/dist/date/dates.js +23 -3
- package/dist/dialog/config.d.ts +4 -0
- package/dist/dialog/config.js +8 -0
- package/dist/dialog/dialog.d.ts +34 -6
- package/dist/dialog/dialog.js +151 -14
- package/dist/field/bound-field.d.ts +17 -1
- package/dist/field/bound-field.js +68 -7
- package/dist/field/check.js +1 -0
- package/dist/field/field.js +1 -1
- package/dist/field/input.d.ts +1 -0
- package/dist/field/input.js +2 -2
- package/dist/field/number-field.js +2 -2
- package/dist/filter/filter-builder.d.ts +7 -1
- package/dist/filter/filter-builder.js +8 -1
- package/dist/form/controls.d.ts +40 -8
- package/dist/form/controls.js +58 -15
- package/dist/form/custom-field.d.ts +1 -0
- package/dist/form/custom-field.js +23 -0
- package/dist/form/features.d.ts +27 -0
- package/dist/form/features.js +52 -0
- package/dist/form/form.d.ts +67 -7
- package/dist/form/form.js +267 -24
- package/dist/form/policy.d.ts +29 -0
- package/dist/form/policy.js +16 -0
- package/dist/grid/appearance.d.ts +365 -0
- package/dist/grid/appearance.js +814 -0
- package/dist/grid/features.d.ts +38 -0
- package/dist/grid/features.js +78 -0
- package/dist/grid/grid.d.ts +216 -4
- package/dist/grid/grid.js +1853 -301
- package/dist/grid/policy.d.ts +58 -0
- package/dist/grid/policy.js +17 -0
- package/dist/grid/view.d.ts +32 -2
- package/dist/grid/view.js +89 -4
- package/dist/index.d.ts +14 -11
- package/dist/index.js +2 -1
- package/dist/menu/config.d.ts +7 -0
- package/dist/menu/config.js +44 -0
- package/dist/menu/menu.d.ts +23 -0
- package/dist/menu/menu.js +69 -17
- package/dist/picker/picker.js +8 -3
- package/dist/section/accordion.d.ts +26 -0
- package/dist/section/accordion.js +86 -8
- package/dist/section/policy.d.ts +8 -0
- package/dist/section/policy.js +17 -0
- package/dist/section/section.d.ts +30 -0
- package/dist/section/section.js +106 -3
- package/dist/select/choice-element.d.ts +12 -0
- package/dist/select/choice-element.js +36 -0
- package/dist/select/choice.d.ts +12 -2
- package/dist/select/choice.js +93 -28
- package/dist/select/select.d.ts +12 -0
- package/dist/select/select.js +37 -1
- package/dist/styles/contract.d.ts +14 -0
- package/dist/styles/contract.js +15 -0
- package/dist/switch/switch.d.ts +16 -0
- package/dist/switch/switch.js +56 -6
- package/dist/tree/navigator.d.ts +3 -0
- package/dist/tree/navigator.js +26 -2
- package/dist/window/chrome.d.ts +5 -1
- package/dist/window/chrome.js +4 -3
- package/dist/window/config.d.ts +71 -0
- package/dist/window/config.js +40 -0
- package/dist/window/window.d.ts +51 -13
- package/dist/window/window.js +233 -38
- package/dist/yaml.d.ts +19 -0
- package/dist/yaml.js +57 -0
- package/package.json +10 -4
- package/styles/aster-ui.css +233 -10
- package/styles/base.css +36 -0
- package/styles/contract.ts +16 -0
- package/styles/elements.css +197 -10
- package/styles/fonts/OFL-bricolage-grotesque.txt +93 -0
- package/styles/fonts/OFL-fraunces.txt +93 -0
- package/styles/fonts/bricolage-grotesque-latin-opsz-normal.woff2 +0 -0
- package/styles/fonts/fraunces-latin-full-normal.woff2 +0 -0
- package/styles/fonts.css +17 -0
- package/styles/theme-harbour.css +5 -0
- package/styles/theme-lamplight.css +5 -0
package/dist/grid/grid.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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";
|
|
2
|
+
import { isRelative, progressPolicy, FilterDraft, filterOperators as metadataOperators, describeCriteria, exportCsv, selectionPublication, EditModel, editPublication, keyText, Tree, } from "@getaster/data";
|
|
3
3
|
import { html, nothing } from "lit-html";
|
|
4
4
|
import { live } from "lit-html/directives/live.js";
|
|
5
5
|
import { styleMap } from "lit-html/directives/style-map.js";
|
|
@@ -20,15 +20,19 @@ import "../filter/filter-builder.js";
|
|
|
20
20
|
import "../dialog/dialog.js";
|
|
21
21
|
import { containsText, parseRange, rangeText, withDate } from "./filters.js";
|
|
22
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";
|
|
23
|
+
import { defaultView, effectiveColumns, effectiveFrozen, effectiveGroups, groupFields, freezeThrough, isNumeric, loadViews, moveColumn, naturalWidth, nudgeColumn, reconcile, removeSort, setFilter, setGroup, setHidden, setWidth, sortBy, sortDirection, storeViews, track, viewQuery, viewRefusals, criteriaFields, } from "./view.js";
|
|
24
|
+
import { policyPermits } from "./policy.js";
|
|
25
|
+
import { cellText, labelText, mappedValue, styleCell, styleRow, validateGridLabels, validateGridPresentation, } from "./appearance.js";
|
|
26
|
+
import { filterOperatorLabel } from "../rule/operators.js";
|
|
27
|
+
import { editMode as featureEditMode, multiSort as featureMultiSort, paged as featurePaged, selectionAppearance as featureSelectionAppearance, selectionColumn as featureSelectionColumn, validateFeatures, withFeature, withoutFeature, } from "./features.js";
|
|
24
28
|
const OVERSCAN = 400;
|
|
29
|
+
/** The leading selection column's track, kept in step with the stylesheet. */
|
|
30
|
+
const SELECT_WIDTH = 44;
|
|
25
31
|
/** A count that isn't exact: an estimate, or an uncounted collection's, which includes a page that may not exist. */
|
|
26
32
|
const atLeast = (c) => c.count.value === undefined || c.counted.value ? {}
|
|
27
33
|
: c.estimated.value ? { estimate: true }
|
|
28
34
|
: { atLeast: Math.max(0, c.count.value - c.pageSize) };
|
|
29
35
|
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
36
|
/** An estimate to two significant figures: its last digits aren't known. */
|
|
33
37
|
function roughly(n) {
|
|
34
38
|
if (n < 100)
|
|
@@ -79,7 +83,11 @@ export class Grid extends AsterElement {
|
|
|
79
83
|
#set = this.prop("");
|
|
80
84
|
#binding = this.prop(undefined);
|
|
81
85
|
#selection = this.prop(undefined);
|
|
82
|
-
#
|
|
86
|
+
#policy = this.prop({});
|
|
87
|
+
#features = this.prop([]);
|
|
88
|
+
/** Bumped on a policy change, so effects that rebuild the query follow it. */
|
|
89
|
+
#capabilitiesVersion = signal(0);
|
|
90
|
+
#driftDismissed = signal("");
|
|
83
91
|
#ownsCollection = false;
|
|
84
92
|
#bound;
|
|
85
93
|
#boundQuery;
|
|
@@ -90,17 +98,37 @@ export class Grid extends AsterElement {
|
|
|
90
98
|
#activeKey = signal(undefined);
|
|
91
99
|
#groupFocus = signal(undefined);
|
|
92
100
|
#focusRequest = 0;
|
|
101
|
+
#scrollRequest = 0;
|
|
93
102
|
#viewRequest = 0;
|
|
94
103
|
#pendingFocus = signal(undefined);
|
|
95
104
|
#focusBefore = null;
|
|
96
105
|
#groupToolFocus;
|
|
97
106
|
#base = this.prop(undefined);
|
|
98
107
|
#view = this.prop(undefined);
|
|
108
|
+
/** Entries the current view keeps but the derived query leaves out. */
|
|
109
|
+
#drift = computed(() => {
|
|
110
|
+
const view = this.#view.value;
|
|
111
|
+
if (!view)
|
|
112
|
+
return [];
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const entry of viewRefusals(view, this.#capabilities())) {
|
|
116
|
+
const id = `${entry.kind}:${entry.field}`;
|
|
117
|
+
if (seen.has(id))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(id);
|
|
120
|
+
const reason = this.#refusalReason(entry);
|
|
121
|
+
out.push({ ...entry, reason: reason ?? labelText(this.#labels.value, "driftFallbackReason", { field: this.#title(entry.field) }) });
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
});
|
|
99
125
|
#label = this.prop("");
|
|
100
126
|
#publish = this.prop("");
|
|
101
127
|
#persist = this.prop("");
|
|
102
128
|
#renderers = this.prop({});
|
|
103
129
|
#formatters = this.prop({});
|
|
130
|
+
#presentation = this.prop({});
|
|
131
|
+
#labels = this.prop({});
|
|
104
132
|
#lookups = this.prop({});
|
|
105
133
|
#summaries = this.prop([]);
|
|
106
134
|
#progressPolicy = this.prop({});
|
|
@@ -109,7 +137,19 @@ export class Grid extends AsterElement {
|
|
|
109
137
|
#summaryCollection;
|
|
110
138
|
#summaryContext;
|
|
111
139
|
#empty = this.prop(nothing);
|
|
112
|
-
#
|
|
140
|
+
#loading = this.prop(nothing);
|
|
141
|
+
#loadingMessage = this.prop("Loading…");
|
|
142
|
+
#errorView = this.prop(undefined);
|
|
143
|
+
#autoFetch = this.prop(true);
|
|
144
|
+
#pageSize = this.prop(100);
|
|
145
|
+
#keepPages = this.prop(undefined);
|
|
146
|
+
#exactCount = this.prop(false);
|
|
147
|
+
/** The page shown in paged mode, zero-based. */
|
|
148
|
+
#page = signal(0);
|
|
149
|
+
/** Set by refresh() or the first interaction; autoFetch false waits for it. */
|
|
150
|
+
#demand = signal(false);
|
|
151
|
+
#chrome = this.prop({});
|
|
152
|
+
#toolbarOptions = this.prop({});
|
|
113
153
|
#treeOptions = this.prop(undefined);
|
|
114
154
|
#rowControls = this.prop(undefined);
|
|
115
155
|
#expansion = this.prop(undefined);
|
|
@@ -123,6 +163,8 @@ export class Grid extends AsterElement {
|
|
|
123
163
|
#filterCalendar = new CalendarState();
|
|
124
164
|
#filterCalendarField = this.prop(undefined);
|
|
125
165
|
#rowMenu = this.prop(() => []);
|
|
166
|
+
#columnMenu = this.prop(() => []);
|
|
167
|
+
#filterOperators = this.prop({});
|
|
126
168
|
#rules = this.prop({});
|
|
127
169
|
#editing = signal(undefined);
|
|
128
170
|
#id = uid("aster-grid");
|
|
@@ -217,6 +259,27 @@ export class Grid extends AsterElement {
|
|
|
217
259
|
/** Text-only display formatting by field; never changes server sorting or filtering. */
|
|
218
260
|
get formatters() { return this.#formatters.value; }
|
|
219
261
|
set formatters(value) { this.#formatters.value = value; }
|
|
262
|
+
/**
|
|
263
|
+
* Declarative presentation: per-column titles, alignment, value maps and
|
|
264
|
+
* formats, row and cell classes, and hilites. Default `{}`, which keeps
|
|
265
|
+
* today's rendering. Renderers and formatters (JavaScript) outrank these
|
|
266
|
+
* plain-data display overrides for cell content.
|
|
267
|
+
*/
|
|
268
|
+
get presentation() { return this.#presentation.value; }
|
|
269
|
+
set presentation(value) {
|
|
270
|
+
validateGridPresentation(value, true);
|
|
271
|
+
this.#presentation.value = { ...value };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The grid's overridable strings. Every entry is optional; a missing entry
|
|
275
|
+
* keeps today's text, and `{name}` placeholders are filled by the grid.
|
|
276
|
+
* A stopgap before full i18n, not an i18n engine.
|
|
277
|
+
*/
|
|
278
|
+
get labels() { return this.#labels.value; }
|
|
279
|
+
set labels(value) {
|
|
280
|
+
validateGridLabels(value);
|
|
281
|
+
this.#labels.value = { ...value };
|
|
282
|
+
}
|
|
220
283
|
/** Exact overall totals and, when grouped, totals on each group header. */
|
|
221
284
|
get summaries() { return this.#summaries.value; }
|
|
222
285
|
set summaries(value) {
|
|
@@ -241,7 +304,7 @@ export class Grid extends AsterElement {
|
|
|
241
304
|
const selection = this.#selection.peek();
|
|
242
305
|
if (scope === "selection" && !selection)
|
|
243
306
|
throw new Error("Selection export requires a shared Selection");
|
|
244
|
-
const columns = view
|
|
307
|
+
const columns = this.#visibleColumns(view).map((c) => ({ field: c.field }));
|
|
245
308
|
return exportCsv(ctx, collection.query, columns, { ...csv, ...(scope === "selection" ? { keys: selection.keys.peek() } : {}) });
|
|
246
309
|
}
|
|
247
310
|
/** Foreign keys shown by a related record's field, by column. */
|
|
@@ -258,6 +321,33 @@ export class Grid extends AsterElement {
|
|
|
258
321
|
set rowMenu(v) {
|
|
259
322
|
this.#rowMenu.value = v;
|
|
260
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* The page's actions for a column, shown first in its menu. Read each time
|
|
326
|
+
* a menu opens. Default: no items.
|
|
327
|
+
*/
|
|
328
|
+
get columnMenu() {
|
|
329
|
+
return this.#columnMenu.value;
|
|
330
|
+
}
|
|
331
|
+
set columnMenu(v) {
|
|
332
|
+
this.#columnMenu.value = v;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Per-field operator allowlists for filtering, by column field. Each entry
|
|
336
|
+
* narrows the operators the metadata offers; it can never add one. Default
|
|
337
|
+
* `{}`: the metadata's operators.
|
|
338
|
+
*/
|
|
339
|
+
get filterOperators() {
|
|
340
|
+
return this.#filterOperators.value;
|
|
341
|
+
}
|
|
342
|
+
set filterOperators(v) {
|
|
343
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
344
|
+
throw new TypeError("filterOperators must be a record of operator lists");
|
|
345
|
+
for (const [field, list] of Object.entries(v)) {
|
|
346
|
+
if (!Array.isArray(list) || list.some((op) => typeof op !== "string" || !op))
|
|
347
|
+
throw new TypeError(`filterOperators.${field} must be a list of operators`);
|
|
348
|
+
}
|
|
349
|
+
this.#filterOperators.value = Object.fromEntries(Object.entries(v).map(([field, list]) => [field, [...list]]));
|
|
350
|
+
}
|
|
261
351
|
/**
|
|
262
352
|
* A tree grid: the first column shows each record's level with an opener;
|
|
263
353
|
* levels load as they open; a filter keeps the path to each match. Set it
|
|
@@ -267,7 +357,7 @@ export class Grid extends AsterElement {
|
|
|
267
357
|
return this.#treeOptions.value;
|
|
268
358
|
}
|
|
269
359
|
set tree(v) {
|
|
270
|
-
if (v && this.#
|
|
360
|
+
if (v && this.#binding.peek())
|
|
271
361
|
throw new Error("A shared collection grid supports flat rows");
|
|
272
362
|
if (v && this.#summaries.peek().length)
|
|
273
363
|
throw new Error("Tree summaries require an explicit query through the data summary API");
|
|
@@ -323,8 +413,9 @@ export class Grid extends AsterElement {
|
|
|
323
413
|
}
|
|
324
414
|
else if (expansion.limit !== undefined && open.length >= expansion.limit) {
|
|
325
415
|
const noun = expansion.noun ?? "row";
|
|
326
|
-
|
|
327
|
-
|
|
416
|
+
const text = this.#text("expansionLimit", { limit: expansion.limit, noun: expansion.limit === 1 ? noun : `${noun}s` });
|
|
417
|
+
announce(text);
|
|
418
|
+
this.#refused.value = text;
|
|
328
419
|
return false;
|
|
329
420
|
}
|
|
330
421
|
this.#refused.value = undefined;
|
|
@@ -397,7 +488,269 @@ export class Grid extends AsterElement {
|
|
|
397
488
|
return set ? this.#ctx.peek()?.store.get(set, key)?.entity : undefined;
|
|
398
489
|
}
|
|
399
490
|
#rowState(key, entity, expanded) {
|
|
400
|
-
return { key, entity, expanded, editable: this.#
|
|
491
|
+
return { key, entity, expanded, editable: this.#editEnabled() };
|
|
492
|
+
}
|
|
493
|
+
#selectionAllowed() {
|
|
494
|
+
return this.#policy.value.selectable ?? true;
|
|
495
|
+
}
|
|
496
|
+
/** Whether selection gets a leading checkbox column. */
|
|
497
|
+
#selectionColumnOn() {
|
|
498
|
+
return featureSelectionColumn(this.#features.value) && this.#selectionAllowed();
|
|
499
|
+
}
|
|
500
|
+
/** How selected records are highlighted. */
|
|
501
|
+
#selectionAppearance() {
|
|
502
|
+
return featureSelectionAppearance(this.#features.value);
|
|
503
|
+
}
|
|
504
|
+
#restoreSelectionFocus() {
|
|
505
|
+
if (!(document.activeElement instanceof Element) || !this.contains(document.activeElement) || !document.activeElement.closest(".aster-grid-selection, .aster-grid-select"))
|
|
506
|
+
return;
|
|
507
|
+
this.#selectionInside.value = undefined;
|
|
508
|
+
queueMicrotask(() => this.focus({ preventScroll: true }));
|
|
509
|
+
}
|
|
510
|
+
#title(field) {
|
|
511
|
+
return this.#presentation.value.columns?.[field]?.title ?? this.#collection?.type.field(field)?.title ?? field;
|
|
512
|
+
}
|
|
513
|
+
/** One label text, overridden or the shipped default, with placeholders filled. */
|
|
514
|
+
#text(key, values) {
|
|
515
|
+
return labelText(this.#labels.value, key, values);
|
|
516
|
+
}
|
|
517
|
+
/** A value the server hides from this person on this record: said, not left blank. */
|
|
518
|
+
#hiddenValue() {
|
|
519
|
+
return html `<span class="aster-hidden" title=${this.#text("hiddenTitle")}>${this.#text("hiddenValue")}</span>`;
|
|
520
|
+
}
|
|
521
|
+
/** Whether the page lets this field use a capability, before schema and structure. */
|
|
522
|
+
#policyAllows(capability, field, fallback) {
|
|
523
|
+
return policyPermits(this.#policy.value, capability, field, fallback);
|
|
524
|
+
}
|
|
525
|
+
/** Whether the column's own values sort; a lookup column needs its related display path. */
|
|
526
|
+
#structurallySortable(field) {
|
|
527
|
+
return !this.#lookups.value[field] || this.#sortPaths.value[field] !== undefined;
|
|
528
|
+
}
|
|
529
|
+
/** Why sorting this field is refused, in precedence order: schema, structure, policy. */
|
|
530
|
+
#sortRefusal(field) {
|
|
531
|
+
const title = this.#title(field);
|
|
532
|
+
const schema = this.#ctx.value?.schema.sets.get(this.set);
|
|
533
|
+
const navigation = this.#sortPaths.value[field]?.split("/")[0];
|
|
534
|
+
if (schema?.nonSortable.has(field) || (navigation !== undefined && schema?.nonSortable.has(navigation)))
|
|
535
|
+
return this.#text("sortServiceRefused", { field: title });
|
|
536
|
+
if (!this.#structurallySortable(field))
|
|
537
|
+
return this.#text("sortNavigationRefused", { field: title });
|
|
538
|
+
if (!this.#policyAllows("sortable", field, true))
|
|
539
|
+
return this.#text("sortPolicyRefused", { field: title });
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
/** Why filtering this field is refused, in precedence order: schema, policy. */
|
|
543
|
+
#filterRefusal(field) {
|
|
544
|
+
const title = this.#title(field);
|
|
545
|
+
if (this.#ctx.value?.schema.sets.get(this.set)?.nonFilterable.has(field))
|
|
546
|
+
return this.#text("filterServiceRefused", { field: title });
|
|
547
|
+
if (!this.#policyAllows("filterable", field, true))
|
|
548
|
+
return this.#text("filterPolicyRefused", { field: title });
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
/** The operators a field may use: the metadata's, narrowed by the page's allowlist. */
|
|
552
|
+
#operators(field) {
|
|
553
|
+
const offered = metadataOperators(this.#collection?.type.field(field));
|
|
554
|
+
const allowed = this.#filterOperators.value[field];
|
|
555
|
+
return allowed === undefined ? offered : offered.filter((op) => allowed.includes(op));
|
|
556
|
+
}
|
|
557
|
+
/** Why one filter operator is refused for a field. */
|
|
558
|
+
#filterOperatorRefusal(field, op) {
|
|
559
|
+
if (this.#operators(field).includes(op))
|
|
560
|
+
return undefined;
|
|
561
|
+
return this.#text("filterOperatorRefused", { field: this.#title(field), operator: filterOperatorLabel(op) });
|
|
562
|
+
}
|
|
563
|
+
/** Whether a parsed filter row criteria uses only operators the page allows. */
|
|
564
|
+
#criteriaOperatorRefusal(field, criteria) {
|
|
565
|
+
const map = (node) => {
|
|
566
|
+
if (!node || typeof node !== "object")
|
|
567
|
+
return undefined;
|
|
568
|
+
const value = node;
|
|
569
|
+
switch (value.op) {
|
|
570
|
+
case "and":
|
|
571
|
+
case "or":
|
|
572
|
+
return value.args?.map(map).find((reason) => reason !== undefined);
|
|
573
|
+
case "not":
|
|
574
|
+
return value.arg ? map(value.arg) : undefined;
|
|
575
|
+
default:
|
|
576
|
+
return value.op === undefined ? undefined : this.#filterOperatorRefusal(field, value.op);
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
return map(criteria);
|
|
580
|
+
}
|
|
581
|
+
/** Why a draft condition uses an operator the page refuses; related-record fields are metadata-only. */
|
|
582
|
+
#draftOperatorRefusal(model) {
|
|
583
|
+
const own = this.#collection?.type;
|
|
584
|
+
const walk = (node) => {
|
|
585
|
+
if (node.kind !== "condition") {
|
|
586
|
+
for (const child of node.children) {
|
|
587
|
+
const reason = walk(child);
|
|
588
|
+
if (reason)
|
|
589
|
+
return reason;
|
|
590
|
+
}
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
if (!node.field || model.typeFor(node.id) !== own)
|
|
594
|
+
return undefined;
|
|
595
|
+
return this.#filterOperatorRefusal(node.field, node.op);
|
|
596
|
+
};
|
|
597
|
+
return walk(model.root.peek());
|
|
598
|
+
}
|
|
599
|
+
/** Why grouping by this field is refused, in precedence order: structure, policy. */
|
|
600
|
+
#groupRefusal(field) {
|
|
601
|
+
const title = this.#title(field);
|
|
602
|
+
if (this.#tree || this.#external())
|
|
603
|
+
return this.#text("groupSharedRefused", { field: title });
|
|
604
|
+
const type = this.#collection?.type;
|
|
605
|
+
const entry = type?.field(field);
|
|
606
|
+
if (!entry)
|
|
607
|
+
return this.#text("groupUnknownRefused", { field: title });
|
|
608
|
+
if (entry.type === "Edm.String" && !entry.allowedValues && (entry.maxLength ?? 0) > 500)
|
|
609
|
+
return this.#text("groupLongRefused", { field: title });
|
|
610
|
+
if (!this.#policyAllows("groupable", field, true))
|
|
611
|
+
return this.#text("groupPolicyRefused", { field: title });
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
/** Whether grouping this field is permitted and possible. */
|
|
615
|
+
#groupable(field) {
|
|
616
|
+
return this.#groupRefusal(field) === undefined;
|
|
617
|
+
}
|
|
618
|
+
/** Why resizing this column is refused. */
|
|
619
|
+
#resizeRefusal(field) {
|
|
620
|
+
return this.#policyAllows("resizable", field, true) ? undefined : this.#text("resizePolicyRefused", { field: this.#title(field) });
|
|
621
|
+
}
|
|
622
|
+
/** Why moving this column is refused. */
|
|
623
|
+
#moveRefusal(field) {
|
|
624
|
+
return this.#policyAllows("movable", field, true) ? undefined : this.#text("movePolicyRefused", { field: this.#title(field) });
|
|
625
|
+
}
|
|
626
|
+
/** Why hiding this column is refused, in precedence order: the last visible column, policy. */
|
|
627
|
+
#hideRefusal(field) {
|
|
628
|
+
const view = this.#view.value;
|
|
629
|
+
if (!view)
|
|
630
|
+
return undefined;
|
|
631
|
+
const visible = this.#visibleColumns(view);
|
|
632
|
+
if (visible.length <= 1 && visible.some((column) => column.field === field))
|
|
633
|
+
return this.#text("hideLastRefused");
|
|
634
|
+
if (!this.#policyAllows("hideable", field, true))
|
|
635
|
+
return this.#text("hidePolicyRefused", { field: this.#title(field) });
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
/** Why freezing this column is refused. */
|
|
639
|
+
#freezeRefusal(field) {
|
|
640
|
+
return this.#policyAllows("freezable", field, true) ? undefined : this.#text("freezePolicyRefused", { field: this.#title(field) });
|
|
641
|
+
}
|
|
642
|
+
/** Why freezing through this column is refused: every frozen column must be freezable and one must stay unfrozen. */
|
|
643
|
+
#freezeThroughRefusal(field) {
|
|
644
|
+
const view = this.#view.value;
|
|
645
|
+
if (!view)
|
|
646
|
+
return undefined;
|
|
647
|
+
const visible = this.#visibleColumns(view);
|
|
648
|
+
const at = visible.findIndex((column) => column.field === field);
|
|
649
|
+
if (at < 0)
|
|
650
|
+
return this.#text("freezeUnknownRefused", { field: this.#title(field) });
|
|
651
|
+
if (at === visible.length - 1)
|
|
652
|
+
return this.#text("freezeLastRefused");
|
|
653
|
+
for (let i = 0; i <= at; i++) {
|
|
654
|
+
const reason = this.#freezeRefusal(visible[i].field);
|
|
655
|
+
if (reason)
|
|
656
|
+
return reason;
|
|
657
|
+
}
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
/** The columns shown, honoring a page that forbids hiding. */
|
|
661
|
+
#visibleColumns(view) {
|
|
662
|
+
return effectiveColumns(view, (field) => this.#policyAllows("hideable", field, true));
|
|
663
|
+
}
|
|
664
|
+
/** The frozen run in force, honoring a page that forbids freezing. */
|
|
665
|
+
#frozenCount(view, visible = this.#visibleColumns(view)) {
|
|
666
|
+
return effectiveFrozen(view, visible, (field) => this.#policyAllows("freezable", field, true));
|
|
667
|
+
}
|
|
668
|
+
/** The grouping levels in force, honoring structure and policy. */
|
|
669
|
+
#groupedFields(view = this.#view.value) {
|
|
670
|
+
return effectiveGroups(view, (field) => this.#groupable(field));
|
|
671
|
+
}
|
|
672
|
+
/** Whether the person may add another sort key. */
|
|
673
|
+
#multiSort() {
|
|
674
|
+
return featureMultiSort(this.#features.value);
|
|
675
|
+
}
|
|
676
|
+
/** Whether the grid loads a page at a time. */
|
|
677
|
+
#paged() {
|
|
678
|
+
return featurePaged(this.#features.value);
|
|
679
|
+
}
|
|
680
|
+
/** Paged mode applies to flat rows; grouped and tree grids keep loading by viewport. */
|
|
681
|
+
#pagedMode() {
|
|
682
|
+
return this.#paged() && !this.#tree && this.#groupedFields().length === 0;
|
|
683
|
+
}
|
|
684
|
+
/** The data-core options for an owned collection. Defaults keep the collection's own. */
|
|
685
|
+
#collectionOptions() {
|
|
686
|
+
return {
|
|
687
|
+
pageSize: this.#pageSize.value,
|
|
688
|
+
...(this.#keepPages.value === undefined ? {} : { keepPages: this.#keepPages.value }),
|
|
689
|
+
...(this.#exactCount.value ? { exactCount: true } : {}),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/** Whether the page lets this field be edited; schema and page rules still refuse on their own. */
|
|
693
|
+
#fieldEditable(field) {
|
|
694
|
+
return this.#policyAllows("editable", field, false);
|
|
695
|
+
}
|
|
696
|
+
#capabilities() {
|
|
697
|
+
return {
|
|
698
|
+
sortable: (field) => this.#sortRefusal(field) === undefined,
|
|
699
|
+
filterable: (field) => this.#filterRefusal(field) === undefined,
|
|
700
|
+
groupable: (field) => this.#groupRefusal(field) === undefined,
|
|
701
|
+
hideable: (field) => this.#hideRefusal(field) === undefined,
|
|
702
|
+
freezable: (field) => this.#freezeRefusal(field) === undefined,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/** The refusal for one drift entry, with the same precedence its affordance uses. */
|
|
706
|
+
#refusalReason(entry) {
|
|
707
|
+
switch (entry.kind) {
|
|
708
|
+
case "sort":
|
|
709
|
+
return this.#sortRefusal(entry.field);
|
|
710
|
+
case "filter":
|
|
711
|
+
case "criteria":
|
|
712
|
+
return this.#filterRefusal(entry.field);
|
|
713
|
+
case "group":
|
|
714
|
+
return this.#groupRefusal(entry.field);
|
|
715
|
+
case "hidden":
|
|
716
|
+
return this.#hideRefusal(entry.field);
|
|
717
|
+
case "frozen":
|
|
718
|
+
return this.#freezeRefusal(entry.field);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
#driftKey(entries) {
|
|
722
|
+
return entries.map((entry) => `${entry.kind}:${entry.field}`).sort().join("|");
|
|
723
|
+
}
|
|
724
|
+
#driftText(entries) {
|
|
725
|
+
const parts = entries.map((entry) => {
|
|
726
|
+
const title = this.#title(entry.field);
|
|
727
|
+
const what = (() => {
|
|
728
|
+
switch (entry.kind) {
|
|
729
|
+
case "sort":
|
|
730
|
+
return this.#text("driftSort", { field: title });
|
|
731
|
+
case "filter":
|
|
732
|
+
return this.#text("driftFilter", { field: title });
|
|
733
|
+
case "criteria":
|
|
734
|
+
return this.#text("driftCriteria", { field: title });
|
|
735
|
+
case "group":
|
|
736
|
+
return this.#text("driftGroup", { field: title });
|
|
737
|
+
case "hidden":
|
|
738
|
+
return this.#text("driftHidden", { field: title });
|
|
739
|
+
case "frozen":
|
|
740
|
+
return this.#text("driftFrozen", { field: title });
|
|
741
|
+
}
|
|
742
|
+
})();
|
|
743
|
+
return this.#text("driftEntry", { what, reason: entry.reason });
|
|
744
|
+
});
|
|
745
|
+
return this.#text("driftNote", { entries: parts.join(" ") });
|
|
746
|
+
}
|
|
747
|
+
/** Whether any visible column can be edited in place. */
|
|
748
|
+
#editEnabled() {
|
|
749
|
+
const view = this.#view.value;
|
|
750
|
+
const type = this.#collection?.type;
|
|
751
|
+
if (!view || !type)
|
|
752
|
+
return false;
|
|
753
|
+
return this.#visibleColumns(view).some((column) => !type.field(column.field)?.computed && this.#fieldEditable(column.field));
|
|
401
754
|
}
|
|
402
755
|
/** The row's floating controls, in a cell of their own past the last column. */
|
|
403
756
|
#controls(key, entity, expanded, activeRow, columns) {
|
|
@@ -487,12 +840,152 @@ export class Grid extends AsterElement {
|
|
|
487
840
|
scroll.scrollTop = this.#heights.offset(index);
|
|
488
841
|
return true;
|
|
489
842
|
}
|
|
490
|
-
/**
|
|
843
|
+
/**
|
|
844
|
+
* What the page allows, by capability and field. Policy can only restrict:
|
|
845
|
+
* schema and structure refuse first, and a page rule's read-only reason
|
|
846
|
+
* outranks a policy reason. It is host configuration, never part of the
|
|
847
|
+
* saved [[object:GridView]].
|
|
848
|
+
*/
|
|
849
|
+
get policy() {
|
|
850
|
+
return this.#policy.value;
|
|
851
|
+
}
|
|
852
|
+
set policy(value) {
|
|
853
|
+
const before = this.#selectionAllowed();
|
|
854
|
+
this.#policy.value = value;
|
|
855
|
+
this.#capabilitiesVersion.value++;
|
|
856
|
+
if (before && !this.#selectionAllowed())
|
|
857
|
+
this.#restoreSelectionFocus();
|
|
858
|
+
}
|
|
859
|
+
/** Sugar for `policy.sortable`: no sorting, an allowlist, or a predicate. */
|
|
860
|
+
get sortable() {
|
|
861
|
+
const value = this.#policy.value.sortable;
|
|
862
|
+
return typeof value === "function" ? true : value;
|
|
863
|
+
}
|
|
864
|
+
set sortable(value) {
|
|
865
|
+
const { sortable: _, ...rest } = this.#policy.peek();
|
|
866
|
+
this.policy = value === undefined ? rest : { ...rest, sortable: value };
|
|
867
|
+
}
|
|
868
|
+
/** Sugar for `policy.filterable`: no filtering, an allowlist, or a predicate. */
|
|
869
|
+
get filterable() {
|
|
870
|
+
const value = this.#policy.value.filterable;
|
|
871
|
+
return typeof value === "function" ? true : value;
|
|
872
|
+
}
|
|
873
|
+
set filterable(value) {
|
|
874
|
+
const { filterable: _, ...rest } = this.#policy.peek();
|
|
875
|
+
this.policy = value === undefined ? rest : { ...rest, filterable: value };
|
|
876
|
+
}
|
|
877
|
+
/** Sugar for `policy.groupable`: no grouping, an allowlist, or a predicate. */
|
|
878
|
+
get groupable() {
|
|
879
|
+
return this.#capabilityValue("groupable");
|
|
880
|
+
}
|
|
881
|
+
set groupable(value) {
|
|
882
|
+
this.#setCapability("groupable", value);
|
|
883
|
+
}
|
|
884
|
+
/** Sugar for `policy.resizable`: no column resizing, an allowlist, or a predicate. */
|
|
885
|
+
get resizable() {
|
|
886
|
+
return this.#capabilityValue("resizable");
|
|
887
|
+
}
|
|
888
|
+
set resizable(value) {
|
|
889
|
+
this.#setCapability("resizable", value);
|
|
890
|
+
}
|
|
891
|
+
/** Sugar for `policy.movable`: no column moving, an allowlist, or a predicate. */
|
|
892
|
+
get movable() {
|
|
893
|
+
return this.#capabilityValue("movable");
|
|
894
|
+
}
|
|
895
|
+
set movable(value) {
|
|
896
|
+
this.#setCapability("movable", value);
|
|
897
|
+
}
|
|
898
|
+
/** Sugar for `policy.hideable`: no column hiding, an allowlist, or a predicate. */
|
|
899
|
+
get hideable() {
|
|
900
|
+
return this.#capabilityValue("hideable");
|
|
901
|
+
}
|
|
902
|
+
set hideable(value) {
|
|
903
|
+
this.#setCapability("hideable", value);
|
|
904
|
+
}
|
|
905
|
+
/** Sugar for `policy.freezable`: no column freezing, an allowlist, or a predicate. */
|
|
906
|
+
get freezable() {
|
|
907
|
+
return this.#capabilityValue("freezable");
|
|
908
|
+
}
|
|
909
|
+
set freezable(value) {
|
|
910
|
+
this.#setCapability("freezable", value);
|
|
911
|
+
}
|
|
912
|
+
#capabilityValue(capability) {
|
|
913
|
+
const value = this.#policy.value[capability];
|
|
914
|
+
return typeof value === "function" ? true : value;
|
|
915
|
+
}
|
|
916
|
+
#setCapability(capability, value) {
|
|
917
|
+
const { [capability]: _, ...rest } = this.#policy.peek();
|
|
918
|
+
this.policy = value === undefined ? rest : { ...rest, [capability]: value };
|
|
919
|
+
}
|
|
920
|
+
/** Sugar for `policy.editable`: no editing, an allowlist, or a predicate. */
|
|
491
921
|
get editable() {
|
|
492
|
-
|
|
922
|
+
const value = this.#policy.value.editable;
|
|
923
|
+
return typeof value === "function" ? true : value ?? false;
|
|
924
|
+
}
|
|
925
|
+
set editable(value) {
|
|
926
|
+
this.policy = { ...this.#policy.peek(), editable: value };
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* How permitted capabilities behave, as [[type:GridFeature]] descriptors.
|
|
930
|
+
* The structured form is the contract; the matching properties are sugar.
|
|
931
|
+
* Default `[]`, which keeps today's grid.
|
|
932
|
+
*/
|
|
933
|
+
get features() {
|
|
934
|
+
return this.#features.value;
|
|
935
|
+
}
|
|
936
|
+
set features(value) {
|
|
937
|
+
validateFeatures(value);
|
|
938
|
+
this.#features.value = value.map((entry) => ({ ...entry }));
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Sugar for a `sort` feature's `multi`: whether Shift adds another sort key.
|
|
942
|
+
* `false` makes every sort action replace the sort. Default `true`.
|
|
943
|
+
*/
|
|
944
|
+
get multiSort() {
|
|
945
|
+
return this.#multiSort();
|
|
946
|
+
}
|
|
947
|
+
set multiSort(value) {
|
|
948
|
+
if (typeof value !== "boolean")
|
|
949
|
+
throw new TypeError("multiSort must be a boolean");
|
|
950
|
+
this.#features.value = value ? withoutFeature(this.#features.peek(), "sort") : withFeature(this.#features.peek(), { kind: "sort", multi: false });
|
|
951
|
+
}
|
|
952
|
+
/** Sugar for an `edit` feature's `mode`: a whole row (`"row"`, the default) or one cell (`"cell"`). */
|
|
953
|
+
get editMode() {
|
|
954
|
+
return featureEditMode(this.#features.value);
|
|
955
|
+
}
|
|
956
|
+
set editMode(value) {
|
|
957
|
+
if (value !== "row" && value !== "cell")
|
|
958
|
+
throw new TypeError('editMode must be "row" or "cell"');
|
|
959
|
+
this.#features.value = value === "row" ? withoutFeature(this.#features.peek(), "edit") : withFeature(this.#features.peek(), { kind: "edit", mode: "cell" });
|
|
960
|
+
}
|
|
961
|
+
/** Sugar for a `select` feature's `appearance`: how a selected record is highlighted. */
|
|
962
|
+
get selectionAppearance() {
|
|
963
|
+
return featureSelectionAppearance(this.#features.value);
|
|
964
|
+
}
|
|
965
|
+
set selectionAppearance(value) {
|
|
966
|
+
if (value !== "row" && value !== "cell")
|
|
967
|
+
throw new TypeError('selectionAppearance must be "row" or "cell"');
|
|
968
|
+
const select = this.#selectFeature();
|
|
969
|
+
if (value === "row" && select.column !== true)
|
|
970
|
+
this.#features.value = withoutFeature(this.#features.peek(), "select");
|
|
971
|
+
else
|
|
972
|
+
this.#features.value = withFeature(this.#features.peek(), { kind: "select", appearance: value, ...(select.column ? { column: true } : {}) });
|
|
973
|
+
}
|
|
974
|
+
/** Sugar for a `select` feature's `column`: whether selection gets a checkbox column. */
|
|
975
|
+
get selectionColumn() {
|
|
976
|
+
return featureSelectionColumn(this.#features.value);
|
|
977
|
+
}
|
|
978
|
+
set selectionColumn(value) {
|
|
979
|
+
if (typeof value !== "boolean")
|
|
980
|
+
throw new TypeError("selectionColumn must be a boolean");
|
|
981
|
+
const select = this.#selectFeature();
|
|
982
|
+
if (!value && select.appearance === undefined)
|
|
983
|
+
this.#features.value = withoutFeature(this.#features.peek(), "select");
|
|
984
|
+
else
|
|
985
|
+
this.#features.value = withFeature(this.#features.peek(), { kind: "select", ...(select.appearance ? { appearance: select.appearance } : {}), ...(value ? { column: true } : {}) });
|
|
493
986
|
}
|
|
494
|
-
|
|
495
|
-
this.#
|
|
987
|
+
#selectFeature() {
|
|
988
|
+
return this.#features.value.find((entry) => entry.kind === "select") ?? {};
|
|
496
989
|
}
|
|
497
990
|
/**
|
|
498
991
|
* Page rules for the cells of a row being edited. Each row has a child
|
|
@@ -513,6 +1006,268 @@ export class Grid extends AsterElement {
|
|
|
513
1006
|
set empty(v) {
|
|
514
1007
|
this.#empty.value = v;
|
|
515
1008
|
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Shown in the content area while the first page loads. Default: the
|
|
1011
|
+
* built-in presentation with [[attr:Grid.loadingMessage]].
|
|
1012
|
+
*/
|
|
1013
|
+
get loading() {
|
|
1014
|
+
return this.#loading.value;
|
|
1015
|
+
}
|
|
1016
|
+
set loading(v) {
|
|
1017
|
+
this.#loading.value = v;
|
|
1018
|
+
}
|
|
1019
|
+
/** The built-in loading presentation's text. Default `"Loading…"`. */
|
|
1020
|
+
get loadingMessage() {
|
|
1021
|
+
return this.#loadingMessage.value;
|
|
1022
|
+
}
|
|
1023
|
+
set loadingMessage(v) {
|
|
1024
|
+
if (typeof v !== "string")
|
|
1025
|
+
throw new TypeError("loadingMessage must be a string");
|
|
1026
|
+
this.#loadingMessage.value = v;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* The root collection failure, as a template: the page says what happened
|
|
1030
|
+
* and offers `retry`, which calls [[method:Grid.refresh]]. Default: a
|
|
1031
|
+
* "Couldn’t load rows." message with a Retry button.
|
|
1032
|
+
*/
|
|
1033
|
+
get error() {
|
|
1034
|
+
return this.#errorView.value;
|
|
1035
|
+
}
|
|
1036
|
+
set error(v) {
|
|
1037
|
+
if (v !== undefined && typeof v !== "function")
|
|
1038
|
+
throw new TypeError("error must be a function");
|
|
1039
|
+
this.#errorView.value = v;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Load on connect (the default) or wait for [[method:Grid.refresh]] or the
|
|
1043
|
+
* first interaction with the grid. Default `true`.
|
|
1044
|
+
*/
|
|
1045
|
+
get autoFetch() {
|
|
1046
|
+
return this.#autoFetch.value;
|
|
1047
|
+
}
|
|
1048
|
+
set autoFetch(v) {
|
|
1049
|
+
if (typeof v !== "boolean")
|
|
1050
|
+
throw new TypeError("autoFetch must be a boolean");
|
|
1051
|
+
this.#autoFetch.value = v;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Rows per load, in either loading mode. Default `100`. In paged mode it is
|
|
1055
|
+
* also the number of rows shown at once.
|
|
1056
|
+
*/
|
|
1057
|
+
get pageSize() {
|
|
1058
|
+
return this.#pageSize.value;
|
|
1059
|
+
}
|
|
1060
|
+
set pageSize(v) {
|
|
1061
|
+
if (!Number.isSafeInteger(v) || v < 1)
|
|
1062
|
+
throw new TypeError("pageSize must be a positive integer");
|
|
1063
|
+
this.#pageSize.value = v;
|
|
1064
|
+
}
|
|
1065
|
+
/** Loaded pages kept while scrolling; the data core's default (20) when unset. */
|
|
1066
|
+
get keepPages() {
|
|
1067
|
+
return this.#keepPages.value;
|
|
1068
|
+
}
|
|
1069
|
+
set keepPages(v) {
|
|
1070
|
+
if (v !== undefined && (!Number.isSafeInteger(v) || v < 1))
|
|
1071
|
+
throw new TypeError("keepPages must be a positive integer");
|
|
1072
|
+
this.#keepPages.value = v;
|
|
1073
|
+
}
|
|
1074
|
+
/** Ask the server for an exact total where it would estimate or omit one. Default `false`. */
|
|
1075
|
+
get exactCount() {
|
|
1076
|
+
return this.#exactCount.value;
|
|
1077
|
+
}
|
|
1078
|
+
set exactCount(v) {
|
|
1079
|
+
if (typeof v !== "boolean")
|
|
1080
|
+
throw new TypeError("exactCount must be a boolean");
|
|
1081
|
+
this.#exactCount.value = v;
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Reloads the rows the grid shows, keeping them visible until the answer
|
|
1085
|
+
* arrives. Rejects when the load fails; the error view or `collection.error`
|
|
1086
|
+
* reports it. With `autoFetch` false it also starts the first load.
|
|
1087
|
+
*/
|
|
1088
|
+
async refresh() {
|
|
1089
|
+
this.#demand.value = true;
|
|
1090
|
+
const grouping = this.#grouping;
|
|
1091
|
+
if (grouping)
|
|
1092
|
+
return grouping.root.refresh();
|
|
1093
|
+
const collection = this.#collection;
|
|
1094
|
+
if (!collection)
|
|
1095
|
+
return;
|
|
1096
|
+
if (this.#tree) {
|
|
1097
|
+
const top = this.#tree.level(undefined);
|
|
1098
|
+
await top.ensure(0, top.pageSize);
|
|
1099
|
+
return this.#tree.refresh();
|
|
1100
|
+
}
|
|
1101
|
+
return collection.refresh();
|
|
1102
|
+
}
|
|
1103
|
+
/** Whether autoFetch false is still waiting for refresh() or a first interaction. */
|
|
1104
|
+
#deferred() {
|
|
1105
|
+
return !this.#autoFetch.value && !this.#demand.value;
|
|
1106
|
+
}
|
|
1107
|
+
/** The collection whose loading/error state the content area reports. */
|
|
1108
|
+
#rootState() {
|
|
1109
|
+
return this.#grouping?.root ?? this.#collection;
|
|
1110
|
+
}
|
|
1111
|
+
/** Whether the content area is waiting for rows; a deferred grid is idle, not busy. */
|
|
1112
|
+
#busy(count) {
|
|
1113
|
+
const status = this.#rootState()?.status.value;
|
|
1114
|
+
if (status === "loading")
|
|
1115
|
+
return true;
|
|
1116
|
+
if (this.#deferred())
|
|
1117
|
+
return false;
|
|
1118
|
+
return count === undefined && status !== "error";
|
|
1119
|
+
}
|
|
1120
|
+
/** The root collection's failure, if any, as a banner with a retry. */
|
|
1121
|
+
#failureView() {
|
|
1122
|
+
const root = this.#rootState();
|
|
1123
|
+
if (!root || root.status.value !== "error")
|
|
1124
|
+
return nothing;
|
|
1125
|
+
const error = root.error.value;
|
|
1126
|
+
const retry = () => {
|
|
1127
|
+
this.focus({ preventScroll: true });
|
|
1128
|
+
void this.refresh().catch(() => { });
|
|
1129
|
+
};
|
|
1130
|
+
const custom = this.#errorView.value;
|
|
1131
|
+
return html `<div class="aster-grid-failure" role="alert">
|
|
1132
|
+
${custom
|
|
1133
|
+
? custom(error, retry)
|
|
1134
|
+
: html `<span>${this.#text("loadFailed")}</span><button type="button" class="aster-grid-tool" @click=${retry}>${this.#text("retry")}</button>`}
|
|
1135
|
+
</div>`;
|
|
1136
|
+
}
|
|
1137
|
+
/** The content-area loading presentation, used only while there is nothing to show. */
|
|
1138
|
+
#loadingView() {
|
|
1139
|
+
// The label bag may reword the built-in text; an explicit loadingMessage wins.
|
|
1140
|
+
const message = this.#loadingMessage.value === "Loading…" ? this.#text("loading") : this.#loadingMessage.value;
|
|
1141
|
+
return this.#loading.value !== nothing
|
|
1142
|
+
? this.#loading.value
|
|
1143
|
+
: html `<div class="aster-grid-loading" role="status">${message}</div>`;
|
|
1144
|
+
}
|
|
1145
|
+
/** What sits below the rows: loading while empty, the empty template once the query is known to match nothing. */
|
|
1146
|
+
#contentState(layout, count) {
|
|
1147
|
+
if (this.#rootState()?.status.value === "error")
|
|
1148
|
+
return nothing;
|
|
1149
|
+
if (this.#busy(count) && layout.keys.length === 0)
|
|
1150
|
+
return this.#loadingView();
|
|
1151
|
+
if (count !== 0)
|
|
1152
|
+
return nothing;
|
|
1153
|
+
const empty = this.#empty.value !== nothing ? this.#empty.value : this.#text("empty");
|
|
1154
|
+
return html `<div class="aster-grid-empty">${empty || nothing}</div>`;
|
|
1155
|
+
}
|
|
1156
|
+
/** The paging bar, in paged mode when chrome permits it. */
|
|
1157
|
+
#pagingBar(layout) {
|
|
1158
|
+
if (!this.#pagedMode() || this.#chrome.value.pagingBar === false)
|
|
1159
|
+
return nothing;
|
|
1160
|
+
const total = layout.total;
|
|
1161
|
+
const size = Math.max(1, this.#pageSize.value);
|
|
1162
|
+
const pages = total === undefined ? undefined : Math.max(1, Math.ceil(total / size));
|
|
1163
|
+
const page = pages === undefined ? Math.max(0, this.#page.value) : Math.min(Math.max(0, this.#page.value), pages - 1);
|
|
1164
|
+
const loaded = layout.keys.filter((key) => key !== undefined).length;
|
|
1165
|
+
const firstRow = page * size;
|
|
1166
|
+
const lastRow = total === undefined ? firstRow + loaded : Math.min(total, firstRow + size);
|
|
1167
|
+
const canNext = pages === undefined ? loaded >= size : page < pages - 1;
|
|
1168
|
+
const fmt = (n) => new Intl.NumberFormat("en-GB").format(n);
|
|
1169
|
+
const label = pages === undefined
|
|
1170
|
+
? this.#text("page", { page: fmt(page + 1) })
|
|
1171
|
+
: this.#text("pageOf", { page: fmt(page + 1), pages: fmt(pages) })
|
|
1172
|
+
+ (total ? this.#text("pageRows", { first: fmt(firstRow + 1), last: fmt(lastRow), total: fmt(total) }) : "");
|
|
1173
|
+
const button = (name, action, target, disabled, text) => html `<button type="button"
|
|
1174
|
+
class="aster-grid-tool" data-page-action=${action} aria-label=${name} ?disabled=${disabled} @click=${() => this.#goPage(target, action)}>${text}</button>`;
|
|
1175
|
+
return html `<div class="aster-grid-paging" role="group" aria-label=${this.#text("pages")}>
|
|
1176
|
+
${button(this.#text("firstPage"), "first", 0, page === 0, "«")}
|
|
1177
|
+
${button(this.#text("previousPage"), "previous", page - 1, page === 0, "‹")}
|
|
1178
|
+
<span class="aster-grid-page" role="status">${label}</span>
|
|
1179
|
+
${button(this.#text("nextPage"), "next", page + 1, !canNext, "›")}
|
|
1180
|
+
${button(this.#text("lastPage"), "last", pages === undefined ? page + 1 : pages - 1, pages === undefined ? !canNext : page >= pages - 1, "»")}
|
|
1181
|
+
</div>`;
|
|
1182
|
+
}
|
|
1183
|
+
/** Moves to a page, clamping to the known range; a new page starts at the top. */
|
|
1184
|
+
#goPage(page, action) {
|
|
1185
|
+
if (!this.#pagedMode())
|
|
1186
|
+
return;
|
|
1187
|
+
const total = this.#collection?.count.value;
|
|
1188
|
+
const size = Math.max(1, this.#pageSize.peek());
|
|
1189
|
+
const pages = total === undefined ? undefined : Math.max(1, Math.ceil(total / size));
|
|
1190
|
+
const next = pages === undefined ? Math.max(0, page) : Math.min(Math.max(0, page), pages - 1);
|
|
1191
|
+
if (action)
|
|
1192
|
+
this.#pagingFocus = action;
|
|
1193
|
+
if (next === this.#page.peek())
|
|
1194
|
+
return;
|
|
1195
|
+
this.#page.value = next;
|
|
1196
|
+
const scroll = this.querySelector(".aster-grid-scroll");
|
|
1197
|
+
if (scroll)
|
|
1198
|
+
scroll.scrollTop = 0;
|
|
1199
|
+
this.#top.value = 0;
|
|
1200
|
+
const { row, col } = this.#active.peek();
|
|
1201
|
+
if (row >= 0)
|
|
1202
|
+
this.#active.value = { row: 0, col };
|
|
1203
|
+
}
|
|
1204
|
+
#pagingFocus;
|
|
1205
|
+
/** Which chrome the grid shows. Default `{}`: the full toolbar, header, automatic filter row and paging bar. */
|
|
1206
|
+
get chrome() {
|
|
1207
|
+
return this.#chrome.value;
|
|
1208
|
+
}
|
|
1209
|
+
set chrome(v) {
|
|
1210
|
+
this.#setChrome(v);
|
|
1211
|
+
}
|
|
1212
|
+
/** The toolbar's controls. Default `{}`: every control shown. Object-only; use `showToolbar` to hide the toolbar. */
|
|
1213
|
+
get toolbar() {
|
|
1214
|
+
return this.#toolbarOptions.value;
|
|
1215
|
+
}
|
|
1216
|
+
set toolbar(v) {
|
|
1217
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
1218
|
+
throw new TypeError("toolbar must be an object; use showToolbar to hide the toolbar");
|
|
1219
|
+
this.#toolbarOptions.value = { ...v };
|
|
1220
|
+
}
|
|
1221
|
+
/** Sugar for `chrome.header`. Default `true`; false hides the header visually but keeps it for assistive technology. */
|
|
1222
|
+
get showHeader() {
|
|
1223
|
+
return this.#chrome.value.header !== false;
|
|
1224
|
+
}
|
|
1225
|
+
set showHeader(v) {
|
|
1226
|
+
this.#setChrome({ ...this.#chrome.peek(), header: v });
|
|
1227
|
+
}
|
|
1228
|
+
/** Sugar for `chrome.toolbar`. Default `true`. */
|
|
1229
|
+
get showToolbar() {
|
|
1230
|
+
return this.#chrome.value.toolbar !== false;
|
|
1231
|
+
}
|
|
1232
|
+
set showToolbar(v) {
|
|
1233
|
+
this.#setChrome({ ...this.#chrome.peek(), toolbar: v });
|
|
1234
|
+
}
|
|
1235
|
+
/** Sugar for `chrome.filterRow`: false removes the row, true forces it open, "auto" (the default) follows the view. */
|
|
1236
|
+
get showFilterRow() {
|
|
1237
|
+
return this.#chrome.value.filterRow !== false;
|
|
1238
|
+
}
|
|
1239
|
+
set showFilterRow(v) {
|
|
1240
|
+
this.#setChrome({ ...this.#chrome.peek(), filterRow: v });
|
|
1241
|
+
}
|
|
1242
|
+
/** Sugar for `chrome.headerMenu`. Default `true`. */
|
|
1243
|
+
get showHeaderMenu() {
|
|
1244
|
+
return this.#chrome.value.headerMenu !== false;
|
|
1245
|
+
}
|
|
1246
|
+
set showHeaderMenu(v) {
|
|
1247
|
+
this.#setChrome({ ...this.#chrome.peek(), headerMenu: v });
|
|
1248
|
+
}
|
|
1249
|
+
/** Sugar for `chrome.pagingBar`. Default `true`. */
|
|
1250
|
+
get showPagingBar() {
|
|
1251
|
+
return this.#chrome.value.pagingBar !== false;
|
|
1252
|
+
}
|
|
1253
|
+
set showPagingBar(v) {
|
|
1254
|
+
this.#setChrome({ ...this.#chrome.peek(), pagingBar: v });
|
|
1255
|
+
}
|
|
1256
|
+
#setChrome(v) {
|
|
1257
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
1258
|
+
throw new TypeError("chrome must be an object");
|
|
1259
|
+
if (v.filterRow !== undefined && v.filterRow !== "auto" && typeof v.filterRow !== "boolean")
|
|
1260
|
+
throw new TypeError('chrome.filterRow must be true, false or "auto"');
|
|
1261
|
+
this.#chrome.value = { ...v };
|
|
1262
|
+
// The active cell cannot stay on a row that is no longer reachable.
|
|
1263
|
+
const { row, col } = this.#active.peek();
|
|
1264
|
+
const header = this.#chrome.value.header !== false;
|
|
1265
|
+
const filters = this.#filtersShown();
|
|
1266
|
+
if (row === HEADER && !header)
|
|
1267
|
+
this.#active.value = { row: 0, col };
|
|
1268
|
+
else if (row === FILTER_ROW && !filters)
|
|
1269
|
+
this.#active.value = { row: header ? HEADER : 0, col };
|
|
1270
|
+
}
|
|
516
1271
|
get collection() {
|
|
517
1272
|
return this.#external() ?? this.#collection;
|
|
518
1273
|
}
|
|
@@ -525,21 +1280,21 @@ export class Grid extends AsterElement {
|
|
|
525
1280
|
}
|
|
526
1281
|
/** Shared selection; without collection, its collection supplies the binding. */
|
|
527
1282
|
get selection() { return this.#selection.value; }
|
|
528
|
-
/**
|
|
529
|
-
|
|
1283
|
+
/**
|
|
1284
|
+
* Allows this view to change selection; borrowed selection state remains
|
|
1285
|
+
* visible. Sugar for `policy.selectable`, default on.
|
|
1286
|
+
*/
|
|
1287
|
+
get selectable() { return this.#selectionAllowed(); }
|
|
530
1288
|
set selectable(value) {
|
|
531
|
-
|
|
532
|
-
this.#selectable.value = value;
|
|
533
|
-
if (restore) {
|
|
534
|
-
this.#selectionInside.value = undefined;
|
|
535
|
-
queueMicrotask(() => this.focus({ preventScroll: true }));
|
|
536
|
-
}
|
|
1289
|
+
this.policy = { ...this.#policy.peek(), selectable: value };
|
|
537
1290
|
}
|
|
538
1291
|
set selection(v) {
|
|
539
|
-
if (v &&
|
|
1292
|
+
if (v && groupFields(this.#view.peek()).length)
|
|
540
1293
|
throw new Error("A shared selection grid supports flat rows");
|
|
541
1294
|
if (v && this.#binding.peek() && v.collection !== this.#binding.peek())
|
|
542
1295
|
throw new Error("collection and selection must refer to the same collection");
|
|
1296
|
+
if (v && this.#treeOptions.peek() && v.collection.query.set !== this.set)
|
|
1297
|
+
throw new Error("A tree grid's selection must be over the same entity set");
|
|
543
1298
|
this.#selection.value = v;
|
|
544
1299
|
this.#selectionNote.value = "";
|
|
545
1300
|
this.#selectionAnchor = undefined;
|
|
@@ -557,6 +1312,7 @@ export class Grid extends AsterElement {
|
|
|
557
1312
|
const collection = this.#collection;
|
|
558
1313
|
if (!this.isConnected || !collection || this.#tree || groupFields(this.#view.peek()).length || !Number.isSafeInteger(index) || index < 0 || !Number.isSafeInteger(column))
|
|
559
1314
|
return false;
|
|
1315
|
+
this.#demand.value = true;
|
|
560
1316
|
const request = ++this.#focusRequest;
|
|
561
1317
|
const query = collection.query;
|
|
562
1318
|
this.#pendingFocus.value = index;
|
|
@@ -564,8 +1320,18 @@ export class Grid extends AsterElement {
|
|
|
564
1320
|
await collection.view(index, index + 1, [index, this.#active.peek().row]);
|
|
565
1321
|
if (!this.isConnected || request !== this.#focusRequest || this.#collection !== collection || collection.query !== query || collection.keys.peek()[index] === undefined)
|
|
566
1322
|
return false;
|
|
567
|
-
const
|
|
568
|
-
|
|
1323
|
+
const paged = this.#pagedMode();
|
|
1324
|
+
const size = Math.max(1, this.#pageSize.peek());
|
|
1325
|
+
const page = paged ? Math.floor(index / size) : 0;
|
|
1326
|
+
if (paged) {
|
|
1327
|
+
if (page !== this.#page.peek())
|
|
1328
|
+
this.#page.value = page;
|
|
1329
|
+
await collection.view(page * size, page * size + size, [index - page * size, 0]);
|
|
1330
|
+
if (!this.isConnected || request !== this.#focusRequest || this.#collection !== collection || collection.query !== query || collection.keys.peek()[index] === undefined)
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
const columns = this.#visibleColumns(this.#view.peek()).length;
|
|
1334
|
+
this.#activate(paged ? index - page * size : index, Math.max(0, Math.min(columns - 1, column)));
|
|
569
1335
|
const scroll = this.querySelector(".aster-grid-scroll");
|
|
570
1336
|
if (scroll)
|
|
571
1337
|
this.#top.value = scroll.scrollTop;
|
|
@@ -573,7 +1339,7 @@ export class Grid extends AsterElement {
|
|
|
573
1339
|
return true;
|
|
574
1340
|
}
|
|
575
1341
|
catch {
|
|
576
|
-
this.#selectionNote.value = "
|
|
1342
|
+
this.#selectionNote.value = this.#text("rowLoadFailed");
|
|
577
1343
|
return false;
|
|
578
1344
|
}
|
|
579
1345
|
finally {
|
|
@@ -586,6 +1352,132 @@ export class Grid extends AsterElement {
|
|
|
586
1352
|
const index = this.#collection?.keys.peek().indexOf(key) ?? -1;
|
|
587
1353
|
return index < 0 ? false : this.focusIndex(index, column);
|
|
588
1354
|
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Scrolls a flat row into view without moving focus or the active cell.
|
|
1357
|
+
* Loads its page if needed. False for grouped/tree grids or a bad position.
|
|
1358
|
+
*/
|
|
1359
|
+
async scrollToRow(index, align = "start") {
|
|
1360
|
+
return this.#scrollTo(index, undefined, align);
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Scrolls a flat row and visible column into view without moving focus or
|
|
1364
|
+
* the active cell. A frozen column needs no sideways scroll. False like
|
|
1365
|
+
* [[method:Grid.scrollToRow]].
|
|
1366
|
+
*/
|
|
1367
|
+
async scrollToCell(index, column, align = "start") {
|
|
1368
|
+
if (!Number.isSafeInteger(column) || column < 0)
|
|
1369
|
+
return false;
|
|
1370
|
+
return this.#scrollTo(index, column, align);
|
|
1371
|
+
}
|
|
1372
|
+
async #scrollTo(index, column, align) {
|
|
1373
|
+
if (align !== "start" && align !== "center" && align !== "end" && align !== "nearest")
|
|
1374
|
+
return false;
|
|
1375
|
+
const collection = this.#collection;
|
|
1376
|
+
if (!this.isConnected || !collection || this.#tree || this.#groupedFields().length || !Number.isSafeInteger(index) || index < 0)
|
|
1377
|
+
return false;
|
|
1378
|
+
const request = ++this.#scrollRequest;
|
|
1379
|
+
const query = collection.query;
|
|
1380
|
+
try {
|
|
1381
|
+
await collection.ensure(index, index + 1);
|
|
1382
|
+
if (!this.isConnected || request !== this.#scrollRequest || this.#collection !== collection || collection.query !== query || collection.keys.peek()[index] === undefined)
|
|
1383
|
+
return false;
|
|
1384
|
+
let local = index;
|
|
1385
|
+
if (this.#pagedMode()) {
|
|
1386
|
+
const size = Math.max(1, this.#pageSize.peek());
|
|
1387
|
+
const page = Math.floor(index / size);
|
|
1388
|
+
if (page !== this.#page.peek())
|
|
1389
|
+
this.#page.value = page;
|
|
1390
|
+
await collection.view(page * size, page * size + size);
|
|
1391
|
+
if (!this.isConnected || request !== this.#scrollRequest || this.#collection !== collection || collection.query !== query || collection.keys.peek()[index] === undefined)
|
|
1392
|
+
return false;
|
|
1393
|
+
local = index - page * size;
|
|
1394
|
+
}
|
|
1395
|
+
this.#scrollNow(local, column, align);
|
|
1396
|
+
// A rendered row changes an estimate into a measurement; place it again once that lands.
|
|
1397
|
+
await new Promise(requestAnimationFrame);
|
|
1398
|
+
if (!this.isConnected || request !== this.#scrollRequest || this.#collection !== collection || collection.query !== query)
|
|
1399
|
+
return false;
|
|
1400
|
+
this.#scrollNow(local, column, align);
|
|
1401
|
+
return true;
|
|
1402
|
+
}
|
|
1403
|
+
catch {
|
|
1404
|
+
this.#selectionNote.value = this.#text("rowLoadFailed");
|
|
1405
|
+
return false;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
/** Writes the scroll positions for one row (and optional visible column). */
|
|
1409
|
+
#scrollNow(row, column, align) {
|
|
1410
|
+
const scroll = this.querySelector(".aster-grid-scroll");
|
|
1411
|
+
if (!scroll)
|
|
1412
|
+
return;
|
|
1413
|
+
const layout = untracked(() => this.#layout());
|
|
1414
|
+
this.#heights.setKeys(layout.keys);
|
|
1415
|
+
const height = this.#heights.height(row);
|
|
1416
|
+
const top = this.#heights.offset(row);
|
|
1417
|
+
const inset = this.querySelector(".aster-grid-headgroup")?.offsetHeight ?? 0;
|
|
1418
|
+
let target = scroll.scrollTop;
|
|
1419
|
+
switch (align) {
|
|
1420
|
+
case "start":
|
|
1421
|
+
target = top - inset;
|
|
1422
|
+
break;
|
|
1423
|
+
case "center":
|
|
1424
|
+
target = top - (scroll.clientHeight - height) / 2;
|
|
1425
|
+
break;
|
|
1426
|
+
case "end":
|
|
1427
|
+
target = top - scroll.clientHeight + height;
|
|
1428
|
+
break;
|
|
1429
|
+
case "nearest":
|
|
1430
|
+
if (top < scroll.scrollTop + inset)
|
|
1431
|
+
target = top - inset;
|
|
1432
|
+
else if (top + height > scroll.scrollTop + scroll.clientHeight)
|
|
1433
|
+
target = top + height - scroll.clientHeight;
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
scroll.scrollTop = Math.max(0, Math.min(scroll.scrollHeight - scroll.clientHeight, target));
|
|
1437
|
+
if (column === undefined) {
|
|
1438
|
+
this.#top.value = scroll.scrollTop;
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
const view = this.#view.peek();
|
|
1442
|
+
if (!view)
|
|
1443
|
+
return;
|
|
1444
|
+
const columns = this.#visibleColumns(view);
|
|
1445
|
+
const col = Math.max(0, Math.min(columns.length - 1, column));
|
|
1446
|
+
if (col < this.#frozenCount(view, columns)) {
|
|
1447
|
+
this.#top.value = scroll.scrollTop;
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const header = this.querySelector(`#${CSS.escape(`${this.#id}-h-${col}`)}`);
|
|
1451
|
+
if (!header) {
|
|
1452
|
+
this.#top.value = scroll.scrollTop;
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
const box = scroll.getBoundingClientRect();
|
|
1456
|
+
const rect = header.getBoundingClientRect();
|
|
1457
|
+
const left = rect.left - box.left + scroll.scrollLeft;
|
|
1458
|
+
let sideways = scroll.scrollLeft;
|
|
1459
|
+
switch (align) {
|
|
1460
|
+
case "start":
|
|
1461
|
+
sideways = left;
|
|
1462
|
+
break;
|
|
1463
|
+
case "center":
|
|
1464
|
+
sideways = left - (box.width - rect.width) / 2;
|
|
1465
|
+
break;
|
|
1466
|
+
case "end":
|
|
1467
|
+
sideways = left - box.width + rect.width;
|
|
1468
|
+
break;
|
|
1469
|
+
case "nearest": {
|
|
1470
|
+
const at = left - scroll.scrollLeft;
|
|
1471
|
+
if (at < 0)
|
|
1472
|
+
sideways = left;
|
|
1473
|
+
else if (at + rect.width > box.width)
|
|
1474
|
+
sideways = left + rect.width - box.width;
|
|
1475
|
+
break;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
scroll.scrollLeft = Math.max(0, Math.min(scroll.scrollWidth - scroll.clientWidth, sideways));
|
|
1479
|
+
this.#top.value = scroll.scrollTop;
|
|
1480
|
+
}
|
|
589
1481
|
connectedCallback() {
|
|
590
1482
|
super.connectedCallback();
|
|
591
1483
|
// One observer for the scroll box and the rows. A new width can change
|
|
@@ -650,6 +1542,9 @@ export class Grid extends AsterElement {
|
|
|
650
1542
|
this.onDisconnect(effect(() => this.#toTop()));
|
|
651
1543
|
this.onDisconnect(effect(() => this.#keep()));
|
|
652
1544
|
this.onDisconnect(effect(() => this.#announce()));
|
|
1545
|
+
this.onDisconnect(effect(() => this.#driftAnnounce()));
|
|
1546
|
+
this.onDisconnect(effect(() => this.#viewChanged()));
|
|
1547
|
+
this.onDisconnect(effect(() => this.#selectionChanged()));
|
|
653
1548
|
this.listen(document, "pointerdown", (e) => {
|
|
654
1549
|
if (this.#panel.peek() && !e.target.closest(".aster-grid-panel, .aster-grid-tool"))
|
|
655
1550
|
this.#panel.value = undefined;
|
|
@@ -659,6 +1554,13 @@ export class Grid extends AsterElement {
|
|
|
659
1554
|
if (e.target === document.body)
|
|
660
1555
|
this.#panelKey(e);
|
|
661
1556
|
});
|
|
1557
|
+
// autoFetch false waits for refresh() or the person's first demand.
|
|
1558
|
+
const demand = () => {
|
|
1559
|
+
this.#demand.value = true;
|
|
1560
|
+
};
|
|
1561
|
+
this.listen(this, "pointerdown", demand);
|
|
1562
|
+
this.listen(this, "keydown", demand);
|
|
1563
|
+
this.listen(this, "scroll", demand, { capture: true });
|
|
662
1564
|
}
|
|
663
1565
|
focus(options) {
|
|
664
1566
|
this.querySelector(".aster-grid-scroll")?.focus(options);
|
|
@@ -672,11 +1574,12 @@ export class Grid extends AsterElement {
|
|
|
672
1574
|
if (!ctx || !collection || !view)
|
|
673
1575
|
return nothing;
|
|
674
1576
|
const type = collection.type;
|
|
675
|
-
const columns = view
|
|
1577
|
+
const columns = this.#visibleColumns(view);
|
|
676
1578
|
const lookups = this.#lookups.value;
|
|
677
1579
|
const fields = columns.map((c) => type.field(c.field));
|
|
678
1580
|
const numeric = columns.map((c, i) => isNumeric(fields[i]) && !lookups[c.field]);
|
|
679
|
-
const
|
|
1581
|
+
const presentation = this.#presentation.value;
|
|
1582
|
+
const titles = columns.map((c) => this.#title(c.field));
|
|
680
1583
|
const layout = this.#layout();
|
|
681
1584
|
const count = layout.keys.length === 0 && layout.total === undefined ? undefined : layout.keys.length;
|
|
682
1585
|
this.#heights.setKeys(layout.keys);
|
|
@@ -687,29 +1590,46 @@ export class Grid extends AsterElement {
|
|
|
687
1590
|
const selection = this.#selection.value;
|
|
688
1591
|
const selectedKeys = selection?.keys.value ?? [];
|
|
689
1592
|
const selected = this.#selected.value;
|
|
690
|
-
const
|
|
1593
|
+
const selectColumn = this.#selectionColumnOn();
|
|
1594
|
+
const selectionAppearance = this.#selectionAppearance();
|
|
1595
|
+
const totalColumns = columns.length + (selectColumn ? 1 : 0);
|
|
1596
|
+
const frozen = this.#frozenCount(view, columns);
|
|
1597
|
+
const frozenLeft = this.#frozenOffsets(columns, frozen, selectColumn ? SELECT_WIDTH : 0);
|
|
691
1598
|
const style = styleMap({
|
|
692
|
-
"--grid-columns": columns.map((c, i) => track(c, lookups[c.field] ? undefined : fields[i])).join(" "),
|
|
1599
|
+
"--grid-columns": [selectColumn ? `${SELECT_WIDTH}px` : undefined, ...columns.map((c, i) => track(c, lookups[c.field] ? undefined : fields[i]))].filter(Boolean).join(" "),
|
|
693
1600
|
...Object.fromEntries(frozenLeft.map((left, i) => [`--frozen-${i}`, `${left}px`])),
|
|
694
1601
|
});
|
|
695
1602
|
const rows = [];
|
|
696
1603
|
for (let i = start; i < end; i++)
|
|
697
1604
|
rows.push(i);
|
|
698
1605
|
const cellId = (row, col) => `${this.#id}-${row}-${col}`;
|
|
699
|
-
const filters = this.#
|
|
700
|
-
const
|
|
701
|
-
|
|
1606
|
+
const filters = this.#filtersShown();
|
|
1607
|
+
const chrome = this.#chrome.value;
|
|
1608
|
+
const toolbarOptions = this.#toolbarOptions.value;
|
|
1609
|
+
const headerShown = chrome.header !== false;
|
|
1610
|
+
const toolbarShown = chrome.toolbar !== false;
|
|
1611
|
+
// A hidden header or filter row stays in the DOM but must not own the active cell.
|
|
1612
|
+
const activeCell = active.row === HEADER && !headerShown ? { row: 0, col: active.col }
|
|
1613
|
+
: active.row === FILTER_ROW && !filters ? { row: headerShown ? HEADER : 0, col: active.col }
|
|
1614
|
+
: active;
|
|
1615
|
+
const activeId = activeCell.row === HEADER ? `${this.#id}-h-${activeCell.col}`
|
|
1616
|
+
: activeCell.row === FILTER_ROW ? `${this.#id}-f-${activeCell.col}`
|
|
702
1617
|
: !count ? undefined
|
|
703
|
-
: cellId(
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1618
|
+
: cellId(activeCell.row, activeCell.row < count && layout.at(activeCell.row).kind === "group" ? 0 : activeCell.col);
|
|
1619
|
+
const countId = `${this.#id}-total`;
|
|
1620
|
+
const countAnnounced = !toolbarShown || toolbarOptions.count !== false;
|
|
1621
|
+
const describedBy = countAnnounced && (layout.atLeast !== undefined || layout.estimate || !toolbarShown) ? countId : nothing;
|
|
1622
|
+
const frozenStyle = (i) => (i < frozen ? `left:var(--frozen-${i})` : nothing);
|
|
1623
|
+
return html `<div class="aster-grid-chrome">${toolbarShown ? this.#toolbar(view, layout) : html `<span class="aster-grid-total aster-sr-only" id=${countId} aria-live="polite">${this.#countText(layout)}</span>`}${this.#driftView()}${this.#failureView()}${this.#pagingBar(layout)}</div>
|
|
1624
|
+
<div class="aster-grid-scroll" role=${this.#groupedFields(view).length || this.#tree ? "treegrid" : "grid"} tabindex="0" aria-label=${this.#label.value}
|
|
1625
|
+
?data-fixed-columns=${columns.length > 0 && columns.every((c) => c.width !== undefined)} ?data-head-hidden=${!headerShown}
|
|
1626
|
+
data-selection-appearance=${selectionAppearance}
|
|
1627
|
+
aria-rowcount=${layout.atLeast !== undefined || layout.estimate ? -1
|
|
1628
|
+
: (layout.first !== undefined && layout.total !== undefined ? layout.total : count ?? 0) + (filters ? 2 : 1)} aria-colcount=${totalColumns + (this.#rowControls.value ? 1 : 0)}
|
|
1629
|
+
aria-describedby=${describedBy}
|
|
1630
|
+
aria-multiselectable=${this.#selectionAllowed() && selection?.multiple ? "true" : nothing}
|
|
1631
|
+
aria-activedescendant=${activeId && (activeCell.row < 0 || (activeCell.row >= start && activeCell.row < end)) ? activeId : nothing} aria-busy=${this.#busy(count) ? "true" : "false"}
|
|
1632
|
+
?data-draggable=${!!this.#treeOptions.value?.draggable && this.#editEnabled()}
|
|
713
1633
|
@scroll=${(e) => this.#scrolled(e.currentTarget)}
|
|
714
1634
|
@keydown=${(e) => this.#key(e, columns.length)}
|
|
715
1635
|
@click=${(e) => this.#click(e)}
|
|
@@ -720,22 +1640,24 @@ export class Grid extends AsterElement {
|
|
|
720
1640
|
@pointerdown=${(e) => this.#dragRow(e)}>
|
|
721
1641
|
<div class="aster-grid-table" style=${style}>
|
|
722
1642
|
<div class="aster-grid-headgroup">
|
|
723
|
-
${this.#treeOptions.value?.draggable && this.#
|
|
1643
|
+
${this.#treeOptions.value?.draggable && this.#editEnabled() ? html `<div class="aster-grid-root-drop">Top level</div>` : nothing}
|
|
724
1644
|
<div class="aster-grid-row aster-grid-head" role="row" aria-rowindex="1">
|
|
725
|
-
${
|
|
1645
|
+
${selectColumn ? this.#selectionHead(selection, selectedKeys) : nothing}
|
|
1646
|
+
${columns.map((c, i) => this.#header(c, fields[i], i, view, numeric[i], frozen, frozenStyle(i), activeCell.row === HEADER && activeCell.col === i, selectColumn ? 2 : 1))}
|
|
726
1647
|
</div>
|
|
727
1648
|
${filters
|
|
728
1649
|
? html `<div class="aster-grid-row aster-grid-filters" role="row" aria-rowindex="2" @keydown=${(e) => this.#filterKey(e)}>
|
|
729
|
-
${
|
|
730
|
-
|
|
731
|
-
|
|
1650
|
+
${selectColumn ? html `<div class="aster-grid-cell aster-grid-select" role="gridcell" aria-colindex="1" data-col="-1"></div>` : nothing}
|
|
1651
|
+
${columns.map((c, i) => html `<div class="aster-grid-cell aster-grid-filter" role="gridcell" aria-colindex=${i + (selectColumn ? 2 : 1)}
|
|
1652
|
+
data-col=${i} id=${`${this.#id}-f-${i}`} ?data-active=${activeCell.row === FILTER_ROW && activeCell.col === i}
|
|
1653
|
+
?data-frozen=${i < frozen} style=${frozenStyle(i)}>${this.#filter(c, fields[i], view)}</div>`)}
|
|
732
1654
|
</div>`
|
|
733
1655
|
: nothing}
|
|
734
1656
|
</div>
|
|
735
1657
|
<div class="aster-grid-spacer" style=${`height:${top}px`}></div>
|
|
736
1658
|
${each(rows.map((i) => ({ i, item: layout.at(i) })), ({ i, item }) => (item.kind === "group" ? item.id : (item.key ?? `?${i}`)), ({ i, item }) => {
|
|
737
1659
|
if (item.kind === "group")
|
|
738
|
-
return this.#groupRow(item, i, i + (filters ? 3 : 2),
|
|
1660
|
+
return this.#groupRow(item, i, i + (filters ? 3 : 2), totalColumns, view, activeCell.row === i);
|
|
739
1661
|
const key = item.key;
|
|
740
1662
|
const stored = key === undefined ? undefined : ctx.store.record(collection.query.set, key).value;
|
|
741
1663
|
const entity = stored?.entity;
|
|
@@ -744,41 +1666,66 @@ export class Grid extends AsterElement {
|
|
|
744
1666
|
const conflict = edit?.conflict.value;
|
|
745
1667
|
const formError = edit?.formError.value;
|
|
746
1668
|
// A read-only reason shows once for the row, not under every cell it covers.
|
|
1669
|
+
// Page-rule and schema reasons come first; a policy refusal adds its own.
|
|
1670
|
+
// Cell mode shows only the pressed cell's reasons.
|
|
1671
|
+
const rowMode = this.editMode === "row";
|
|
1672
|
+
const edited = columns.filter((c) => rowMode || c.field === editing?.field);
|
|
747
1673
|
const reasons = edit
|
|
748
|
-
? [...new Set(
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1674
|
+
? [...new Set([
|
|
1675
|
+
...edited.flatMap((c) => {
|
|
1676
|
+
const st = fields.some((f) => f?.name === c.field) ? edit.state(c.field).value : undefined;
|
|
1677
|
+
return st?.readOnly && st.reasons.readOnly && !fields.find((f) => f?.name === c.field)?.computed ? [st.reasons.readOnly] : [];
|
|
1678
|
+
}),
|
|
1679
|
+
// Page rules and schema reasons win: a field already read-only keeps its own reason.
|
|
1680
|
+
...edited.flatMap((c) => {
|
|
1681
|
+
if (this.#fieldEditable(c.field) || !this.#collection?.type.field(c.field) || edit.state(c.field).value.readOnly)
|
|
1682
|
+
return [];
|
|
1683
|
+
return [`Editing ${this.#title(c.field)} isn’t allowed on this page.`];
|
|
1684
|
+
}),
|
|
1685
|
+
])]
|
|
752
1686
|
: [];
|
|
753
1687
|
const shared = new Map(reasons.map((r, n) => [r, `${this.#id}-reason-${n}`]));
|
|
754
|
-
|
|
1688
|
+
const rowSelected = key !== undefined && (selection ? selectedKeys.includes(key) : key === selected);
|
|
1689
|
+
const selectedColumn = Math.max(0, Math.min(columns.length - 1, active.col));
|
|
1690
|
+
const rowStyle = entity && key !== undefined ? styleRow(presentation, { entity, key, index: i }) : undefined;
|
|
1691
|
+
return html `<div class=${["aster-grid-row", ...(rowStyle?.classes ?? [])].join(" ")} role="row" aria-rowindex=${i + (filters ? 3 : 2) + (layout.first ?? 0)} data-index=${i}
|
|
1692
|
+
data-tone=${rowStyle?.tone ?? nothing}
|
|
755
1693
|
aria-level=${item.node?.level ?? item.level ?? nothing}
|
|
756
1694
|
aria-posinset=${item.node?.position ?? item.position ?? nothing} aria-setsize=${item.node?.siblings ?? item.siblings ?? nothing}
|
|
757
1695
|
style=${item.node || item.level ? `--level:${(item.node?.level ?? item.level ?? 1) - 1}` : nothing}
|
|
758
1696
|
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 :
|
|
1697
|
+
data-key=${key ?? nothing} aria-selected=${this.#external() && !selection ? nothing : rowSelected ? "true" : "false"}
|
|
760
1698
|
?data-grouped-row=${item.level !== undefined}
|
|
761
|
-
?data-active-row=${
|
|
1699
|
+
?data-active-row=${activeCell.row === i} ?data-expanded=${key !== undefined && this.#open.value.includes(key)}
|
|
762
1700
|
?data-placeholder=${!entity} ?data-editing=${edit !== undefined} ?data-lifted=${key !== undefined && key === this.#lifted.value}
|
|
763
1701
|
@keydown=${edit ? (e) => this.#editKey(e) : nothing}
|
|
764
1702
|
@mousedown=${edit ? (e) => this.#focusPressed(e) : nothing}
|
|
765
1703
|
@focusout=${edit ? (e) => this.#editBlur(e) : nothing}>
|
|
1704
|
+
${selectColumn ? this.#selectionCell(key, i, selection, selectedKeys, entity, stored?.hidden) : nothing}
|
|
766
1705
|
${columns.map((c, col) => {
|
|
1706
|
+
const columnPresentation = presentation.columns?.[c.field];
|
|
1707
|
+
const value = entity?.[c.field];
|
|
1708
|
+
const mapped = mappedValue(columnPresentation, value);
|
|
1709
|
+
const cellStyle = entity && key !== undefined
|
|
1710
|
+
? styleCell(presentation, columnPresentation, { entity, key, index: i, field: c.field, value }, mapped)
|
|
1711
|
+
: undefined;
|
|
767
1712
|
const content = !entity ? html `<span class="aster-grid-skeleton"></span>`
|
|
768
|
-
: edit && !fields[col]?.computed
|
|
1713
|
+
: edit && this.#fieldEditable(c.field) && !fields[col]?.computed && (rowMode || c.field === editing.field)
|
|
769
1714
|
? 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
|
|
772
|
-
aria-colindex=${col + 1} ?data-active=${
|
|
773
|
-
?data-
|
|
1715
|
+
: stored?.hidden?.has(c.field) ? this.#hiddenValue() : this.#cell(c, fields[col], entity, key, ctx, mapped);
|
|
1716
|
+
return html `<div class=${["aster-grid-cell", ...(cellStyle?.classes ?? [])].join(" ")} role="gridcell" id=${cellId(i, col)} data-col=${col}
|
|
1717
|
+
aria-colindex=${col + (selectColumn ? 2 : 1)} ?data-active=${activeCell.row === i && activeCell.col === col}
|
|
1718
|
+
?data-selected-cell=${selectionAppearance === "cell" && rowSelected && col === selectedColumn}
|
|
1719
|
+
data-tone=${cellStyle?.tone ?? nothing} data-align=${columnPresentation?.align ?? nothing}
|
|
1720
|
+
?data-numeric=${numeric[col]} ?data-frozen=${col < frozen} style=${`grid-column:${col + (selectColumn ? 2 : 1)};${col < frozen ? `left:var(--frozen-${col})` : ""}`} data-label=${titles[col]}>
|
|
774
1721
|
${col === 0 && item.node ? this.#treeLead(item.node) : nothing}
|
|
775
|
-
${col === 0 && key !== undefined && selection && this.#
|
|
1722
|
+
${col === 0 && key !== undefined && selection && !selectColumn && this.#selectionAllowed() ? html `<div class="aster-grid-selection-value">
|
|
776
1723
|
${this.#selectionControl(key, entity, selectedKeys, stored?.hidden)}<div class="aster-grid-selection-content">${content}</div>
|
|
777
1724
|
</div>` : content}
|
|
778
1725
|
</div>`;
|
|
779
1726
|
})}
|
|
780
|
-
${key !== undefined && entity ? this.#controls(key, entity, this.#open.value.includes(key),
|
|
781
|
-
${key !== undefined && this.#open.value.includes(key) ? this.#expansionCell(key,
|
|
1727
|
+
${key !== undefined && entity ? this.#controls(key, entity, this.#open.value.includes(key), activeCell.row === i, totalColumns) : nothing}
|
|
1728
|
+
${key !== undefined && this.#open.value.includes(key) ? this.#expansionCell(key, totalColumns) : nothing}
|
|
782
1729
|
${conflict
|
|
783
1730
|
? 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
1731
|
<button type="button" @pointerdown=${keepFocus} @click=${() => edit.rebase()}>Keep mine</button>
|
|
@@ -791,7 +1738,7 @@ export class Grid extends AsterElement {
|
|
|
791
1738
|
})}
|
|
792
1739
|
<div class="aster-grid-spacer" style=${`height:${bottom}px`}></div>
|
|
793
1740
|
</div>
|
|
794
|
-
${
|
|
1741
|
+
${this.#contentState(layout, count)}
|
|
795
1742
|
</div>
|
|
796
1743
|
${this.#summaryView()}
|
|
797
1744
|
<aster-menu class="aster-grid-menu"></aster-menu>
|
|
@@ -856,12 +1803,21 @@ export class Grid extends AsterElement {
|
|
|
856
1803
|
else
|
|
857
1804
|
this.focus({ preventScroll: true });
|
|
858
1805
|
}
|
|
1806
|
+
const paging = this.#pagingFocus;
|
|
1807
|
+
if (paging) {
|
|
1808
|
+
this.#pagingFocus = undefined;
|
|
1809
|
+
const bar = this.querySelector(".aster-grid-paging");
|
|
1810
|
+
if (bar && !bar.contains(document.activeElement)) {
|
|
1811
|
+
const target = bar.querySelector(`[data-page-action="${paging}"]:not(:disabled)`) ?? bar.querySelector("button:not(:disabled)");
|
|
1812
|
+
target?.focus({ preventScroll: true });
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
859
1815
|
if (focusBefore && !focusBefore.isConnected && document.activeElement === document.body)
|
|
860
1816
|
this.focus({ preventScroll: true });
|
|
861
1817
|
}
|
|
862
1818
|
// ── toolbar and panels ──
|
|
863
1819
|
#filterContext() {
|
|
864
|
-
return JSON.stringify([this.set, this.#view.value, this.#base.value, this.#external()?.query]);
|
|
1820
|
+
return JSON.stringify([this.set, this.#view.value, this.#base.value, this.#external()?.query, this.#capabilitiesVersion.value]);
|
|
865
1821
|
}
|
|
866
1822
|
#filterStale() {
|
|
867
1823
|
return this.#filterOwner !== this.#ctx.value || this.#filterSnapshot !== this.#filterContext();
|
|
@@ -895,6 +1851,7 @@ export class Grid extends AsterElement {
|
|
|
895
1851
|
<p class="aster-hint">This expression combines with column filters and the page’s query constraints.</p>
|
|
896
1852
|
${this.#filterNote.value ? html `<p class="aster-error" role="status">${this.#filterNote.value}</p>` : nothing}
|
|
897
1853
|
<aster-filter-builder .model=${model} .readOnlyReason=${reason}
|
|
1854
|
+
.operators=${(field) => (field ? this.#operators(field.name) : [])}
|
|
898
1855
|
@apply=${(e) => { e.preventDefault(); void this.#applyAdvanced(model); }}
|
|
899
1856
|
@cancel=${() => this.querySelector(".aster-grid-advanced")?.close()}></aster-filter-builder>
|
|
900
1857
|
${stale ? html `<button type="button" class="aster-button" @click=${() => this.#openAdvanced(this.#filterOpener)}>Reload current expression</button>` : nothing}
|
|
@@ -913,107 +1870,222 @@ export class Grid extends AsterElement {
|
|
|
913
1870
|
this.#filterNote.value = "The current row could not be saved. Your filter draft is still available.";
|
|
914
1871
|
return;
|
|
915
1872
|
}
|
|
916
|
-
if (this.#filterStale()
|
|
1873
|
+
if (this.#filterStale())
|
|
1874
|
+
return;
|
|
1875
|
+
const refusedOperator = this.#draftOperatorRefusal(model);
|
|
1876
|
+
if (refusedOperator) {
|
|
1877
|
+
this.#filterNote.value = `${refusedOperator} Change that condition to apply the filter.`;
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
if (!model.apply())
|
|
917
1881
|
return;
|
|
918
1882
|
const view = this.#view.peek();
|
|
919
1883
|
if (!view)
|
|
920
1884
|
return;
|
|
921
1885
|
const { criteria: _, ...rest } = view;
|
|
922
1886
|
const criteria = model.applied.peek();
|
|
1887
|
+
const refused = criteria ? [...new Set(criteriaFields(criteria))].map((field) => [field, this.#filterRefusal(field)]).find(([, reason]) => reason !== undefined) : undefined;
|
|
1888
|
+
if (refused) {
|
|
1889
|
+
this.#filterNote.value = `${refused[1]} Remove that condition to apply the filter.`;
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
923
1892
|
this.#setView({ ...rest, ...(criteria ? { criteria } : {}) });
|
|
924
1893
|
this.querySelector(".aster-grid-advanced")?.close();
|
|
925
|
-
announce(criteria ? "
|
|
1894
|
+
announce(criteria ? this.#text("advancedApplied") : this.#text("advancedCleared"));
|
|
926
1895
|
}
|
|
927
1896
|
#query(set, view, base, paths = {}) {
|
|
928
1897
|
const checked = this.#criteriaCheck.value;
|
|
929
|
-
return viewQuery(set, checked?.valid === false ? { ...view, criteria: { op: "or", args: [] } } : view, base, paths);
|
|
1898
|
+
return viewQuery(set, checked?.valid === false ? { ...view, criteria: { op: "or", args: [] } } : view, base, paths, this.#capabilities());
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* A saved sort, filter or expression the page now refuses stays in the
|
|
1902
|
+
* view but not in the query. The note says which field and why; it lives
|
|
1903
|
+
* outside the toolbar so hiding the toolbar cannot silence it.
|
|
1904
|
+
*/
|
|
1905
|
+
#driftView() {
|
|
1906
|
+
const entries = this.#drift.value;
|
|
1907
|
+
if (!entries.length)
|
|
1908
|
+
return nothing;
|
|
1909
|
+
const key = this.#driftKey(entries);
|
|
1910
|
+
if (this.#driftDismissed.value === key)
|
|
1911
|
+
return nothing;
|
|
1912
|
+
// No live-region role: #driftAnnounce announces it once per set of entries.
|
|
1913
|
+
return html `<div class="aster-grid-drift">
|
|
1914
|
+
<span>${this.#driftText(entries)}</span>
|
|
1915
|
+
<button type="button" class="aster-grid-tool" @click=${() => {
|
|
1916
|
+
this.#driftDismissed.value = key;
|
|
1917
|
+
this.querySelector(".aster-grid-scroll")?.focus();
|
|
1918
|
+
}}>${this.#text("dismiss")}</button>
|
|
1919
|
+
</div>`;
|
|
1920
|
+
}
|
|
1921
|
+
#countText(layout) {
|
|
1922
|
+
const count = layout.total;
|
|
1923
|
+
const fmt = (n) => new Intl.NumberFormat("en-GB").format(n);
|
|
1924
|
+
if (count === undefined)
|
|
1925
|
+
return this.#deferred() ? this.#text("notLoaded") : this.#text("loading");
|
|
1926
|
+
if (layout.estimate)
|
|
1927
|
+
return this.#text("aboutRows", { count: fmt(roughly(count)) });
|
|
1928
|
+
if (layout.atLeast !== undefined)
|
|
1929
|
+
return this.#text("atLeastRows", { count: fmt(layout.atLeast) });
|
|
1930
|
+
const rows = count === 1 ? this.#text("row") : this.#text("rows", { count: fmt(count) });
|
|
1931
|
+
if (layout.groups === undefined)
|
|
1932
|
+
return rows;
|
|
1933
|
+
const groups = layout.groups === 1 ? this.#text("group") : this.#text("groups", { count: fmt(layout.groups) });
|
|
1934
|
+
return this.#text("groupedRows", { rows, groups });
|
|
930
1935
|
}
|
|
931
1936
|
#toolbar(view, layout) {
|
|
932
1937
|
const panel = this.#panel.value;
|
|
1938
|
+
const options = this.#toolbarOptions.value;
|
|
933
1939
|
const filters = Object.keys(view.filters).length;
|
|
934
|
-
const
|
|
935
|
-
const
|
|
936
|
-
const
|
|
937
|
-
const groupTitle = (field) => this.#
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
};
|
|
1940
|
+
const storedGroups = groupFields(view);
|
|
1941
|
+
const grouped = this.#groupedFields(view);
|
|
1942
|
+
const refusedGroups = storedGroups.filter((field) => !grouped.includes(field));
|
|
1943
|
+
const groupTitle = (field) => this.#title(field);
|
|
1944
|
+
const visible = this.#visibleColumns(view);
|
|
1945
|
+
const filterRefusal = visible.map((column) => this.#filterRefusal(column.field)).find((reason) => reason !== undefined);
|
|
1946
|
+
const anyFilterable = visible.some((column) => this.#filterRefusal(column.field) === undefined);
|
|
942
1947
|
const tool = (name, label) => html `<button type="button" class="aster-grid-tool"
|
|
943
1948
|
style=${anchorStyle(`${this.#id}-tool-${name}`)}
|
|
944
1949
|
aria-expanded=${panel === name ? "true" : "false"} aria-controls=${`${this.#id}-${name}`}
|
|
945
1950
|
@click=${() => (this.#panel.value = panel === name ? undefined : name)}>${label}</button>`;
|
|
946
|
-
return html `<div class="aster-grid-toolbar" role="toolbar" aria-label=${
|
|
1951
|
+
return html `<div class="aster-grid-toolbar" role="toolbar" aria-label=${this.#text("toolbarLabel", { label: this.#label.value })}
|
|
947
1952
|
@keydown=${(e) => this.#panelKey(e)}>
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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>
|
|
1953
|
+
${options.count === false ? nothing : html `<span class="aster-grid-total" id=${`${this.#id}-total`} aria-live="polite">${this.#countText(layout)}</span>`}
|
|
1954
|
+
${options.expandAll === false || !this.#tree ? nothing : html `<button type="button" class="aster-grid-tool" @click=${() => void this.#expandAll()}>${this.#text("expandAll")}</button>
|
|
1955
|
+
<button type="button" class="aster-grid-tool" @click=${() => this.#collapseAll()}>${this.#text("collapseAll")}</button>`}
|
|
1956
|
+
${options.sort === false ? nothing : tool("sort", this.#text("sort"))}
|
|
966
1957
|
${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"
|
|
968
|
-
<button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void this.#grouping?.retry(); }}
|
|
1958
|
+
${this.#grouping?.root.error.value !== undefined ? html `<span class="aster-grid-note aster-error" role="status">${this.#text("groupsFailed")}</span>
|
|
1959
|
+
<button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void this.#grouping?.retry(); }}>${this.#text("retryGroups")}</button>` : nothing}
|
|
969
1960
|
${this.#refused.value ? html `<span class="aster-grid-note aster-grid-refused">${this.#refused.value}</span>` : nothing}
|
|
970
1961
|
${this.#tree?.searching.value
|
|
971
|
-
? html `<span class="aster-grid-note" role="status"
|
|
1962
|
+
? html `<span class="aster-grid-note" role="status">${this.#text("treeSearching")}</span>`
|
|
972
1963
|
: this.#tree?.tooBroad.value
|
|
973
|
-
? html `<span class="aster-grid-note" role="status"
|
|
1964
|
+
? html `<span class="aster-grid-note" role="status">${this.#text("treeTooBroad")}</span>`
|
|
974
1965
|
: this.#tree?.error.value !== undefined
|
|
975
|
-
? html `<span class="aster-grid-note aster-error" role="status"
|
|
1966
|
+
? html `<span class="aster-grid-note aster-error" role="status">${this.#text("treeSearchFailed")}</span>`
|
|
976
1967
|
: nothing}
|
|
977
1968
|
${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 ? "
|
|
979
|
-
@click=${() => this.#setView(setGroup(view,
|
|
980
|
-
${grouped.length > 1 ? html `<button type="button" class="aster-grid-tool" data-group-action="earlier" aria-label=${
|
|
981
|
-
@click=${() => this.#reorderGroup(
|
|
982
|
-
<button type="button" class="aster-grid-tool" data-group-action="later" aria-label=${
|
|
983
|
-
@click=${() => this.#reorderGroup(
|
|
1969
|
+
<button type="button" class="aster-grid-tool" data-group-action="remove" aria-pressed="true" title=${grouped.length === 1 ? this.#text("stopGrouping") : this.#text("removeGrouping", { field: groupTitle(field) })}
|
|
1970
|
+
@click=${() => this.#setView(setGroup(view, storedGroups.filter((f) => f !== field)))}>${i ? this.#text("thenBy", { field: groupTitle(field) }) : this.#text("groupedBy", { field: groupTitle(field) })} ✕</button>
|
|
1971
|
+
${grouped.length > 1 ? html `<button type="button" class="aster-grid-tool" data-group-action="earlier" aria-label=${this.#text("moveGroupingEarlier", { field: groupTitle(field) })} ?disabled=${i === 0}
|
|
1972
|
+
@click=${() => this.#reorderGroup(field, -1)}>↑</button>
|
|
1973
|
+
<button type="button" class="aster-grid-tool" data-group-action="later" aria-label=${this.#text("moveGroupingLater", { field: groupTitle(field) })} ?disabled=${i === grouped.length - 1}
|
|
1974
|
+
@click=${() => this.#reorderGroup(field, 1)}>↓</button>` : nothing}
|
|
1975
|
+
</span>`)}
|
|
1976
|
+
${refusedGroups.map((field) => html `<span class="aster-grid-group-tools" data-group-field=${field} data-refused>
|
|
1977
|
+
<button type="button" class="aster-grid-tool" data-group-action="remove" title=${this.#text("removeRefusedGrouping", { reason: this.#groupRefusal(field) ?? this.#text("groupingRefused") })}
|
|
1978
|
+
@click=${() => this.#setView(setGroup(view, storedGroups.filter((f) => f !== field)))}>${this.#text("groupedBy", { field: groupTitle(field) })} ✕</button>
|
|
984
1979
|
</span>`)}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1980
|
+
${options.filters === false || this.#chrome.value.filterRow === false ? nothing : html `<button type="button" class="aster-grid-tool" aria-pressed=${this.#filtersShown() ? "true" : "false"}
|
|
1981
|
+
?disabled=${!anyFilterable && filters === 0} title=${!anyFilterable && filters === 0 ? (filterRefusal ?? this.#text("filterRefused")) : nothing}
|
|
1982
|
+
@click=${() => this.#toggleFilters(view)}>${filters ? this.#text("filtersActive", { count: filters }) : this.#text("filters")}</button>`}
|
|
1983
|
+
${options.advanced === false ? nothing : html `<button type="button" class="aster-grid-tool" aria-haspopup="dialog" aria-pressed=${view.criteria ? "true" : "false"}
|
|
1984
|
+
?disabled=${!anyFilterable} title=${!anyFilterable ? (filterRefusal ?? this.#text("filterRefused")) : nothing}
|
|
1985
|
+
@click=${(e) => this.#openAdvanced(e.currentTarget)}>${this.#text("advancedFilter")}</button>`}
|
|
1986
|
+
${!anyFilterable ? html `<span class="aster-sr-only">${filterRefusal ?? this.#text("filterRefused")}</span>` : nothing}
|
|
989
1987
|
${view.criteria ? html `<span class="aster-grid-expression" role="status">${this.#criteriaCheck.value?.valid === false
|
|
990
|
-
? "
|
|
1988
|
+
? this.#text("criteriaNeedsRepair")
|
|
991
1989
|
: describeCriteria(view.criteria, this.#collection.type)}</span>` : nothing}
|
|
992
|
-
${tool("columns", "
|
|
993
|
-
${this.#persist.value ? tool("views", "
|
|
994
|
-
${
|
|
1990
|
+
${options.columns === false ? nothing : tool("columns", this.#text("columns"))}
|
|
1991
|
+
${options.views === false || !this.#persist.value ? nothing : tool("views", this.#text("views"))}
|
|
1992
|
+
${options.extra ?? nothing}
|
|
1993
|
+
${panel === "sort" ? this.#sortPanel(view) : panel === "columns" ? this.#columnsPanel(view) : panel === "views" ? this.#viewsPanel(view) : nothing}
|
|
1994
|
+
</div>`;
|
|
1995
|
+
}
|
|
1996
|
+
#sortPanel(view) {
|
|
1997
|
+
const fieldTitle = (field) => this.#title(field);
|
|
1998
|
+
const offered = this.#visibleColumns(view)
|
|
1999
|
+
.filter((column) => this.#sortable(column.field))
|
|
2000
|
+
.map((column) => ({ field: column.field, offered: true, refusal: undefined }));
|
|
2001
|
+
const shown = new Set(offered.map((row) => row.field));
|
|
2002
|
+
// A sort on a hidden or now-refused column still shows, with its reason:
|
|
2003
|
+
// the panel never says nothing is sorted, and the stored key stays clearable.
|
|
2004
|
+
const rows = [...offered];
|
|
2005
|
+
for (const key of view.sort) {
|
|
2006
|
+
if (shown.has(key.field))
|
|
2007
|
+
continue;
|
|
2008
|
+
shown.add(key.field);
|
|
2009
|
+
rows.push({ field: key.field, offered: false, refusal: this.#sortRefusal(key.field) });
|
|
2010
|
+
}
|
|
2011
|
+
const active = view.sort.filter((key) => this.#sortable(key.field));
|
|
2012
|
+
const priority = new Map(active.map((key, i) => [key.field, i + 1]));
|
|
2013
|
+
const toggle = (field) => {
|
|
2014
|
+
const key = view.sort.find((sortKey) => sortKey.field === field);
|
|
2015
|
+
const sorted = key !== undefined;
|
|
2016
|
+
const descending = key?.descending === true;
|
|
2017
|
+
const name = fieldTitle(field);
|
|
2018
|
+
const action = sorted
|
|
2019
|
+
? this.#text("sortActionNow", { field: name, direction: descending ? this.#text("directionDescending") : this.#text("directionAscending") })
|
|
2020
|
+
: this.#text("sortAction", { field: name });
|
|
2021
|
+
return html `<button type="button" class="aster-grid-sort-direction" aria-label=${action} aria-pressed=${sorted ? "true" : "false"}
|
|
2022
|
+
title=${`${action}. ${sorted ? this.#text("sortReverse") : this.#text("sortAscending")}.`}
|
|
2023
|
+
@click=${() => this.#setView(sortDirection(view, field, sorted ? !descending : false, this.#multiSort()))}>
|
|
2024
|
+
<svg viewBox="0 0 10 10" aria-hidden="true"><path d=${sorted
|
|
2025
|
+
? descending ? "M2 3.5 5 6.5l3-3" : "M2 6.5 5 3.5l3 3"
|
|
2026
|
+
: "M2 3.5 5 .5l3 3M2 6.5 5 9.5l3-3"}/></svg>
|
|
2027
|
+
</button>`;
|
|
2028
|
+
};
|
|
2029
|
+
return html `<div class="aster-grid-panel aster-grid-sort-panel aster-popover" popover="manual" ${openWhen(true)}
|
|
2030
|
+
style=${placeStyle(`${this.#id}-tool-sort`)} id=${`${this.#id}-sort`} role="group" aria-label=${this.#text("sortRows")}
|
|
2031
|
+
@keydown=${(e) => this.#panelKey(e)}>
|
|
2032
|
+
<p class="aster-grid-panel-title">${this.#text("sortRows")}</p>
|
|
2033
|
+
${rows.length === 0
|
|
2034
|
+
? html `<p class="aster-grid-panel-note">${this.#text("noSortableColumns")}</p>`
|
|
2035
|
+
: html `<ul>
|
|
2036
|
+
${rows.map(({ field, offered, refusal }) => html `<li data-field=${field} ?data-refused=${refusal !== undefined}>
|
|
2037
|
+
<span class="aster-grid-sort-name"><span class="aster-grid-sort-title">${fieldTitle(field)}</span>
|
|
2038
|
+
${refusal ? html `<small class="aster-grid-sort-reason">${refusal}</small>` : nothing}</span>
|
|
2039
|
+
<span class="aster-grid-sort-actions">
|
|
2040
|
+
<small class="aster-grid-sort-priority" aria-hidden="true">${priority.has(field) && active.length > 1 ? priority.get(field) : ""}</small>
|
|
2041
|
+
${priority.has(field) && active.length > 1 ? html `<span class="aster-sr-only">${this.#text("sortPriority", { priority: priority.get(field) })}</span>` : nothing}
|
|
2042
|
+
${refusal ? nothing : toggle(field)}
|
|
2043
|
+
<button type="button" class="aster-grid-sort-clear" aria-label=${this.#text("clearFieldSorting", { field: fieldTitle(field) })} ?disabled=${!view.sort.some((key) => key.field === field)}
|
|
2044
|
+
@click=${(e) => {
|
|
2045
|
+
// Clearing disables this button, so focus a control that survives the render.
|
|
2046
|
+
if (offered)
|
|
2047
|
+
e.currentTarget.closest("li")?.querySelector(".aster-grid-sort-direction")?.focus();
|
|
2048
|
+
else
|
|
2049
|
+
this.querySelector(`.aster-grid-tool[aria-controls="${this.#id}-sort"]`)?.focus();
|
|
2050
|
+
this.#setView(removeSort(view, field));
|
|
2051
|
+
}}>
|
|
2052
|
+
<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2.5 2.5 7.5 7.5M7.5 2.5 2.5 7.5"/></svg>
|
|
2053
|
+
</button>
|
|
2054
|
+
</span>
|
|
2055
|
+
</li>`)}
|
|
2056
|
+
</ul>`}
|
|
2057
|
+
<button type="button" class="aster-grid-panel-action" ?disabled=${view.sort.length === 0}
|
|
2058
|
+
@click=${() => {
|
|
2059
|
+
const first = this.querySelector(".aster-grid-sort-panel .aster-grid-sort-direction");
|
|
2060
|
+
(first ?? this.querySelector(`.aster-grid-tool[aria-controls="${this.#id}-sort"]`))?.focus();
|
|
2061
|
+
this.#setView({ ...view, sort: [] });
|
|
2062
|
+
}}>${this.#text("clearSorting")}</button>
|
|
995
2063
|
</div>`;
|
|
996
2064
|
}
|
|
997
2065
|
#columnsPanel(view) {
|
|
998
|
-
const
|
|
999
|
-
const
|
|
1000
|
-
const visible = view.columns.filter((c) => !c.hidden).length;
|
|
2066
|
+
const title = (field) => this.#title(field);
|
|
2067
|
+
const shown = new Set(this.#visibleColumns(view).map((column) => column.field));
|
|
1001
2068
|
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
|
|
2069
|
+
id=${`${this.#id}-columns`} role="group" aria-label=${this.#text("columns")}
|
|
1003
2070
|
@keydown=${(e) => this.#panelKey(e)}>
|
|
1004
|
-
<p class="aster-grid-panel-title"
|
|
2071
|
+
<p class="aster-grid-panel-title">${this.#text("columns")}</p>
|
|
1005
2072
|
<ul>
|
|
1006
|
-
${view.columns.map((c, i) =>
|
|
1007
|
-
|
|
2073
|
+
${view.columns.map((c, i) => {
|
|
2074
|
+
const visible = shown.has(c.field);
|
|
2075
|
+
const hide = visible ? this.#hideRefusal(c.field) : undefined;
|
|
2076
|
+
const move = this.#moveRefusal(c.field);
|
|
2077
|
+
return html `<li>
|
|
2078
|
+
<label title=${hide ?? nothing}><input type="checkbox" .checked=${visible} ?disabled=${hide !== undefined}
|
|
1008
2079
|
@change=${(e) => this.#setView(setHidden(view, c.field, !e.target.checked))}>
|
|
1009
2080
|
${title(c.field)}</label>
|
|
1010
|
-
<button type="button" aria-label=${
|
|
2081
|
+
<button type="button" aria-label=${this.#text("moveFieldUp", { field: title(c.field) })} ?disabled=${i === 0 || move !== undefined} title=${move ?? nothing}
|
|
1011
2082
|
@click=${() => this.#setView(moveColumn(view, c.field, i - 1))}>↑</button>
|
|
1012
|
-
<button type="button" aria-label=${
|
|
2083
|
+
<button type="button" aria-label=${this.#text("moveFieldDown", { field: title(c.field) })} ?disabled=${i === view.columns.length - 1 || move !== undefined} title=${move ?? nothing}
|
|
1013
2084
|
@click=${() => this.#setView(moveColumn(view, c.field, i + 1))}>↓</button>
|
|
1014
|
-
</li
|
|
2085
|
+
</li>`;
|
|
2086
|
+
})}
|
|
1015
2087
|
</ul>
|
|
1016
|
-
<button type="button" class="aster-grid-panel-action" @click=${() => this.#reset()}
|
|
2088
|
+
<button type="button" class="aster-grid-panel-action" @click=${() => this.#reset()}>${this.#text("resetView")}</button>
|
|
1017
2089
|
</div>`;
|
|
1018
2090
|
}
|
|
1019
2091
|
#viewsPanel(view) {
|
|
@@ -1028,29 +2100,29 @@ export class Grid extends AsterElement {
|
|
|
1028
2100
|
this.#panel.value = undefined;
|
|
1029
2101
|
};
|
|
1030
2102
|
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
|
|
2103
|
+
id=${`${this.#id}-views`} role="group" aria-label=${this.#text("views")}
|
|
1032
2104
|
@keydown=${(e) => this.#panelKey(e)}>
|
|
1033
|
-
<p class="aster-grid-panel-title"
|
|
1034
|
-
${names.length === 0 ? html `<p class="aster-grid-panel-note"
|
|
2105
|
+
<p class="aster-grid-panel-title">${this.#text("savedViews")}</p>
|
|
2106
|
+
${names.length === 0 ? html `<p class="aster-grid-panel-note">${this.#text("noSavedViews")}</p>` : nothing}
|
|
1035
2107
|
<ul>
|
|
1036
2108
|
${names.map((n) => html `<li>
|
|
1037
2109
|
<button type="button" class="aster-grid-panel-view" @click=${() => {
|
|
1038
2110
|
this.#setView(reconcile(saved.named[n], this.#offered ?? view));
|
|
1039
2111
|
this.#panel.value = undefined;
|
|
1040
2112
|
}}>${n}</button>
|
|
1041
|
-
<button type="button" aria-label=${
|
|
2113
|
+
<button type="button" aria-label=${this.#text("deleteView", { name: n })} @click=${() => {
|
|
1042
2114
|
const { [n]: _, ...rest } = saved.named;
|
|
1043
2115
|
this.#saved.value = { ...saved, named: rest };
|
|
1044
|
-
}}
|
|
2116
|
+
}}>${this.#text("delete")}</button>
|
|
1045
2117
|
</li>`)}
|
|
1046
2118
|
</ul>
|
|
1047
2119
|
<form class="aster-grid-panel-save" @submit=${(e) => (e.preventDefault(), save(e.currentTarget))}>
|
|
1048
2120
|
<label class="aster-field" data-ring-owner>
|
|
1049
|
-
<input aria-label
|
|
2121
|
+
<input aria-label=${this.#text("viewName")} placeholder=${this.#text("viewNamePlaceholder")}>
|
|
1050
2122
|
</label>
|
|
1051
|
-
<button type="submit" class="aster-grid-panel-action"
|
|
2123
|
+
<button type="submit" class="aster-grid-panel-action">${this.#text("save")}</button>
|
|
1052
2124
|
</form>
|
|
1053
|
-
<button type="button" class="aster-grid-panel-action" @click=${() => this.#reset()}
|
|
2125
|
+
<button type="button" class="aster-grid-panel-action" @click=${() => this.#reset()}>${this.#text("resetView")}</button>
|
|
1054
2126
|
</div>`;
|
|
1055
2127
|
}
|
|
1056
2128
|
#reset() {
|
|
@@ -1068,98 +2140,126 @@ export class Grid extends AsterElement {
|
|
|
1068
2140
|
this.querySelector(`.aster-grid-tool[aria-controls="${this.#id}-${panel}"]`)?.focus();
|
|
1069
2141
|
}
|
|
1070
2142
|
// ── header ──
|
|
1071
|
-
#header(column, field, i, view, numeric, frozen, active) {
|
|
1072
|
-
|
|
1073
|
-
|
|
2143
|
+
#header(column, field, i, view, numeric, frozen, style, active, offset) {
|
|
2144
|
+
// Only an effective key counts as sorted: a stored key the page refuses
|
|
2145
|
+
// is reported by the drift note, not drawn as if the rows came sorted.
|
|
2146
|
+
const keys = view.sort.filter((key) => this.#sortable(key.field));
|
|
2147
|
+
const sort = keys.findIndex((key) => key.field === column.field);
|
|
2148
|
+
const key = sort >= 0 ? keys[sort] : undefined;
|
|
1074
2149
|
const ariaSort = sort === 0 ? (key.descending ? "descending" : "ascending") : nothing;
|
|
1075
|
-
const title =
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
2150
|
+
const title = this.#title(column.field);
|
|
2151
|
+
const align = this.#presentation.value.columns?.[column.field]?.align;
|
|
2152
|
+
const refusal = this.#sortRefusal(column.field);
|
|
2153
|
+
const move = this.#moveRefusal(column.field);
|
|
2154
|
+
const resize = this.#resizeRefusal(column.field);
|
|
2155
|
+
const multi = this.#multiSort();
|
|
2156
|
+
const hint = [
|
|
2157
|
+
refusal ?? this.#text("sortBy", { field: title }) + (multi ? this.#text("addToSort") : ""),
|
|
2158
|
+
move ?? this.#text("dragToMove"),
|
|
2159
|
+
resize ?? this.#text("dragToResize"),
|
|
2160
|
+
].join(". ");
|
|
2161
|
+
return html `<div class="aster-grid-cell aster-grid-header" role="columnheader" aria-colindex=${i + offset} id=${`${this.#id}-h-${i}`}
|
|
2162
|
+
data-col=${i} aria-sort=${ariaSort} ?data-numeric=${numeric} ?data-frozen=${i < frozen} ?data-active=${active} style=${style}
|
|
2163
|
+
data-align=${align ?? nothing}
|
|
2164
|
+
data-field=${column.field} data-sortable=${refusal ? "false" : "true"} ?data-movable=${!move} ?data-resizable=${!resize}>
|
|
2165
|
+
<button type="button" class="aster-grid-sort" tabindex="-1" title=${hint}
|
|
1081
2166
|
@pointerdown=${(e) => this.#dragColumn(e, column.field)}
|
|
1082
2167
|
@click=${(e) => this.#sort(column.field, e.shiftKey)}>
|
|
1083
2168
|
<span>${title}</span>
|
|
1084
2169
|
${key
|
|
1085
2170
|
? html `<svg class="aster-grid-sort-mark" viewBox="0 0 10 10" aria-hidden="true">
|
|
1086
2171
|
<path d=${key.descending ? "M2 3.5 5 6.5l3-3" : "M2 6.5 5 3.5l3 3"}/></svg>
|
|
1087
|
-
${
|
|
2172
|
+
${keys.length > 1 ? html `<small>${sort + 1}</small>` : nothing}`
|
|
1088
2173
|
: nothing}
|
|
1089
2174
|
</button>
|
|
1090
|
-
|
|
2175
|
+
${refusal ? html `<span class="aster-sr-only">${refusal}</span>` : nothing}
|
|
2176
|
+
${this.showHeaderMenu ? html `<button type="button" class="aster-grid-header-menu" tabindex="-1" aria-label=${this.#text("columnOptions", { field: title })}
|
|
1091
2177
|
@click=${(e) => this.#openMenu(column.field, e.currentTarget)}>
|
|
1092
2178
|
<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.8 5 6.8l3-3"/></svg>
|
|
1093
|
-
</button
|
|
1094
|
-
|
|
1095
|
-
@dblclick=${() => this.#setView(setWidth(this.#view.peek(), column.field, undefined))}></span
|
|
2179
|
+
</button>` : nothing}
|
|
2180
|
+
${resize ? nothing : html `<span class="aster-grid-resize" aria-hidden="true" @pointerdown=${(e) => this.#resize(e, column.field)}
|
|
2181
|
+
@dblclick=${() => this.#setView(setWidth(this.#view.peek(), column.field, undefined))}></span>`}
|
|
1096
2182
|
</div>`;
|
|
1097
2183
|
}
|
|
1098
2184
|
#menuItems(field) {
|
|
1099
2185
|
const view = this.#view.peek();
|
|
1100
|
-
const
|
|
1101
|
-
const title = type.field(field)?.title ?? field;
|
|
2186
|
+
const title = this.#title(field);
|
|
1102
2187
|
const sorted = view.sort.find((s) => s.field === field);
|
|
1103
|
-
const visible = view
|
|
2188
|
+
const visible = this.#visibleColumns(view);
|
|
2189
|
+
const frozen = this.#frozenCount(view, visible);
|
|
1104
2190
|
const at = visible.findIndex((c) => c.field === field);
|
|
1105
2191
|
const set = (v) => this.#setView(v);
|
|
1106
2192
|
const width = (delta) => set(setWidth(view, field, (visible[at]?.width ?? this.#measuredWidth(field)) + delta));
|
|
1107
|
-
const frozenHere = at + 1 ===
|
|
2193
|
+
const frozenHere = at + 1 === frozen;
|
|
1108
2194
|
const move = (to) => set(moveColumn(view, field, view.columns.findIndex((c) => c.field === visible[to]?.field)));
|
|
2195
|
+
const sortedFields = groupFields(view);
|
|
2196
|
+
const groupRefusal = this.#groupRefusal(field);
|
|
2197
|
+
const freezeRefusal = this.#freezeThroughRefusal(field);
|
|
2198
|
+
const moveRefusal = this.#moveRefusal(field);
|
|
2199
|
+
const resizeRefusal = this.#resizeRefusal(field);
|
|
2200
|
+
const hideRefusal = at < 0 ? undefined : this.#hideRefusal(field);
|
|
2201
|
+
const moveItem = (label, run, unavailable) => moveRefusal ? { label, run: () => { }, disabled: moveRefusal } : { label, run, ...(unavailable ? { disabled: unavailable } : {}) };
|
|
2202
|
+
const custom = this.#columnMenu.value(field);
|
|
1109
2203
|
return [
|
|
2204
|
+
...custom,
|
|
2205
|
+
...(custom.length ? [{ kind: "separator" }] : []),
|
|
1110
2206
|
...(this.#sortable(field)
|
|
1111
2207
|
? [
|
|
1112
2208
|
{
|
|
1113
2209
|
kind: "group",
|
|
1114
|
-
label: "
|
|
2210
|
+
label: this.#text("menuSort"),
|
|
1115
2211
|
items: [
|
|
1116
|
-
{ kind: "radio", label: "
|
|
1117
|
-
{ kind: "radio", label: "
|
|
1118
|
-
{ kind: "radio", label: "
|
|
1119
|
-
...(view.sort.length > 0 && !sorted ? [{ label: "
|
|
2212
|
+
{ kind: "radio", label: this.#text("ascending"), checked: !!sorted && !sorted.descending, run: () => set(sortDirection(view, field, false, false)) },
|
|
2213
|
+
{ kind: "radio", label: this.#text("descending"), checked: !!sorted?.descending, run: () => set(sortDirection(view, field, true, false)) },
|
|
2214
|
+
{ kind: "radio", label: this.#text("notSorted"), checked: !sorted, run: () => set(removeSort(view, field)) },
|
|
2215
|
+
...(this.#multiSort() && view.sort.length > 0 && !sorted ? [{ label: this.#text("addToSortMenu"), run: () => set(sortDirection(view, field, false, true)) }] : []),
|
|
1120
2216
|
],
|
|
1121
2217
|
},
|
|
1122
2218
|
{ kind: "separator" },
|
|
1123
2219
|
]
|
|
1124
2220
|
: []),
|
|
1125
|
-
|
|
1126
|
-
?
|
|
1127
|
-
: []),
|
|
1128
|
-
|
|
1129
|
-
?
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
}
|
|
1135
|
-
|
|
2221
|
+
groupRefusal
|
|
2222
|
+
? { label: this.#text("groupBy", { field: title }), run: () => { }, disabled: groupRefusal }
|
|
2223
|
+
: { kind: "check", label: this.#text("groupBy", { field: title }), checked: sortedFields.includes(field), run: () => set(setGroup(view, sortedFields.includes(field) ? sortedFields.filter((f) => f !== field) : [...sortedFields, field])) },
|
|
2224
|
+
freezeRefusal
|
|
2225
|
+
? { label: this.#text("freezeUpTo", { field: title }), run: () => { }, disabled: freezeRefusal }
|
|
2226
|
+
: {
|
|
2227
|
+
kind: "check",
|
|
2228
|
+
label: this.#text("freezeUpTo", { field: title }),
|
|
2229
|
+
checked: frozenHere,
|
|
2230
|
+
run: () => set(frozenHere ? { ...view, frozen: 0 } : freezeThrough(view, field, (f) => this.#measuredWidth(f))),
|
|
2231
|
+
},
|
|
1136
2232
|
{
|
|
1137
2233
|
kind: "submenu",
|
|
1138
|
-
label: "
|
|
2234
|
+
label: this.#text("menuMove"),
|
|
1139
2235
|
items: [
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
2236
|
+
moveItem(this.#text("moveLeft"), () => set(nudgeColumn(view, field, -1)), at > 0 ? undefined : this.#text("firstColumn")),
|
|
2237
|
+
moveItem(this.#text("moveRight"), () => set(nudgeColumn(view, field, 1)), at < visible.length - 1 ? undefined : this.#text("lastColumn")),
|
|
2238
|
+
moveItem(this.#text("moveToStart"), () => move(0), at > 0 ? undefined : this.#text("firstColumn")),
|
|
2239
|
+
moveItem(this.#text("moveToEnd"), () => move(visible.length - 1), at < visible.length - 1 ? undefined : this.#text("lastColumn")),
|
|
1144
2240
|
],
|
|
1145
2241
|
},
|
|
1146
2242
|
{
|
|
1147
2243
|
kind: "submenu",
|
|
1148
|
-
label: "
|
|
1149
|
-
items:
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
2244
|
+
label: this.#text("menuWidth"),
|
|
2245
|
+
items: resizeRefusal
|
|
2246
|
+
? [this.#text("wider"), this.#text("narrower"), this.#text("fitSpace")].map((label) => ({ label, run: () => { }, disabled: resizeRefusal }))
|
|
2247
|
+
: [
|
|
2248
|
+
{ label: this.#text("wider"), run: () => width(40) },
|
|
2249
|
+
{ label: this.#text("narrower"), run: () => width(-40) },
|
|
2250
|
+
{ label: this.#text("fitSpace"), run: () => set(setWidth(view, field, undefined)) },
|
|
2251
|
+
],
|
|
1154
2252
|
},
|
|
1155
2253
|
{ kind: "separator" },
|
|
1156
|
-
|
|
1157
|
-
|
|
2254
|
+
hideRefusal
|
|
2255
|
+
? { label: this.#text("hideField", { field: title }), run: () => { }, disabled: hideRefusal }
|
|
2256
|
+
: { label: this.#text("hideField", { field: title }), run: () => set(setHidden(view, field, true)) },
|
|
2257
|
+
{ label: this.#text("columnsEllipsis"), run: () => (this.#panel.value = "columns") },
|
|
1158
2258
|
];
|
|
1159
2259
|
}
|
|
1160
2260
|
#openMenu(field, anchor, returnTo = anchor) {
|
|
1161
|
-
const title = this.#
|
|
1162
|
-
this.querySelector(".aster-grid-menu")?.openFrom(anchor, this.#menuItems(field),
|
|
2261
|
+
const title = this.#title(field);
|
|
2262
|
+
this.querySelector(".aster-grid-menu")?.openFrom(anchor, this.#menuItems(field), this.#text("rowColumn", { field: title }), returnTo);
|
|
1163
2263
|
}
|
|
1164
2264
|
/**
|
|
1165
2265
|
* The row's menu: the page's actions for the record, then the column's
|
|
@@ -1167,7 +2267,7 @@ export class Grid extends AsterElement {
|
|
|
1167
2267
|
*/
|
|
1168
2268
|
#openRowMenu(row, col, point) {
|
|
1169
2269
|
const item = untracked(() => this.#layout()).at(row);
|
|
1170
|
-
const field = this.#view.peek()
|
|
2270
|
+
const field = this.#visibleColumns(this.#view.peek())[col]?.field;
|
|
1171
2271
|
if (!field)
|
|
1172
2272
|
return;
|
|
1173
2273
|
const title = this.#collection.type.field(field)?.title ?? field;
|
|
@@ -1177,20 +2277,39 @@ export class Grid extends AsterElement {
|
|
|
1177
2277
|
const items = [
|
|
1178
2278
|
...actions,
|
|
1179
2279
|
...(actions.length ? [{ kind: "separator" }] : []),
|
|
1180
|
-
{ kind: "submenu", label:
|
|
2280
|
+
{ kind: "submenu", label: this.#text("rowColumn", { field: title }), items: this.#menuItems(field) },
|
|
1181
2281
|
];
|
|
1182
2282
|
const cell = this.querySelector(`#${CSS.escape(`${this.#id}-${row}-${col}`)}`);
|
|
1183
2283
|
const rect = cell?.getBoundingClientRect();
|
|
1184
2284
|
const at = point ?? { x: rect?.left ?? 0, y: rect?.bottom ?? 0 };
|
|
1185
|
-
this.querySelector(".aster-grid-menu")?.openAt(at, items, key !== undefined ? "
|
|
2285
|
+
this.querySelector(".aster-grid-menu")?.openAt(at, items, key !== undefined ? this.#text("rowMenu") : this.#text("rowColumn", { field: title }), this.querySelector(".aster-grid-scroll"));
|
|
1186
2286
|
}
|
|
1187
2287
|
// ── filters ──
|
|
2288
|
+
/** A filter cell whose operator allowlist leaves no usable control. */
|
|
2289
|
+
#filterRefused(reason) {
|
|
2290
|
+
return html `<span class="aster-grid-filter-refused" data-filterable="false" title=${reason}>${this.#text("notFilterable")}<span class="aster-sr-only"> ${reason}</span></span>`;
|
|
2291
|
+
}
|
|
1188
2292
|
#filter(column, field, view) {
|
|
1189
2293
|
if (!field)
|
|
1190
2294
|
return nothing;
|
|
1191
|
-
const
|
|
2295
|
+
const refusal = this.#filterRefusal(column.field);
|
|
2296
|
+
if (refusal)
|
|
2297
|
+
return this.#filterRefused(refusal);
|
|
2298
|
+
const operators = this.#operators(column.field);
|
|
2299
|
+
const title = this.#title(column.field);
|
|
1192
2300
|
const current = view.filters[column.field];
|
|
1193
2301
|
const lookup = this.#lookups.value[column.field];
|
|
2302
|
+
if (lookup && !operators.includes("in"))
|
|
2303
|
+
return this.#filterRefused(this.#filterOperatorRefusal(column.field, "in"));
|
|
2304
|
+
if (!lookup && (field.type === "Edm.Boolean" || field.allowedValues) && !operators.includes("eq"))
|
|
2305
|
+
return this.#filterRefused(this.#filterOperatorRefusal(column.field, "eq"));
|
|
2306
|
+
const ranged = isNumeric(field) || field.type === "Edm.Date";
|
|
2307
|
+
if (!lookup && !(field.type === "Edm.Boolean" || field.allowedValues) && !ranged && !operators.includes("contains")) {
|
|
2308
|
+
return this.#filterRefused(this.#filterOperatorRefusal(column.field, "contains"));
|
|
2309
|
+
}
|
|
2310
|
+
if (!lookup && ranged && !["eq", "gt", "ge", "lt", "le"].some((op) => operators.includes(op))) {
|
|
2311
|
+
return this.#filterRefused(this.#filterOperatorRefusal(column.field, "eq"));
|
|
2312
|
+
}
|
|
1194
2313
|
const pick = (options, value, to) => html `<aster-choice .label=${`Filter ${title}`} .placeholder=${"Any"} .options=${[{ value: "\u0000any", label: "Any" }, ...options]}
|
|
1195
2314
|
.value=${value ?? "\u0000any"}
|
|
1196
2315
|
@change=${(e) => this.#setView(setFilter(this.#view.peek(), column.field, e.detail === "\u0000any" ? undefined : to(e.detail)))}></aster-choice>`;
|
|
@@ -1208,7 +2327,6 @@ export class Grid extends AsterElement {
|
|
|
1208
2327
|
return pick([{ value: true, label: "Yes" }, { value: false, label: "No" }], chosen, eq);
|
|
1209
2328
|
if (field.allowedValues)
|
|
1210
2329
|
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
2330
|
const text = this.#filterText.value[column.field] ?? (ranged ? rangeText(current) : current && "value" in current ? String(current.value) : "");
|
|
1213
2331
|
const error = this.#filterErrors.value[column.field];
|
|
1214
2332
|
const errorId = `${this.#id}-filter-${column.field}`;
|
|
@@ -1223,14 +2341,14 @@ export class Grid extends AsterElement {
|
|
|
1223
2341
|
this.querySelector(`#${CSS.escape(button)}`)?.focus();
|
|
1224
2342
|
};
|
|
1225
2343
|
return html `<label class="aster-field" data-ring-owner style=${date ? anchorStyle(anchor) : nothing}>
|
|
1226
|
-
<input .value=${live(text)} aria-label=${
|
|
2344
|
+
<input .value=${live(text)} aria-label=${this.#text("filterField", { field: title })} aria-invalid=${error ? "true" : nothing}
|
|
1227
2345
|
aria-describedby=${error ? errorId : nothing}
|
|
1228
2346
|
placeholder=${ranged ? (date ? ">=1 Oct 2026" : ">5 or 1..3") : "Contains"}
|
|
1229
2347
|
@input=${(e) => this.#typeFilter(column.field, field, e.target.value, ranged)}
|
|
1230
2348
|
@keydown=${(e) => e.key === "Enter" && this.#flushFilter(column.field)}
|
|
1231
2349
|
@blur=${() => this.#flushFilter(column.field)}>
|
|
1232
2350
|
${date
|
|
1233
|
-
? html `<button type="button" class="aster-date-button" id=${button} aria-label=${
|
|
2351
|
+
? html `<button type="button" class="aster-date-button" id=${button} aria-label=${this.#text("chooseFilterDate", { field: title })}
|
|
1234
2352
|
aria-haspopup="dialog" aria-expanded=${open ? "true" : "false"} aria-controls=${dialog}
|
|
1235
2353
|
@click=${(e) => {
|
|
1236
2354
|
e.preventDefault();
|
|
@@ -1277,6 +2395,9 @@ export class Grid extends AsterElement {
|
|
|
1277
2395
|
const parsed = ranged
|
|
1278
2396
|
? parseRange(field, text)
|
|
1279
2397
|
: { criteria: containsText(name, text) };
|
|
2398
|
+
const refused = parsed.criteria && !parsed.error ? this.#criteriaOperatorRefusal(name, parsed.criteria) : undefined;
|
|
2399
|
+
if (refused)
|
|
2400
|
+
parsed.error = refused;
|
|
1280
2401
|
const { [name]: _, ...errors } = this.#filterErrors.peek();
|
|
1281
2402
|
this.#filterErrors.value = parsed.error ? { ...errors, [name]: parsed.error } : errors;
|
|
1282
2403
|
if (!parsed.error)
|
|
@@ -1286,12 +2407,17 @@ export class Grid extends AsterElement {
|
|
|
1286
2407
|
this.#filterTimers.set(name, setTimeout(apply, FILTER_DELAY));
|
|
1287
2408
|
}
|
|
1288
2409
|
#toggleFilters(view) {
|
|
1289
|
-
|
|
2410
|
+
if (this.#chrome.peek().filterRow === false)
|
|
2411
|
+
return;
|
|
2412
|
+
const showing = this.#filtersShown();
|
|
1290
2413
|
if (showing && Object.keys(view.filters).length > 0) {
|
|
1291
2414
|
this.#filterText.value = {};
|
|
1292
2415
|
this.#filterErrors.value = {};
|
|
1293
2416
|
this.#setView({ ...view, filters: {} });
|
|
1294
2417
|
}
|
|
2418
|
+
// A forced row stays open; the button still clears its filters.
|
|
2419
|
+
if (this.#chrome.peek().filterRow === true)
|
|
2420
|
+
return;
|
|
1295
2421
|
this.#showFilters.value = !showing;
|
|
1296
2422
|
}
|
|
1297
2423
|
#optionsFor(field, lookup) {
|
|
@@ -1307,7 +2433,7 @@ export class Grid extends AsterElement {
|
|
|
1307
2433
|
return options;
|
|
1308
2434
|
}
|
|
1309
2435
|
// ── cells ──
|
|
1310
|
-
#cell(column, field, entity, key, ctx) {
|
|
2436
|
+
#cell(column, field, entity, key, ctx, mapped) {
|
|
1311
2437
|
const render = this.#renderers.value[column.field];
|
|
1312
2438
|
if (render)
|
|
1313
2439
|
return render(entity, key);
|
|
@@ -1315,27 +2441,32 @@ export class Grid extends AsterElement {
|
|
|
1315
2441
|
const formatter = this.#formatters.value[column.field];
|
|
1316
2442
|
if (formatter)
|
|
1317
2443
|
return formatter(value, entity, field);
|
|
2444
|
+
if (mapped)
|
|
2445
|
+
return mapped.label;
|
|
1318
2446
|
const lookup = this.#lookups.value[column.field];
|
|
1319
2447
|
if (lookup && value !== null && value !== undefined) {
|
|
1320
2448
|
const related = ctx.lookup(lookup.set, keyText([value])).value;
|
|
1321
2449
|
return related ? String(related[lookup.display] ?? "") : html `<span class="aster-grid-skeleton"></span>`;
|
|
1322
2450
|
}
|
|
1323
|
-
|
|
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);
|
|
2451
|
+
return cellText(field, value, this.#presentation.value.columns?.[column.field]?.format);
|
|
1330
2452
|
}
|
|
1331
2453
|
#external() {
|
|
1332
|
-
|
|
2454
|
+
const binding = this.#binding.value;
|
|
2455
|
+
if (binding)
|
|
2456
|
+
return binding;
|
|
2457
|
+
// A tree owns one collection per level; a supplied selection is state, not the data binding.
|
|
2458
|
+
if (this.#treeOptions.value)
|
|
2459
|
+
return undefined;
|
|
2460
|
+
return this.#selection.value?.collection;
|
|
1333
2461
|
}
|
|
1334
2462
|
#syncBound() {
|
|
1335
2463
|
const collection = this.#external();
|
|
1336
2464
|
const view = this.#view.value;
|
|
1337
2465
|
const base = this.#base.value;
|
|
1338
2466
|
const paths = this.#sortPaths.value;
|
|
2467
|
+
// Capability changes alter the projected query even when the view doesn't.
|
|
2468
|
+
this.#policy.value;
|
|
2469
|
+
this.#ctx.value;
|
|
1339
2470
|
if (!collection) {
|
|
1340
2471
|
this.#bound = undefined;
|
|
1341
2472
|
return;
|
|
@@ -1357,9 +2488,10 @@ export class Grid extends AsterElement {
|
|
|
1357
2488
|
}
|
|
1358
2489
|
if (!view)
|
|
1359
2490
|
return;
|
|
1360
|
-
const parts = [this.#boundBase, base
|
|
2491
|
+
const parts = [this.#boundBase, base].filter((c) => c !== undefined);
|
|
1361
2492
|
const filter = parts.length > 1 ? { op: "and", args: parts } : parts[0];
|
|
1362
|
-
|
|
2493
|
+
// View entries go through capability filtering; refused filters drop out and the drift note reports them.
|
|
2494
|
+
const projected = this.#query(query.set, view, filter, paths);
|
|
1363
2495
|
if (JSON.stringify([query.filter, query.orderBy]) === JSON.stringify([projected.filter, projected.orderBy]))
|
|
1364
2496
|
return;
|
|
1365
2497
|
const { filter: _filter, orderBy: _orderBy, ...rest } = query;
|
|
@@ -1370,6 +2502,9 @@ export class Grid extends AsterElement {
|
|
|
1370
2502
|
}
|
|
1371
2503
|
#recoverActive() {
|
|
1372
2504
|
this.#version.value;
|
|
2505
|
+
// Paged mode keeps the active row inside the page it chose.
|
|
2506
|
+
if (this.#pagedMode())
|
|
2507
|
+
return;
|
|
1373
2508
|
const grouping = this.#grouping;
|
|
1374
2509
|
if (grouping) {
|
|
1375
2510
|
const rows = grouping.rows.value;
|
|
@@ -1418,19 +2553,20 @@ export class Grid extends AsterElement {
|
|
|
1418
2553
|
});
|
|
1419
2554
|
}
|
|
1420
2555
|
#choose(key, index, extend, toggle) {
|
|
1421
|
-
if (!this.#
|
|
1422
|
-
return;
|
|
2556
|
+
if (!this.#selectionAllowed())
|
|
2557
|
+
return false;
|
|
1423
2558
|
const selection = this.#selection.peek();
|
|
1424
2559
|
if (!selection)
|
|
1425
|
-
return;
|
|
2560
|
+
return false;
|
|
1426
2561
|
let accepted = true;
|
|
1427
2562
|
if (extend && selection.multiple) {
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
accepted = selection.
|
|
2563
|
+
accepted = selection.selectAll(this.#shiftRange(selection, key, index, this.#selectionAnchor));
|
|
2564
|
+
}
|
|
2565
|
+
else if (!extend && this.#cascadeEnabled(selection)) {
|
|
2566
|
+
// Cascades settle several keys at once: toggling the clicked row first
|
|
2567
|
+
// would keep it selected when an ordered selection rejects the whole.
|
|
2568
|
+
accepted = this.#cascadeChoose(selection, key, !selection.keys.peek().includes(key));
|
|
2569
|
+
this.#selectionAnchor = { key, index, query: selection.collection.query };
|
|
1434
2570
|
}
|
|
1435
2571
|
else {
|
|
1436
2572
|
if (toggle || selection.ordered)
|
|
@@ -1440,9 +2576,80 @@ export class Grid extends AsterElement {
|
|
|
1440
2576
|
this.#selectionAnchor = { key, index, query: selection.collection.query };
|
|
1441
2577
|
}
|
|
1442
2578
|
this.#selectionNote.value = accepted ? "" : `Select at most ${selection.maxOrdered} ordered items. Deselect an item to add another.`;
|
|
2579
|
+
return accepted;
|
|
2580
|
+
}
|
|
2581
|
+
/** The keys between the anchor and the click: visible tree order when there is a tree, membership order otherwise. */
|
|
2582
|
+
#shiftRange(selection, key, index, anchor) {
|
|
2583
|
+
if (this.#tree && anchor) {
|
|
2584
|
+
const visible = this.#layout().keys;
|
|
2585
|
+
const from = visible.indexOf(anchor.key);
|
|
2586
|
+
const to = visible.indexOf(key);
|
|
2587
|
+
if (from >= 0 && to >= 0) {
|
|
2588
|
+
return visible.slice(Math.min(from, to), Math.max(from, to) + 1).filter((k) => k !== undefined);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
const slots = selection.collection.keys.peek();
|
|
2592
|
+
const loaded = anchor ? slots.indexOf(anchor.key) : -1;
|
|
2593
|
+
const from = loaded >= 0 ? loaded : anchor?.query === selection.collection.query ? anchor.index : index;
|
|
2594
|
+
return slots.slice(Math.min(from, index), Math.max(from, index) + 1).filter((k) => k !== undefined);
|
|
2595
|
+
}
|
|
2596
|
+
/** Cascade needs a tree, a multiple shared selection and the tree's switch. */
|
|
2597
|
+
#cascadeEnabled(selection) {
|
|
2598
|
+
return selection.multiple && this.#tree !== undefined && this.#treeOptions.value?.cascadeSelection === true;
|
|
2599
|
+
}
|
|
2600
|
+
/** The loaded tree rows whose parent chain passes through `key`. */
|
|
2601
|
+
#loadedDescendants(key) {
|
|
2602
|
+
const rows = this.#tree?.rows.peek() ?? [];
|
|
2603
|
+
const parentOf = new Map();
|
|
2604
|
+
for (const row of rows)
|
|
2605
|
+
if (row.key !== undefined)
|
|
2606
|
+
parentOf.set(row.key, row.parent);
|
|
2607
|
+
const under = (candidate) => {
|
|
2608
|
+
let parent = parentOf.get(candidate);
|
|
2609
|
+
while (parent !== undefined) {
|
|
2610
|
+
if (parent === key)
|
|
2611
|
+
return true;
|
|
2612
|
+
parent = parentOf.get(parent);
|
|
2613
|
+
}
|
|
2614
|
+
return false;
|
|
2615
|
+
};
|
|
2616
|
+
return rows.flatMap((row) => (row.key !== undefined && under(row.key) ? [row.key] : []));
|
|
2617
|
+
}
|
|
2618
|
+
/**
|
|
2619
|
+
* Applies a selection to a node's loaded descendants, and settles each
|
|
2620
|
+
* ancestor on whether all its loaded children are chosen. `choosing` is
|
|
2621
|
+
* the clicked row's state before this call, which itself changes nothing.
|
|
2622
|
+
*/
|
|
2623
|
+
#cascadeChoose(selection, key, choosing) {
|
|
2624
|
+
const rows = this.#tree?.rows.peek() ?? [];
|
|
2625
|
+
const parentOf = new Map();
|
|
2626
|
+
for (const row of rows)
|
|
2627
|
+
if (row.key !== undefined)
|
|
2628
|
+
parentOf.set(row.key, row.parent);
|
|
2629
|
+
const under = new Set([key, ...this.#loadedDescendants(key)]);
|
|
2630
|
+
let next = [...selection.keys.peek()];
|
|
2631
|
+
if (choosing) {
|
|
2632
|
+
for (const row of rows)
|
|
2633
|
+
if (row.key !== undefined && under.has(row.key) && !next.includes(row.key))
|
|
2634
|
+
next.push(row.key);
|
|
2635
|
+
}
|
|
2636
|
+
else {
|
|
2637
|
+
next = next.filter((candidate) => !under.has(candidate));
|
|
2638
|
+
}
|
|
2639
|
+
let parent = parentOf.get(key);
|
|
2640
|
+
while (parent !== undefined) {
|
|
2641
|
+
const children = rows.flatMap((row) => (row.key !== undefined && row.parent === parent ? [row.key] : []));
|
|
2642
|
+
const completed = children.length > 0 && children.every((child) => next.includes(child));
|
|
2643
|
+
if (completed && !next.includes(parent))
|
|
2644
|
+
next.push(parent);
|
|
2645
|
+
if (!completed)
|
|
2646
|
+
next = next.filter((candidate) => candidate !== parent);
|
|
2647
|
+
parent = parentOf.get(parent);
|
|
2648
|
+
}
|
|
2649
|
+
return selection.selectAll(next);
|
|
1443
2650
|
}
|
|
1444
2651
|
#moveSelection(key, delta) {
|
|
1445
|
-
if (!this.#
|
|
2652
|
+
if (!this.#selectionAllowed())
|
|
1446
2653
|
return;
|
|
1447
2654
|
const selection = this.#selection.peek();
|
|
1448
2655
|
if (!selection)
|
|
@@ -1488,6 +2695,88 @@ export class Grid extends AsterElement {
|
|
|
1488
2695
|
?disabled=${index === chosen.length - 1} @click=${() => this.#moveSelection(key, 1)}>↓</button>` : nothing}
|
|
1489
2696
|
</span>`;
|
|
1490
2697
|
}
|
|
2698
|
+
/**
|
|
2699
|
+
* The selection column's header. With a multiple shared selection it is a
|
|
2700
|
+
* select-all for the loaded rows, mixed when only some are chosen; a single
|
|
2701
|
+
* or owned selection has nothing to select in bulk.
|
|
2702
|
+
*/
|
|
2703
|
+
#selectionHead(selection, chosen) {
|
|
2704
|
+
const loaded = this.#tree
|
|
2705
|
+
? this.#tree.rows.peek().flatMap((row) => (row.key !== undefined ? [row.key] : []))
|
|
2706
|
+
: this.#collection?.keys.value.filter((key) => key !== undefined) ?? [];
|
|
2707
|
+
const loadedSet = new Set(loaded);
|
|
2708
|
+
const picked = loaded.filter((key) => chosen.includes(key));
|
|
2709
|
+
const checked = loaded.length > 0 && picked.length === loaded.length;
|
|
2710
|
+
const mixed = picked.length > 0 && !checked;
|
|
2711
|
+
const multiple = selection?.multiple === true;
|
|
2712
|
+
return html `<div class="aster-grid-cell aster-grid-select aster-grid-select-head" role="columnheader" aria-colindex="1" id=${`${this.#id}-h-s`}>
|
|
2713
|
+
${multiple
|
|
2714
|
+
? html `<input type="checkbox" .checked=${checked} .indeterminate=${mixed} tabindex="0"
|
|
2715
|
+
aria-label=${checked ? "Clear the selection of loaded rows" : mixed ? "Select all loaded rows, some are selected" : "Select all loaded rows"}
|
|
2716
|
+
@change=${(e) => {
|
|
2717
|
+
const input = e.currentTarget;
|
|
2718
|
+
if (!selection)
|
|
2719
|
+
return;
|
|
2720
|
+
if (checked) {
|
|
2721
|
+
const keep = chosen.filter((key) => !loadedSet.has(key));
|
|
2722
|
+
selection.selectAll(keep);
|
|
2723
|
+
this.#selectionNote.value = keep.length ? `Kept ${keep.length} selected ${keep.length === 1 ? "row" : "rows"} not loaded here.` : "";
|
|
2724
|
+
}
|
|
2725
|
+
else if (selection.selectAll([...new Set([...chosen, ...loaded])])) {
|
|
2726
|
+
this.#selectionNote.value = "";
|
|
2727
|
+
}
|
|
2728
|
+
else {
|
|
2729
|
+
this.#selectionNote.value = `Select at most ${selection.maxOrdered} ordered items. Deselect an item to add another.`;
|
|
2730
|
+
}
|
|
2731
|
+
const now = selection.keys.peek();
|
|
2732
|
+
const picked = loaded.filter((key) => now.includes(key)).length;
|
|
2733
|
+
input.checked = loaded.length > 0 && picked === loaded.length;
|
|
2734
|
+
input.indeterminate = picked > 0 && picked < loaded.length;
|
|
2735
|
+
}}>`
|
|
2736
|
+
: html `<span class="aster-sr-only">Selection</span>`}
|
|
2737
|
+
</div>`;
|
|
2738
|
+
}
|
|
2739
|
+
/** One row's checkbox in the leading selection column. */
|
|
2740
|
+
#selectionCell(key, index, selection, chosen, entity, hidden) {
|
|
2741
|
+
if (key === undefined)
|
|
2742
|
+
return html `<div class="aster-grid-cell aster-grid-select" role="gridcell" aria-colindex="1" data-col="-1"></div>`;
|
|
2743
|
+
const chosenIndex = chosen.indexOf(key);
|
|
2744
|
+
const checked = selection ? chosenIndex >= 0 : key === this.#selected.value;
|
|
2745
|
+
const field = this.#collection?.type.titleField;
|
|
2746
|
+
const name = field && hidden?.has(field) ? "Hidden" : String((entity && field ? entity[field] : undefined) ?? key);
|
|
2747
|
+
// A folder with some chosen descendants is mixed while cascade is on.
|
|
2748
|
+
const mixed = !checked && selection?.multiple === true && this.#tree !== undefined && this.#treeOptions.value?.cascadeSelection === true
|
|
2749
|
+
&& this.#loadedDescendants(key).some((descendant) => chosen.includes(descendant));
|
|
2750
|
+
const label = mixed
|
|
2751
|
+
? `Select all under ${name}, some are selected`
|
|
2752
|
+
: selection?.multiple ? `${checked ? "Deselect" : "Select"} ${name}` : `${checked ? "Selected" : "Select"} ${name}`;
|
|
2753
|
+
return html `<div class="aster-grid-cell aster-grid-select" role="gridcell" aria-colindex="1" data-col="-1" data-label="Select">
|
|
2754
|
+
<input type="checkbox" .checked=${checked} .indeterminate=${mixed} tabindex="-1" aria-label=${label}
|
|
2755
|
+
@change=${(e) => {
|
|
2756
|
+
const input = e.currentTarget;
|
|
2757
|
+
if (selection)
|
|
2758
|
+
this.#choose(key, this.#collection.keys.peek().indexOf(key), false, selection.multiple);
|
|
2759
|
+
else
|
|
2760
|
+
this.#activate(index, this.#active.peek().col);
|
|
2761
|
+
// The native toggle ran first; settle it on the state that resulted.
|
|
2762
|
+
input.checked = selection ? selection.keys.peek().includes(key) : key === this.#selected.peek();
|
|
2763
|
+
const chosenNow = selection?.keys.peek() ?? [];
|
|
2764
|
+
input.indeterminate = !input.checked && selection?.multiple === true && this.#tree !== undefined && this.#treeOptions.peek()?.cascadeSelection === true
|
|
2765
|
+
&& this.#loadedDescendants(key).some((descendant) => chosenNow.includes(descendant));
|
|
2766
|
+
}}
|
|
2767
|
+
@keydown=${(e) => {
|
|
2768
|
+
if (e.key === "Escape") {
|
|
2769
|
+
e.preventDefault();
|
|
2770
|
+
this.focus();
|
|
2771
|
+
}
|
|
2772
|
+
else if (e.key !== "Tab")
|
|
2773
|
+
e.stopPropagation();
|
|
2774
|
+
}}
|
|
2775
|
+
@dblclick=${(e) => e.stopPropagation()}
|
|
2776
|
+
@contextmenu=${(e) => e.stopPropagation()}>
|
|
2777
|
+
${selection?.ordered && chosenIndex >= 0 ? html `<span class="aster-grid-select-rank" aria-hidden="true">${chosenIndex + 1}</span>` : nothing}
|
|
2778
|
+
</div>`;
|
|
2779
|
+
}
|
|
1491
2780
|
// ── data and view ──
|
|
1492
2781
|
/** With `persist`, the person's last view replaces the page's, reconciled with today's columns. */
|
|
1493
2782
|
#restore() {
|
|
@@ -1514,6 +2803,9 @@ export class Grid extends AsterElement {
|
|
|
1514
2803
|
}
|
|
1515
2804
|
/** Stored views have been read; until then, writing would replace them. */
|
|
1516
2805
|
#restored = false;
|
|
2806
|
+
/** The view and effective selection the change events last reported. */
|
|
2807
|
+
#viewBefore;
|
|
2808
|
+
#selectionBefore;
|
|
1517
2809
|
/** Publishes the shared selection or active row, plus view and total. */
|
|
1518
2810
|
#announce() {
|
|
1519
2811
|
const key = this.#publish.value;
|
|
@@ -1542,9 +2834,52 @@ export class Grid extends AsterElement {
|
|
|
1542
2834
|
...(publication?.hidden ? { hidden: publication.hidden } : {}),
|
|
1543
2835
|
});
|
|
1544
2836
|
}
|
|
2837
|
+
/** Drift is announced once per set of dropped entries; dismissing hides the note, not the announcement. */
|
|
2838
|
+
#driftAnnounced = "";
|
|
2839
|
+
/** Notifies after the view changed, whichever code changed it; never cancelable. */
|
|
2840
|
+
#viewChanged() {
|
|
2841
|
+
const view = this.#view.value;
|
|
2842
|
+
if (view === this.#viewBefore)
|
|
2843
|
+
return;
|
|
2844
|
+
const before = this.#viewBefore;
|
|
2845
|
+
this.#viewBefore = view;
|
|
2846
|
+
if (!before || !view)
|
|
2847
|
+
return;
|
|
2848
|
+
if (JSON.stringify(before.sort) !== JSON.stringify(view.sort))
|
|
2849
|
+
this.emit("sort-change", { sort: view.sort }, { cancelable: false });
|
|
2850
|
+
if (JSON.stringify(before.filters) !== JSON.stringify(view.filters))
|
|
2851
|
+
this.emit("filter-change", { filters: view.filters }, { cancelable: false });
|
|
2852
|
+
if (JSON.stringify(before.criteria) !== JSON.stringify(view.criteria))
|
|
2853
|
+
this.emit("criteria-change", { criteria: view.criteria }, { cancelable: false });
|
|
2854
|
+
}
|
|
2855
|
+
/** Notifies after the effective selection changed; owned selection reports its one key. */
|
|
2856
|
+
#selectionChanged() {
|
|
2857
|
+
const shared = this.#selection.value;
|
|
2858
|
+
const keys = shared ? [...shared.keys.value] : this.#selected.value === undefined ? [] : [this.#selected.value];
|
|
2859
|
+
const id = keys.join("\u0000");
|
|
2860
|
+
if (id === this.#selectionBefore)
|
|
2861
|
+
return;
|
|
2862
|
+
const first = this.#selectionBefore === undefined;
|
|
2863
|
+
this.#selectionBefore = id;
|
|
2864
|
+
if (!first)
|
|
2865
|
+
this.emit("selection-change", { keys }, { cancelable: false });
|
|
2866
|
+
}
|
|
2867
|
+
#driftAnnounce() {
|
|
2868
|
+
const entries = this.#drift.value;
|
|
2869
|
+
const key = this.#driftKey(entries);
|
|
2870
|
+
if (!entries.length) {
|
|
2871
|
+
this.#driftAnnounced = "";
|
|
2872
|
+
this.#driftDismissed.value = "";
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
if (key === this.#driftAnnounced)
|
|
2876
|
+
return;
|
|
2877
|
+
this.#driftAnnounced = key;
|
|
2878
|
+
announce(this.#driftText(entries));
|
|
2879
|
+
}
|
|
1545
2880
|
#setView(view) {
|
|
1546
2881
|
const request = ++this.#viewRequest;
|
|
1547
|
-
if (this.#editing.peek() && (JSON.stringify(
|
|
2882
|
+
if (this.#editing.peek() && (JSON.stringify(this.#groupedFields(view)) !== JSON.stringify(this.#groupedFields(this.#view.peek())) || JSON.stringify(view.criteria) !== JSON.stringify(this.#view.peek()?.criteria))) {
|
|
1548
2883
|
void this.#endEdit(true).then((saved) => {
|
|
1549
2884
|
if (saved && this.isConnected && request === this.#viewRequest)
|
|
1550
2885
|
this.#commitView(view);
|
|
@@ -1554,8 +2889,8 @@ export class Grid extends AsterElement {
|
|
|
1554
2889
|
this.#commitView(view);
|
|
1555
2890
|
}
|
|
1556
2891
|
#commitView(view) {
|
|
1557
|
-
const beforeGroups =
|
|
1558
|
-
const afterGroups =
|
|
2892
|
+
const beforeGroups = this.#groupedFields(this.#view.peek());
|
|
2893
|
+
const afterGroups = this.#groupedFields(view);
|
|
1559
2894
|
const focused = this.contains(document.activeElement) ? document.activeElement?.closest(".aster-grid-group-tools") : null;
|
|
1560
2895
|
if (focused && JSON.stringify(beforeGroups) !== JSON.stringify(afterGroups)) {
|
|
1561
2896
|
const field = focused.dataset.groupField;
|
|
@@ -1577,6 +2912,7 @@ export class Grid extends AsterElement {
|
|
|
1577
2912
|
if (binding) {
|
|
1578
2913
|
untracked(() => {
|
|
1579
2914
|
this.#selected.value = undefined;
|
|
2915
|
+
this.#ownedOptions = undefined;
|
|
1580
2916
|
if (this.#collection === binding) {
|
|
1581
2917
|
this.#ownsCollection = false;
|
|
1582
2918
|
return;
|
|
@@ -1593,11 +2929,14 @@ export class Grid extends AsterElement {
|
|
|
1593
2929
|
if (!this.#view.peek())
|
|
1594
2930
|
this.view = defaultView(ctx.schema.set(set).type);
|
|
1595
2931
|
});
|
|
1596
|
-
|
|
2932
|
+
const options = this.#collectionOptions();
|
|
2933
|
+
const optionsKey = JSON.stringify(options);
|
|
2934
|
+
if (this.#collection?.query.set === set && (this.#tree || (this.#ownsCollection && this.#ownedOptions === optionsKey)))
|
|
1597
2935
|
return;
|
|
1598
2936
|
const treeOptions = untracked(() => this.#treeOptions.value);
|
|
1599
2937
|
if (treeOptions) {
|
|
1600
2938
|
untracked(() => {
|
|
2939
|
+
this.#ownedOptions = undefined;
|
|
1601
2940
|
this.#tree?.dispose();
|
|
1602
2941
|
const filters = computed(() => {
|
|
1603
2942
|
return this.#query(set, this.#view.value ?? { columns: [], sort: [], filters: {}, frozen: 0 }).filter;
|
|
@@ -1607,9 +2946,10 @@ export class Grid extends AsterElement {
|
|
|
1607
2946
|
...treeOptions,
|
|
1608
2947
|
base: computed(() => this.#base.value),
|
|
1609
2948
|
filter: filters,
|
|
1610
|
-
orderBy: computed(() => viewQuery(set, this.#view.value ?? { columns: [], sort: [], filters: {}, frozen: 0 }, undefined, this.#sortPaths.value).orderBy ?? []),
|
|
2949
|
+
orderBy: computed(() => viewQuery(set, this.#view.value ?? { columns: [], sort: [], filters: {}, frozen: 0 }, undefined, this.#sortPaths.value, this.#capabilities()).orderBy ?? []),
|
|
1611
2950
|
});
|
|
1612
2951
|
this.#collection = this.#tree.level(undefined);
|
|
2952
|
+
this.#autoOpen(this.#tree, treeOptions.autoOpen ?? 0);
|
|
1613
2953
|
this.#version.value++;
|
|
1614
2954
|
});
|
|
1615
2955
|
this.onDisconnect(() => {
|
|
@@ -1623,20 +2963,24 @@ export class Grid extends AsterElement {
|
|
|
1623
2963
|
untracked(() => {
|
|
1624
2964
|
if (this.#ownsCollection && this.#collection)
|
|
1625
2965
|
ctx.release(this.#collection);
|
|
1626
|
-
this.#collection = ctx.collection(query,
|
|
2966
|
+
this.#collection = ctx.collection(query, options);
|
|
1627
2967
|
this.#ownsCollection = true;
|
|
2968
|
+
this.#ownedOptions = optionsKey;
|
|
1628
2969
|
this.#version.value++;
|
|
1629
2970
|
});
|
|
1630
2971
|
}
|
|
2972
|
+
/** The owned collection's data options, so a change recreates it. */
|
|
2973
|
+
#ownedOptions;
|
|
1631
2974
|
/** A new query (filter, sort, grouping, base) starts at the top, with its first row active. */
|
|
1632
2975
|
#toTop() {
|
|
1633
2976
|
const view = this.#view.value;
|
|
1634
2977
|
// Before the view arrives there's no query to compare with.
|
|
1635
2978
|
if (!view)
|
|
1636
2979
|
return;
|
|
1637
|
-
const key = JSON.stringify([view.filters, view.criteria, view.sort, view.groupBy, this.#base.value, this.#external()?.query]);
|
|
2980
|
+
const key = JSON.stringify([view.filters, view.criteria, view.sort, view.groupBy, this.#base.value, this.#external()?.query, this.#capabilitiesVersion.value]);
|
|
1638
2981
|
untracked(() => {
|
|
1639
2982
|
if (this.#lastQuery !== undefined && this.#lastQuery !== key) {
|
|
2983
|
+
this.#page.value = 0;
|
|
1640
2984
|
const scroll = this.querySelector(".aster-grid-scroll");
|
|
1641
2985
|
if (scroll)
|
|
1642
2986
|
scroll.scrollTop = 0;
|
|
@@ -1656,13 +3000,18 @@ export class Grid extends AsterElement {
|
|
|
1656
3000
|
#load() {
|
|
1657
3001
|
const collection = this.#collection;
|
|
1658
3002
|
this.#version.value;
|
|
1659
|
-
if (!collection)
|
|
3003
|
+
if (!collection || this.#deferred())
|
|
1660
3004
|
return;
|
|
1661
3005
|
const layout = this.#layout();
|
|
1662
3006
|
this.#heights.setKeys(layout.keys);
|
|
1663
3007
|
const { start, end } = this.#heights.window(this.#top.value, this.#viewport.value, OVERSCAN);
|
|
1664
3008
|
const wanted = new Map();
|
|
1665
|
-
if (
|
|
3009
|
+
if (this.#pagedMode()) {
|
|
3010
|
+
const first = layout.first ?? 0;
|
|
3011
|
+
const size = Math.max(1, this.#pageSize.peek());
|
|
3012
|
+
wanted.set(collection, { from: first, to: Math.max(first + 1, layout.total === undefined ? first + size : Math.min(layout.total, first + size)) });
|
|
3013
|
+
}
|
|
3014
|
+
else if (!this.#groupedFields().length && !this.#tree)
|
|
1666
3015
|
wanted.set(collection, { from: start, to: Math.max(end, 1) });
|
|
1667
3016
|
else {
|
|
1668
3017
|
// A tree's top level loads before it has lines to look at.
|
|
@@ -1715,7 +3064,25 @@ export class Grid extends AsterElement {
|
|
|
1715
3064
|
},
|
|
1716
3065
|
};
|
|
1717
3066
|
}
|
|
1718
|
-
if (
|
|
3067
|
+
if (this.#pagedMode()) {
|
|
3068
|
+
const keys = collection.keys.value;
|
|
3069
|
+
const total = collection.count.value;
|
|
3070
|
+
const size = Math.max(1, this.#pageSize.value);
|
|
3071
|
+
const pages = total === undefined ? undefined : Math.max(1, Math.ceil(total / size));
|
|
3072
|
+
const page = pages === undefined ? Math.max(0, this.#page.value) : Math.min(Math.max(0, this.#page.value), pages - 1);
|
|
3073
|
+
const first = page * size;
|
|
3074
|
+
const end = total === undefined ? Math.min(keys.length, first + size) : Math.min(total, first + size);
|
|
3075
|
+
const slice = keys.slice(first, end);
|
|
3076
|
+
return {
|
|
3077
|
+
keys: slice,
|
|
3078
|
+
total,
|
|
3079
|
+
...atLeast(collection),
|
|
3080
|
+
groups: undefined,
|
|
3081
|
+
first,
|
|
3082
|
+
at: (i) => ({ kind: "row", key: slice[i], collection, local: first + i }),
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
if (!this.#groupedFields(view).length) {
|
|
1719
3086
|
const keys = collection.keys.value;
|
|
1720
3087
|
const total = collection.count.value;
|
|
1721
3088
|
return { keys, total, ...atLeast(collection), groups: undefined, at: (i) => ({ kind: "row", key: keys[i], collection, local: i }) };
|
|
@@ -1728,7 +3095,8 @@ export class Grid extends AsterElement {
|
|
|
1728
3095
|
#groupRow(item, index, rowIndex, columns, _view, active) {
|
|
1729
3096
|
const field = this.#collection.type.field(item.field);
|
|
1730
3097
|
const label = this.#groupLabel(item.field, field, item.group.value);
|
|
1731
|
-
const
|
|
3098
|
+
const fields = this.#groupedFields(this.#view.value);
|
|
3099
|
+
const path = item.path.map((value, i) => String(this.#groupLabel(fields[i], this.#collection.type.field(fields[i]), value))).join(" / ");
|
|
1732
3100
|
return html `<div class="aster-grid-row aster-grid-group" role="row" aria-rowindex=${rowIndex} data-index=${index}
|
|
1733
3101
|
data-key=${item.id} aria-level=${item.level} aria-posinset=${item.position} aria-setsize=${item.siblings}
|
|
1734
3102
|
aria-expanded=${item.expanded ? "true" : "false"} aria-busy=${item.loading ? "true" : "false"} style=${`--level:${item.level - 1}`}>
|
|
@@ -1736,13 +3104,13 @@ export class Grid extends AsterElement {
|
|
|
1736
3104
|
?data-active=${active} @click=${(e) => { if (!e.target.closest("button"))
|
|
1737
3105
|
this.#toggleGroup(item.id); }}>
|
|
1738
3106
|
<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">${
|
|
3107
|
+
<span class="aster-sr-only">${this.#title(item.field)}: </span>
|
|
1740
3108
|
<span class="aster-grid-group-label">${label}</span>
|
|
1741
3109
|
<span class="aster-grid-group-count">${new Intl.NumberFormat("en-GB").format(item.group.count)}</span>
|
|
1742
3110
|
${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"
|
|
1744
|
-
${item.error !== undefined ? html `<span class="aster-error" role="status"
|
|
1745
|
-
<button type="button" class="aster-grid-tool" aria-label=${
|
|
3111
|
+
${item.loading ? html `<span class="aster-grid-group-loading" role="status">${this.#text("loading")}</span>` : nothing}
|
|
3112
|
+
${item.error !== undefined ? html `<span class="aster-error" role="status">${this.#text("groupLoadFailed")}</span>
|
|
3113
|
+
<button type="button" class="aster-grid-tool" aria-label=${this.#text("retryGroup", { group: path })} @click=${() => { this.#activate(index, 0); this.focus({ preventScroll: true }); void this.#grouping?.retry(item.id).catch(() => { }); }}>${this.#text("retry")}</button>` : nothing}
|
|
1746
3114
|
</div>
|
|
1747
3115
|
</div>`;
|
|
1748
3116
|
}
|
|
@@ -1772,17 +3140,31 @@ export class Grid extends AsterElement {
|
|
|
1772
3140
|
if (summary.label)
|
|
1773
3141
|
return summary.label;
|
|
1774
3142
|
if (summary.method === "count")
|
|
1775
|
-
return "
|
|
1776
|
-
const title = this.#
|
|
1777
|
-
|
|
1778
|
-
|
|
3143
|
+
return this.#text("summaryRows");
|
|
3144
|
+
const title = this.#title(summary.field);
|
|
3145
|
+
switch (summary.method) {
|
|
3146
|
+
case "sum":
|
|
3147
|
+
return this.#text("summarySum", { field: title });
|
|
3148
|
+
case "min":
|
|
3149
|
+
return this.#text("summaryMinimum", { field: title });
|
|
3150
|
+
case "max":
|
|
3151
|
+
return this.#text("summaryMaximum", { field: title });
|
|
3152
|
+
case "average":
|
|
3153
|
+
return this.#text("summaryAverage", { field: title });
|
|
3154
|
+
case "countdistinct":
|
|
3155
|
+
return this.#text("summaryDistinct", { field: title });
|
|
3156
|
+
default:
|
|
3157
|
+
return title;
|
|
3158
|
+
}
|
|
1779
3159
|
}
|
|
1780
3160
|
#summaryValue(summary, value) {
|
|
1781
3161
|
if (value === null)
|
|
1782
3162
|
return "—";
|
|
1783
3163
|
if (summary.method === "count" || summary.method === "countdistinct")
|
|
1784
3164
|
return new Intl.NumberFormat("en-GB").format(Number(value));
|
|
1785
|
-
|
|
3165
|
+
const field = this.#collection?.type.field(summary.field);
|
|
3166
|
+
const format = this.#presentation.value.columns?.[summary.field]?.format;
|
|
3167
|
+
return format ? cellText(field, value, format) : formatValue(field, value);
|
|
1786
3168
|
}
|
|
1787
3169
|
#summaryView() {
|
|
1788
3170
|
const summary = this.#summary;
|
|
@@ -1790,20 +3172,26 @@ export class Grid extends AsterElement {
|
|
|
1790
3172
|
return nothing;
|
|
1791
3173
|
const values = summary.values.value;
|
|
1792
3174
|
const status = summary.status.value;
|
|
1793
|
-
return html `<section class="aster-grid-summary" aria-label=${
|
|
3175
|
+
return html `<section class="aster-grid-summary" aria-label=${this.#text("summaryAria", { label: this.#label.value })} aria-busy=${status === "loading" ? "true" : "false"}>
|
|
1794
3176
|
<aster-progress-status class="aster-grid-summary-status" .busy=${status === "loading"}
|
|
1795
|
-
.error=${status === "error" ? "
|
|
1796
|
-
.pendingText=${"
|
|
1797
|
-
${status === "error" ? html `<button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void summary.refresh(); }}
|
|
3177
|
+
.error=${status === "error" ? this.#text("summaryLoadFailed") : undefined} .progressPolicy=${this.#progressPolicy.value}
|
|
3178
|
+
.pendingText=${this.#text("summaryLoading")} .completeText=${this.#text("summariesUpdated")}></aster-progress-status>
|
|
3179
|
+
${status === "error" ? html `<button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void summary.refresh(); }}>${this.#text("retrySummaries")}</button>` : nothing}
|
|
1798
3180
|
${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
3181
|
</section>`;
|
|
1800
3182
|
}
|
|
1801
3183
|
#groupLabel(name, field, value) {
|
|
3184
|
+
const column = this.#presentation.value.columns?.[name];
|
|
3185
|
+
const mapped = mappedValue(column, value);
|
|
3186
|
+
if (mapped)
|
|
3187
|
+
return mapped.label;
|
|
1802
3188
|
if (value === null)
|
|
1803
3189
|
return "None";
|
|
1804
3190
|
const lookup = this.#lookups.value[name];
|
|
1805
3191
|
if (lookup)
|
|
1806
3192
|
return String(this.#ctx.peek().lookup(lookup.set, keyText([value])).value?.[lookup.display] ?? "…");
|
|
3193
|
+
if (column?.format)
|
|
3194
|
+
return cellText(field, value, column.format);
|
|
1807
3195
|
if (field?.type === "Edm.Boolean")
|
|
1808
3196
|
return value ? "Yes" : "No";
|
|
1809
3197
|
if (field?.type === "Edm.Date" && typeof value === "string")
|
|
@@ -1812,14 +3200,6 @@ export class Grid extends AsterElement {
|
|
|
1812
3200
|
return absoluteTime(value);
|
|
1813
3201
|
return formatValue(field, value);
|
|
1814
3202
|
}
|
|
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
3203
|
async #toggleGroup(id) {
|
|
1824
3204
|
const grouping = this.#grouping;
|
|
1825
3205
|
if (!grouping)
|
|
@@ -1849,16 +3229,21 @@ export class Grid extends AsterElement {
|
|
|
1849
3229
|
}
|
|
1850
3230
|
void grouping.toggle(id).catch(() => { });
|
|
1851
3231
|
}
|
|
1852
|
-
#reorderGroup(
|
|
3232
|
+
#reorderGroup(field, step) {
|
|
1853
3233
|
const view = this.#view.peek();
|
|
1854
3234
|
if (!view)
|
|
1855
3235
|
return;
|
|
1856
|
-
const
|
|
1857
|
-
const
|
|
1858
|
-
|
|
3236
|
+
const stored = [...groupFields(view)];
|
|
3237
|
+
const effective = this.#groupedFields(view);
|
|
3238
|
+
const neighbour = effective[effective.indexOf(field) + step];
|
|
3239
|
+
if (neighbour === undefined)
|
|
3240
|
+
return;
|
|
3241
|
+
const a = stored.indexOf(field);
|
|
3242
|
+
const b = stored.indexOf(neighbour);
|
|
3243
|
+
if (a < 0 || b < 0)
|
|
1859
3244
|
return;
|
|
1860
|
-
[
|
|
1861
|
-
this.#setView(setGroup(view,
|
|
3245
|
+
[stored[a], stored[b]] = [stored[b], stored[a]];
|
|
3246
|
+
this.#setView(setGroup(view, stored));
|
|
1862
3247
|
}
|
|
1863
3248
|
#groupSummaryConfig;
|
|
1864
3249
|
#groupFieldsKey = "";
|
|
@@ -1867,7 +3252,7 @@ export class Grid extends AsterElement {
|
|
|
1867
3252
|
#group() {
|
|
1868
3253
|
const ctx = this.#ctx.value;
|
|
1869
3254
|
const set = this.set;
|
|
1870
|
-
const fields = this.#
|
|
3255
|
+
const fields = this.#groupedFields(this.#view.value);
|
|
1871
3256
|
const key = JSON.stringify([set, fields]);
|
|
1872
3257
|
const summaries = this.#summaries.value;
|
|
1873
3258
|
untracked(() => {
|
|
@@ -1926,9 +3311,9 @@ export class Grid extends AsterElement {
|
|
|
1926
3311
|
this.#anchor = { index, delta };
|
|
1927
3312
|
this.#version.value++;
|
|
1928
3313
|
}
|
|
1929
|
-
#frozenOffsets(columns, frozen) {
|
|
3314
|
+
#frozenOffsets(columns, frozen, base = 0) {
|
|
1930
3315
|
const out = [];
|
|
1931
|
-
let left =
|
|
3316
|
+
let left = base;
|
|
1932
3317
|
for (let i = 0; i < Math.min(frozen, columns.length); i++) {
|
|
1933
3318
|
out.push(left);
|
|
1934
3319
|
left += columns[i].width ?? 0;
|
|
@@ -1946,7 +3331,7 @@ export class Grid extends AsterElement {
|
|
|
1946
3331
|
* doesn't sort until the server can order by the related field.
|
|
1947
3332
|
*/
|
|
1948
3333
|
#sortable(field) {
|
|
1949
|
-
return
|
|
3334
|
+
return this.#sortRefusal(field) === undefined;
|
|
1950
3335
|
}
|
|
1951
3336
|
/**
|
|
1952
3337
|
* Lookup columns sort by the related record's display field, through the
|
|
@@ -1969,15 +3354,22 @@ export class Grid extends AsterElement {
|
|
|
1969
3354
|
return paths;
|
|
1970
3355
|
});
|
|
1971
3356
|
#sort(field, add) {
|
|
1972
|
-
if (this.#dragged
|
|
3357
|
+
if (this.#dragged)
|
|
1973
3358
|
return;
|
|
3359
|
+
const refusal = this.#sortRefusal(field);
|
|
3360
|
+
if (refusal) {
|
|
3361
|
+
this.#refused.value = refusal;
|
|
3362
|
+
announce(refusal);
|
|
3363
|
+
return;
|
|
3364
|
+
}
|
|
3365
|
+
this.#refused.value = undefined;
|
|
1974
3366
|
const view = this.#view.peek();
|
|
1975
3367
|
if (view)
|
|
1976
|
-
this.#setView(sortBy(view, field, add));
|
|
3368
|
+
this.#setView(sortBy(view, field, add && this.#multiSort()));
|
|
1977
3369
|
}
|
|
1978
3370
|
/** Drag the right edge of a header; JS writes only the column's width. */
|
|
1979
3371
|
#resize(e, field) {
|
|
1980
|
-
if (e.button !== 0)
|
|
3372
|
+
if (e.button !== 0 || this.#resizeRefusal(field))
|
|
1981
3373
|
return;
|
|
1982
3374
|
e.preventDefault();
|
|
1983
3375
|
e.stopPropagation();
|
|
@@ -1996,6 +3388,12 @@ export class Grid extends AsterElement {
|
|
|
1996
3388
|
#dragColumn(e, field) {
|
|
1997
3389
|
if (e.button !== 0)
|
|
1998
3390
|
return;
|
|
3391
|
+
const refusal = this.#moveRefusal(field);
|
|
3392
|
+
if (refusal) {
|
|
3393
|
+
this.#refused.value = refusal;
|
|
3394
|
+
announce(refusal);
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
1999
3397
|
const button = e.currentTarget;
|
|
2000
3398
|
const from = e.clientX;
|
|
2001
3399
|
this.#dragged = false;
|
|
@@ -2037,7 +3435,7 @@ export class Grid extends AsterElement {
|
|
|
2037
3435
|
#dragRow(e) {
|
|
2038
3436
|
const tree = this.#tree;
|
|
2039
3437
|
const target = e.target;
|
|
2040
|
-
if (!tree || !this.#treeOptions.peek()?.draggable || e.button !== 0 || !this.#
|
|
3438
|
+
if (!tree || !this.#treeOptions.peek()?.draggable || e.button !== 0 || !this.#editEnabled())
|
|
2041
3439
|
return;
|
|
2042
3440
|
if (target.closest("button, input, textarea, select, a, [data-editing], .aster-grid-expansion, .aster-grid-controls"))
|
|
2043
3441
|
return;
|
|
@@ -2051,13 +3449,26 @@ export class Grid extends AsterElement {
|
|
|
2051
3449
|
let label;
|
|
2052
3450
|
let over;
|
|
2053
3451
|
// The node's ancestors from stored records: a drop is refused where its own key is among them.
|
|
3452
|
+
const from = (() => {
|
|
3453
|
+
const parent = tree.entity(key)?.[tree.options.parentField];
|
|
3454
|
+
return parent === null || parent === undefined ? undefined : keyText([parent]);
|
|
3455
|
+
})();
|
|
3456
|
+
const options = this.#treeOptions.peek();
|
|
2054
3457
|
const why = (to) => {
|
|
3458
|
+
// Structure refuses first; a drop rule is host policy on top.
|
|
2055
3459
|
for (let k = to; k !== undefined;) {
|
|
2056
3460
|
if (k === key)
|
|
2057
3461
|
return "A row can’t move under itself or its own subtree.";
|
|
2058
3462
|
const parent = tree.entity(k)?.[tree.options.parentField];
|
|
2059
3463
|
k = parent === null || parent === undefined ? undefined : keyText([parent]);
|
|
2060
3464
|
}
|
|
3465
|
+
if (options.reparent === false && to !== from)
|
|
3466
|
+
return "Rows can’t be moved to another parent on this page.";
|
|
3467
|
+
if (options.dropOnLeaves === false && to !== undefined) {
|
|
3468
|
+
const node = tree.rows.peek().find((row) => row.key === to);
|
|
3469
|
+
if (node?.folder === false)
|
|
3470
|
+
return "A row can’t be dropped on a leaf; drop it on a folder or the top level.";
|
|
3471
|
+
}
|
|
2061
3472
|
return undefined;
|
|
2062
3473
|
};
|
|
2063
3474
|
pressDrag(e, { distance: 6, escape: true, capture: scroll, scroller: () => scroll, inset: () => this.querySelector(".aster-grid-headgroup")?.offsetHeight ?? 0 }, {
|
|
@@ -2071,7 +3482,7 @@ export class Grid extends AsterElement {
|
|
|
2071
3482
|
this.toggleAttribute("data-dragging", true);
|
|
2072
3483
|
if (touch)
|
|
2073
3484
|
this.#lifted.value = key;
|
|
2074
|
-
announce(
|
|
3485
|
+
announce(this.#text("dragPickedUp", { field: title }));
|
|
2075
3486
|
},
|
|
2076
3487
|
move: (x, y) => {
|
|
2077
3488
|
// Above a finger, where it can be seen (below it near the top); beside a mouse pointer.
|
|
@@ -2107,7 +3518,7 @@ export class Grid extends AsterElement {
|
|
|
2107
3518
|
const parent = tree.entity(key)?.[tree.options.parentField];
|
|
2108
3519
|
const from = parent === null || parent === undefined ? undefined : keyText([parent]);
|
|
2109
3520
|
if (!over || over.to === from) {
|
|
2110
|
-
announce(
|
|
3521
|
+
announce(this.#text("dragNotMoved", { field: title }));
|
|
2111
3522
|
return;
|
|
2112
3523
|
}
|
|
2113
3524
|
try {
|
|
@@ -2126,6 +3537,13 @@ export class Grid extends AsterElement {
|
|
|
2126
3537
|
if (e.target.closest("button, input, select, textarea, a[href], [contenteditable], [data-editing]"))
|
|
2127
3538
|
return;
|
|
2128
3539
|
const row = e.target.closest('[role="row"][data-index]:not(.aster-grid-group)');
|
|
3540
|
+
const pressed = e.target.closest('[role="gridcell"]');
|
|
3541
|
+
if (row?.dataset.key && pressed && Number(pressed.dataset.col ?? -1) >= 0) {
|
|
3542
|
+
const column = Number(pressed.dataset.col);
|
|
3543
|
+
const target = { key: row.dataset.key, field: this.#visibleColumns(this.#view.peek())[column]?.field, row: Number(row.dataset.index), column, originalEvent: e };
|
|
3544
|
+
if (!this.emit("cell-double-click", target))
|
|
3545
|
+
return;
|
|
3546
|
+
}
|
|
2129
3547
|
if (row?.dataset.key && !this.emit("row-open", { key: row.dataset.key }))
|
|
2130
3548
|
return;
|
|
2131
3549
|
// With expansion, a double-click opens the row rather than editing it in
|
|
@@ -2138,11 +3556,11 @@ export class Grid extends AsterElement {
|
|
|
2138
3556
|
void this.collapse(key);
|
|
2139
3557
|
return;
|
|
2140
3558
|
}
|
|
2141
|
-
if (!this.#
|
|
3559
|
+
if (!this.#editEnabled() || e.target.closest("[data-editing]"))
|
|
2142
3560
|
return;
|
|
2143
3561
|
const cell = e.target.closest('[role="gridcell"]');
|
|
2144
3562
|
if (row?.dataset.key)
|
|
2145
|
-
this.#startEdit(row.dataset.key, Number(cell?.
|
|
3563
|
+
this.#startEdit(row.dataset.key, Number(cell?.dataset.col ?? 0));
|
|
2146
3564
|
}
|
|
2147
3565
|
/**
|
|
2148
3566
|
* Starts editing a row: an edit model with the grid's rules in a child
|
|
@@ -2150,7 +3568,15 @@ export class Grid extends AsterElement {
|
|
|
2150
3568
|
* saves first, and stays if it can't.
|
|
2151
3569
|
*/
|
|
2152
3570
|
async #startEdit(key, col) {
|
|
2153
|
-
if (this.#editing.peek()?.key === key)
|
|
3571
|
+
if (!this.#editEnabled() || this.#editing.peek()?.key === key)
|
|
3572
|
+
return;
|
|
3573
|
+
const columns = this.#visibleColumns(this.#view.peek());
|
|
3574
|
+
const field = columns[col]?.field;
|
|
3575
|
+
const type = this.#collection?.type;
|
|
3576
|
+
// Cell mode edits the pressed cell; a cell that isn't editable starts nothing.
|
|
3577
|
+
if (this.editMode === "cell" && (!field || !this.#fieldEditable(field) || type?.field(field)?.computed))
|
|
3578
|
+
return;
|
|
3579
|
+
if (!this.emit("edit-start", { key, field }, { cancelable: true }))
|
|
2154
3580
|
return;
|
|
2155
3581
|
if (!(await this.#endEdit(true)))
|
|
2156
3582
|
return;
|
|
@@ -2162,7 +3588,18 @@ export class Grid extends AsterElement {
|
|
|
2162
3588
|
const rules = this.#rules.peek();
|
|
2163
3589
|
if (scope && Object.keys(rules).length)
|
|
2164
3590
|
edit.applyRules(scope, `${this.#publish.peek() || "grid"}.row`, rules);
|
|
2165
|
-
|
|
3591
|
+
// The draft's values change signal is the single source for cell-change.
|
|
3592
|
+
let before = { ...edit.values.peek() };
|
|
3593
|
+
const stopChanges = effect(() => {
|
|
3594
|
+
const values = edit.values.value;
|
|
3595
|
+
for (const [name, value] of Object.entries(values)) {
|
|
3596
|
+
if (JSON.stringify(value) === JSON.stringify(before[name]))
|
|
3597
|
+
continue;
|
|
3598
|
+
this.emit("cell-change", { key, field: name, value, previous: before[name] }, { cancelable: false });
|
|
3599
|
+
}
|
|
3600
|
+
before = { ...values };
|
|
3601
|
+
});
|
|
3602
|
+
this.#editing.value = { key, field: field ?? "", column: col, edit, stop: () => (stopChanges(), withdraw?.(), edit.dispose()) };
|
|
2166
3603
|
requestAnimationFrame(() => this.#focusEditor(key, col));
|
|
2167
3604
|
}
|
|
2168
3605
|
#focusEditor(key, col) {
|
|
@@ -2170,8 +3607,8 @@ export class Grid extends AsterElement {
|
|
|
2170
3607
|
// A user may choose another control before deferred initial focus runs.
|
|
2171
3608
|
if (row?.contains(document.activeElement))
|
|
2172
3609
|
return;
|
|
2173
|
-
const cells = row ? [...row.querySelectorAll('[role="gridcell"]')] : [];
|
|
2174
|
-
const target = cells
|
|
3610
|
+
const cells = row ? [...row.querySelectorAll('[role="gridcell"][data-col]')] : [];
|
|
3611
|
+
const target = cells.find((cell) => Number(cell.dataset.col) === col)?.querySelector("input:not([readonly]), textarea:not([readonly]), [role=combobox]:not([aria-readonly])") ??
|
|
2175
3612
|
row?.querySelector("input:not([readonly]), textarea:not([readonly]), [role=combobox]:not([aria-readonly])");
|
|
2176
3613
|
target?.focus();
|
|
2177
3614
|
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
|
|
@@ -2187,8 +3624,22 @@ export class Grid extends AsterElement {
|
|
|
2187
3624
|
if (!editing || (only && editing !== only))
|
|
2188
3625
|
return true;
|
|
2189
3626
|
if (save && editing.edit.dirty.peek()) {
|
|
3627
|
+
const changes = {};
|
|
3628
|
+
const values = editing.edit.values.peek();
|
|
3629
|
+
const baseline = editing.edit.baseline.peek();
|
|
3630
|
+
for (const [field, value] of Object.entries(values)) {
|
|
3631
|
+
if (JSON.stringify(value) !== JSON.stringify(baseline[field]))
|
|
3632
|
+
changes[field] = value;
|
|
3633
|
+
}
|
|
3634
|
+
if (!this.emit("edit-save", { key: editing.key, changes }, { cancelable: true }))
|
|
3635
|
+
return false;
|
|
2190
3636
|
const saved = await editing.edit.save();
|
|
2191
3637
|
if (!saved) {
|
|
3638
|
+
this.emit("edit-error", {
|
|
3639
|
+
key: editing.key,
|
|
3640
|
+
error: editing.edit.formError.peek() ?? "The row couldn’t be saved.",
|
|
3641
|
+
errors: editing.edit.errors.peek(),
|
|
3642
|
+
}, { cancelable: false });
|
|
2192
3643
|
requestAnimationFrame(() => this.querySelector(`.aster-grid-row[data-editing] [aria-invalid="true"]`)?.focus());
|
|
2193
3644
|
return false;
|
|
2194
3645
|
}
|
|
@@ -2258,9 +3709,14 @@ export class Grid extends AsterElement {
|
|
|
2258
3709
|
return;
|
|
2259
3710
|
e.preventDefault();
|
|
2260
3711
|
const index = Number(row.dataset.index);
|
|
2261
|
-
const col = Number(cell.
|
|
2262
|
-
|
|
2263
|
-
|
|
3712
|
+
const col = Number(cell.dataset.col ?? 0);
|
|
3713
|
+
if (row.dataset.key && col >= 0) {
|
|
3714
|
+
const target = { key: row.dataset.key, field: this.#visibleColumns(this.#view.peek())[col]?.field, row: index, column: col, originalEvent: e };
|
|
3715
|
+
if (!this.emit("cell-context-menu", target))
|
|
3716
|
+
return;
|
|
3717
|
+
}
|
|
3718
|
+
this.#activate(index, Math.max(0, col));
|
|
3719
|
+
this.#openRowMenu(index, Math.max(0, col), { x: e.clientX, y: e.clientY });
|
|
2264
3720
|
}
|
|
2265
3721
|
#click(e) {
|
|
2266
3722
|
const cell = e.target.closest('[role="gridcell"]');
|
|
@@ -2270,14 +3726,32 @@ export class Grid extends AsterElement {
|
|
|
2270
3726
|
if (e.target.closest("button, input, select, textarea, a[href], [contenteditable], [data-editing]"))
|
|
2271
3727
|
return;
|
|
2272
3728
|
const index = Number(row.dataset.index);
|
|
2273
|
-
const
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
3729
|
+
const column = Number(cell.dataset.col ?? 0);
|
|
3730
|
+
const field = this.#visibleColumns(this.#view.peek())[column]?.field;
|
|
3731
|
+
const layout = untracked(() => this.#layout());
|
|
3732
|
+
const item = layout.at(index);
|
|
3733
|
+
if (item.kind === "row" && item.key !== undefined && column >= 0) {
|
|
3734
|
+
const target = { key: item.key, field, row: index, column, originalEvent: e };
|
|
3735
|
+
// Both action events fire; either may veto the default.
|
|
3736
|
+
const cellOk = this.emit("cell-click", target);
|
|
3737
|
+
const rowOk = this.emit("row-click", { key: item.key, row: index, originalEvent: e });
|
|
3738
|
+
if (!cellOk || !rowOk)
|
|
3739
|
+
return;
|
|
3740
|
+
// Selection indices are collection-relative; later pages render rows that start mid-collection.
|
|
3741
|
+
this.#choose(item.key, (layout.first ?? 0) + index, e.shiftKey, e.ctrlKey || e.metaKey);
|
|
3742
|
+
}
|
|
3743
|
+
this.#activate(index, row.classList.contains("aster-grid-group") ? this.#active.peek().col : Math.max(0, column));
|
|
2277
3744
|
}
|
|
2278
3745
|
#key(e, columns) {
|
|
2279
3746
|
if (e.target.closest(".aster-grid-filters, .aster-grid-panel, .aster-grid-selection, [data-editing], .aster-grid-group button"))
|
|
2280
3747
|
return;
|
|
3748
|
+
// Focus can still be on the grid during the frame an edit starts in; Escape ends it all the same.
|
|
3749
|
+
if (e.key === "Escape" && this.#editing.peek()) {
|
|
3750
|
+
e.preventDefault();
|
|
3751
|
+
this.#editing.peek().edit.revert();
|
|
3752
|
+
void this.#endEdit(false, true);
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
2281
3755
|
const layout = untracked(() => this.#layout());
|
|
2282
3756
|
const count = layout.keys.length;
|
|
2283
3757
|
const { row, col } = this.#active.peek();
|
|
@@ -2297,7 +3771,7 @@ export class Grid extends AsterElement {
|
|
|
2297
3771
|
}
|
|
2298
3772
|
if (e.key === " " && this.#selection.peek() && item.kind === "row" && item.key !== undefined) {
|
|
2299
3773
|
e.preventDefault();
|
|
2300
|
-
this.#choose(item.key, row, e.shiftKey, this.#selection.peek().multiple);
|
|
3774
|
+
this.#choose(item.key, (layout.first ?? 0) + row, e.shiftKey, this.#selection.peek().multiple);
|
|
2301
3775
|
return;
|
|
2302
3776
|
}
|
|
2303
3777
|
const page = Math.max(1, Math.floor(this.#viewport.peek() / this.#heights.estimate) - 1);
|
|
@@ -2308,8 +3782,15 @@ export class Grid extends AsterElement {
|
|
|
2308
3782
|
break;
|
|
2309
3783
|
case "ArrowUp":
|
|
2310
3784
|
if (row === 0) {
|
|
2311
|
-
|
|
2312
|
-
|
|
3785
|
+
// Above the first row: the filter row when shown, else the header when it is reachable.
|
|
3786
|
+
if (this.#filtersShown()) {
|
|
3787
|
+
e.preventDefault();
|
|
3788
|
+
this.#active.value = { row: FILTER_ROW, col };
|
|
3789
|
+
}
|
|
3790
|
+
else if (this.#headerShown()) {
|
|
3791
|
+
e.preventDefault();
|
|
3792
|
+
this.#active.value = { row: HEADER, col };
|
|
3793
|
+
}
|
|
2313
3794
|
return;
|
|
2314
3795
|
}
|
|
2315
3796
|
next = { row: row - 1, col };
|
|
@@ -2336,10 +3817,11 @@ export class Grid extends AsterElement {
|
|
|
2336
3817
|
case "F2": {
|
|
2337
3818
|
if (item.kind !== "row" || item.key === undefined)
|
|
2338
3819
|
return;
|
|
2339
|
-
if (e.key === "F2" && this.#
|
|
3820
|
+
if (e.key === "F2" && this.#selectionAllowed() && this.#selection.peek() && (!this.#editEnabled() || e.shiftKey)) {
|
|
2340
3821
|
e.preventDefault();
|
|
2341
3822
|
this.#selectionInside.value = item.key;
|
|
2342
|
-
|
|
3823
|
+
const pick = this.#selectionColumnOn() ? ".aster-grid-select input" : ".aster-grid-pick";
|
|
3824
|
+
queueMicrotask(() => this.querySelector(`.aster-grid-row[data-key="${CSS.escape(item.key)}"] ${pick}`)?.focus());
|
|
2343
3825
|
return;
|
|
2344
3826
|
}
|
|
2345
3827
|
if (e.key === "Enter" && !this.emit("row-open", { key: item.key })) {
|
|
@@ -2352,7 +3834,7 @@ export class Grid extends AsterElement {
|
|
|
2352
3834
|
this.expand(item.key);
|
|
2353
3835
|
return;
|
|
2354
3836
|
}
|
|
2355
|
-
if (!this.#
|
|
3837
|
+
if (!this.#editEnabled())
|
|
2356
3838
|
return;
|
|
2357
3839
|
e.preventDefault();
|
|
2358
3840
|
this.#startEdit(item.key, col);
|
|
@@ -2382,27 +3864,89 @@ export class Grid extends AsterElement {
|
|
|
2382
3864
|
if (this.#selection.peek() && e.shiftKey && target !== row) {
|
|
2383
3865
|
const destination = layout.at(target);
|
|
2384
3866
|
if (destination.kind === "row" && destination.key !== undefined)
|
|
2385
|
-
this.#choose(destination.key, target, true, false);
|
|
3867
|
+
this.#choose(destination.key, (layout.first ?? 0) + target, true, false);
|
|
2386
3868
|
}
|
|
2387
3869
|
this.#activate(target, Math.max(0, Math.min(columns - 1, next.col)));
|
|
2388
3870
|
}
|
|
2389
3871
|
#sortText(view) {
|
|
2390
|
-
const
|
|
2391
|
-
if (!
|
|
2392
|
-
return "
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
3872
|
+
const sort = view.sort.filter((key) => this.#sortable(key.field));
|
|
3873
|
+
if (!sort.length)
|
|
3874
|
+
return this.#text("notSorted");
|
|
3875
|
+
const entries = sort.map((key) => this.#text("sortEntry", {
|
|
3876
|
+
field: this.#title(key.field),
|
|
3877
|
+
direction: key.descending ? this.#text("directionDescending") : this.#text("directionAscending"),
|
|
3878
|
+
}));
|
|
3879
|
+
return this.#text("sortedBy", { sorts: entries.join(this.#text("sortSeparator")) });
|
|
3880
|
+
}
|
|
3881
|
+
/** The first column's indent, optional opener and node icon in a tree grid. */
|
|
2396
3882
|
#treeLead(node) {
|
|
3883
|
+
const options = this.#treeOptions.value;
|
|
2397
3884
|
const folder = node.folder !== false && node.key !== undefined;
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
3885
|
+
const icon = folder ? options?.folderIcon : options?.nodeIcon;
|
|
3886
|
+
const opener = options?.showOpener !== false;
|
|
3887
|
+
return html `<span class="aster-grid-tree" style=${`--level:${node.level - 1}`}>${opener
|
|
3888
|
+
? folder
|
|
3889
|
+
? html `<button type="button" class="aster-grid-opener" tabindex="-1" aria-hidden="true" ?data-open=${node.open}
|
|
3890
|
+
@pointerdown=${(e) => e.stopPropagation()}
|
|
3891
|
+
@click=${(e) => {
|
|
3892
|
+
e.stopPropagation();
|
|
3893
|
+
void this.#tree?.toggle(node.key);
|
|
3894
|
+
}}><svg viewBox="0 0 10 10"><path d="M3.5 2 6.5 5l-3 3"/></svg></button>`
|
|
3895
|
+
: html `<span class="aster-grid-opener" aria-hidden="true"></span>`
|
|
3896
|
+
: nothing}
|
|
3897
|
+
${icon ? html `<span class="aster-grid-tree-icon" aria-hidden="true">${icon}</span>` : nothing}</span>`;
|
|
3898
|
+
}
|
|
3899
|
+
/**
|
|
3900
|
+
* Opens the levels a tree loads with: `autoOpen` levels below the top, or
|
|
3901
|
+
* every folder for Expand all. Bounded by `openLimit` and by the first page
|
|
3902
|
+
* of each level.
|
|
3903
|
+
*/
|
|
3904
|
+
async #expandLevels(tree, depth, limit) {
|
|
3905
|
+
let opened = 0;
|
|
3906
|
+
let parents = [undefined];
|
|
3907
|
+
for (let level = 0; level < depth && parents.length > 0 && opened < limit; level++) {
|
|
3908
|
+
const next = [];
|
|
3909
|
+
for (const parent of parents) {
|
|
3910
|
+
const collection = tree.level(parent);
|
|
3911
|
+
await collection.ensure(0, collection.pageSize);
|
|
3912
|
+
if (!this.isConnected || this.#tree !== tree)
|
|
3913
|
+
return opened;
|
|
3914
|
+
for (const key of collection.keys.peek()) {
|
|
3915
|
+
if (key === undefined || tree.isOpen(key))
|
|
3916
|
+
continue;
|
|
3917
|
+
const node = tree.rows.peek().find((row) => row.key === key);
|
|
3918
|
+
if (node?.folder === false)
|
|
3919
|
+
continue;
|
|
3920
|
+
await tree.expand(key);
|
|
3921
|
+
next.push(key);
|
|
3922
|
+
if (++opened >= limit)
|
|
3923
|
+
return opened;
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3926
|
+
parents = next;
|
|
3927
|
+
}
|
|
3928
|
+
return opened;
|
|
3929
|
+
}
|
|
3930
|
+
/** Expand all: every folder in the loaded pages, up to `openLimit`. */
|
|
3931
|
+
async #expandAll() {
|
|
3932
|
+
const tree = this.#tree;
|
|
3933
|
+
if (!tree)
|
|
3934
|
+
return;
|
|
3935
|
+
const limit = this.#treeOptions.peek()?.openLimit ?? 200;
|
|
3936
|
+
const opened = await this.#expandLevels(tree, Number.POSITIVE_INFINITY, limit);
|
|
3937
|
+
announce(opened >= limit ? this.#text("foldersLimit", { limit }) : this.#text("foldersOpened"));
|
|
3938
|
+
}
|
|
3939
|
+
#collapseAll() {
|
|
3940
|
+
this.#tree?.setOpen([]);
|
|
3941
|
+
if (this.#tree)
|
|
3942
|
+
announce(this.#text("foldersClosed"));
|
|
3943
|
+
}
|
|
3944
|
+
/** Opens the configured initial depth once, when the tree is built. */
|
|
3945
|
+
#autoOpen(tree, depth) {
|
|
3946
|
+
if (!(depth > 0))
|
|
3947
|
+
return;
|
|
3948
|
+
const limit = this.#treeOptions.peek()?.openLimit ?? 200;
|
|
3949
|
+
void this.#expandLevels(tree, depth, limit).catch(() => { });
|
|
2406
3950
|
}
|
|
2407
3951
|
/**
|
|
2408
3952
|
* → on a closed folder opens it, on an open one goes to its first child;
|
|
@@ -2435,7 +3979,15 @@ export class Grid extends AsterElement {
|
|
|
2435
3979
|
return true;
|
|
2436
3980
|
}
|
|
2437
3981
|
#filtersShown() {
|
|
2438
|
-
|
|
3982
|
+
const filterRow = this.#chrome.value.filterRow ?? "auto";
|
|
3983
|
+
if (filterRow === false)
|
|
3984
|
+
return false;
|
|
3985
|
+
if (filterRow === true)
|
|
3986
|
+
return true;
|
|
3987
|
+
return this.#showFilters.value || Object.keys(this.#view.value?.filters ?? {}).length > 0;
|
|
3988
|
+
}
|
|
3989
|
+
#headerShown() {
|
|
3990
|
+
return this.#chrome.value.header !== false;
|
|
2439
3991
|
}
|
|
2440
3992
|
#groupKey(e, row, col) {
|
|
2441
3993
|
const grouping = this.#grouping;
|
|
@@ -2485,7 +4037,7 @@ export class Grid extends AsterElement {
|
|
|
2485
4037
|
* row (Enter or F2 goes into the filter; Escape there comes back).
|
|
2486
4038
|
*/
|
|
2487
4039
|
#headKey(e, row, col, columns, count) {
|
|
2488
|
-
const field = this.#view.peek()
|
|
4040
|
+
const field = this.#visibleColumns(this.#view.peek())[col]?.field;
|
|
2489
4041
|
const move = (to) => {
|
|
2490
4042
|
e.preventDefault();
|
|
2491
4043
|
this.#active.value = { row: to.row, col: Math.max(0, Math.min(columns - 1, to.col)) };
|
|
@@ -2500,7 +4052,7 @@ export class Grid extends AsterElement {
|
|
|
2500
4052
|
case "End":
|
|
2501
4053
|
return move({ row, col: columns - 1 });
|
|
2502
4054
|
case "ArrowUp":
|
|
2503
|
-
return row === FILTER_ROW ? move({ row: HEADER, col }) : undefined;
|
|
4055
|
+
return row === FILTER_ROW && this.#headerShown() ? move({ row: HEADER, col }) : undefined;
|
|
2504
4056
|
case "ArrowDown":
|
|
2505
4057
|
if (row === HEADER && this.#filtersShown())
|
|
2506
4058
|
return move({ row: FILTER_ROW, col });
|
|
@@ -2524,7 +4076,7 @@ export class Grid extends AsterElement {
|
|
|
2524
4076
|
return;
|
|
2525
4077
|
case "F10":
|
|
2526
4078
|
case "ContextMenu": {
|
|
2527
|
-
if ((e.key === "F10" && !e.shiftKey) || row !== HEADER || !field)
|
|
4079
|
+
if ((e.key === "F10" && !e.shiftKey) || row !== HEADER || !field || !this.showHeaderMenu)
|
|
2528
4080
|
return;
|
|
2529
4081
|
e.preventDefault();
|
|
2530
4082
|
const opener = this.querySelector(`.aster-grid-header[data-field="${CSS.escape(field)}"] .aster-grid-header-menu`);
|
|
@@ -2542,7 +4094,7 @@ export class Grid extends AsterElement {
|
|
|
2542
4094
|
if (!cell)
|
|
2543
4095
|
return;
|
|
2544
4096
|
e.preventDefault();
|
|
2545
|
-
this.#active.value = { row: FILTER_ROW, col: Number(cell.
|
|
4097
|
+
this.#active.value = { row: FILTER_ROW, col: Number(cell.dataset.col ?? 0) };
|
|
2546
4098
|
this.querySelector(".aster-grid-scroll")?.focus();
|
|
2547
4099
|
}
|
|
2548
4100
|
/** Reveals the active cell; only an owned grid without shared selection chooses its row. */
|
|
@@ -2558,7 +4110,7 @@ export class Grid extends AsterElement {
|
|
|
2558
4110
|
}
|
|
2559
4111
|
if (item.kind === "row" && item.key !== undefined) {
|
|
2560
4112
|
this.#activeKey.value = item.key;
|
|
2561
|
-
if (this.#
|
|
4113
|
+
if (this.#selectionAllowed() && !this.#selection.peek() && !this.#external())
|
|
2562
4114
|
this.#selected.value = item.key;
|
|
2563
4115
|
}
|
|
2564
4116
|
else
|