@getaster/elements 0.3.5 → 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 +1810 -308
- 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 +181 -10
- package/styles/base.css +14 -0
- package/styles/contract.ts +16 -0
- package/styles/elements.css +167 -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,116 +1870,177 @@ 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.#
|
|
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);
|
|
938
1947
|
const tool = (name, label) => html `<button type="button" class="aster-grid-tool"
|
|
939
1948
|
style=${anchorStyle(`${this.#id}-tool-${name}`)}
|
|
940
1949
|
aria-expanded=${panel === name ? "true" : "false"} aria-controls=${`${this.#id}-${name}`}
|
|
941
1950
|
@click=${() => (this.#panel.value = panel === name ? undefined : name)}>${label}</button>`;
|
|
942
|
-
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 })}
|
|
943
1952
|
@keydown=${(e) => this.#panelKey(e)}>
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
: layout.atLeast !== undefined
|
|
949
|
-
? `${fmt(layout.atLeast)}+ rows`
|
|
950
|
-
: `${fmt(count)} ${count === 1 ? "row" : "rows"}${layout.groups === undefined ? "" : ` in ${fmt(layout.groups)} ${layout.groups === 1 ? "group" : "groups"}`}`}</span>
|
|
951
|
-
${tool("sort", "Sort")}
|
|
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"))}
|
|
952
1957
|
${this.#selectionNote.value ? html `<span class="aster-grid-note" role="status">${this.#selectionNote.value}</span>` : nothing}
|
|
953
|
-
${this.#grouping?.root.error.value !== undefined ? html `<span class="aster-grid-note aster-error" role="status"
|
|
954
|
-
<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}
|
|
955
1960
|
${this.#refused.value ? html `<span class="aster-grid-note aster-grid-refused">${this.#refused.value}</span>` : nothing}
|
|
956
1961
|
${this.#tree?.searching.value
|
|
957
|
-
? html `<span class="aster-grid-note" role="status"
|
|
1962
|
+
? html `<span class="aster-grid-note" role="status">${this.#text("treeSearching")}</span>`
|
|
958
1963
|
: this.#tree?.tooBroad.value
|
|
959
|
-
? html `<span class="aster-grid-note" role="status"
|
|
1964
|
+
? html `<span class="aster-grid-note" role="status">${this.#text("treeTooBroad")}</span>`
|
|
960
1965
|
: this.#tree?.error.value !== undefined
|
|
961
|
-
? 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>`
|
|
962
1967
|
: nothing}
|
|
963
1968
|
${grouped.map((field, i) => html `<span class="aster-grid-group-tools" data-group-field=${field}>
|
|
964
|
-
<button type="button" class="aster-grid-tool" data-group-action="remove" aria-pressed="true" title=${grouped.length === 1 ? "
|
|
965
|
-
@click=${() => this.#setView(setGroup(view,
|
|
966
|
-
${grouped.length > 1 ? html `<button type="button" class="aster-grid-tool" data-group-action="earlier" aria-label=${
|
|
967
|
-
@click=${() => this.#reorderGroup(
|
|
968
|
-
<button type="button" class="aster-grid-tool" data-group-action="later" aria-label=${
|
|
969
|
-
@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>
|
|
970
1979
|
</span>`)}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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}
|
|
975
1987
|
${view.criteria ? html `<span class="aster-grid-expression" role="status">${this.#criteriaCheck.value?.valid === false
|
|
976
|
-
? "
|
|
1988
|
+
? this.#text("criteriaNeedsRepair")
|
|
977
1989
|
: describeCriteria(view.criteria, this.#collection.type)}</span>` : nothing}
|
|
978
|
-
${tool("columns", "
|
|
979
|
-
${this.#persist.value ? tool("views", "
|
|
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}
|
|
980
1993
|
${panel === "sort" ? this.#sortPanel(view) : panel === "columns" ? this.#columnsPanel(view) : panel === "views" ? this.#viewsPanel(view) : nothing}
|
|
981
1994
|
</div>`;
|
|
982
1995
|
}
|
|
983
1996
|
#sortPanel(view) {
|
|
984
|
-
const
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
.
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
//
|
|
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];
|
|
991
2005
|
for (const key of view.sort) {
|
|
992
2006
|
if (shown.has(key.field))
|
|
993
2007
|
continue;
|
|
994
2008
|
shown.add(key.field);
|
|
995
|
-
rows.push({ field: key.field, offered: false });
|
|
2009
|
+
rows.push({ field: key.field, offered: false, refusal: this.#sortRefusal(key.field) });
|
|
996
2010
|
}
|
|
997
|
-
const
|
|
2011
|
+
const active = view.sort.filter((key) => this.#sortable(key.field));
|
|
2012
|
+
const priority = new Map(active.map((key, i) => [key.field, i + 1]));
|
|
998
2013
|
const toggle = (field) => {
|
|
999
2014
|
const key = view.sort.find((sortKey) => sortKey.field === field);
|
|
1000
2015
|
const sorted = key !== undefined;
|
|
1001
2016
|
const descending = key?.descending === true;
|
|
1002
2017
|
const name = fieldTitle(field);
|
|
1003
|
-
const action = sorted
|
|
2018
|
+
const action = sorted
|
|
2019
|
+
? this.#text("sortActionNow", { field: name, direction: descending ? this.#text("directionDescending") : this.#text("directionAscending") })
|
|
2020
|
+
: this.#text("sortAction", { field: name });
|
|
1004
2021
|
return html `<button type="button" class="aster-grid-sort-direction" aria-label=${action} aria-pressed=${sorted ? "true" : "false"}
|
|
1005
|
-
title=${`${action}. ${sorted ? "
|
|
1006
|
-
@click=${() => this.#setView(sortDirection(view, field, sorted ? !descending : false,
|
|
2022
|
+
title=${`${action}. ${sorted ? this.#text("sortReverse") : this.#text("sortAscending")}.`}
|
|
2023
|
+
@click=${() => this.#setView(sortDirection(view, field, sorted ? !descending : false, this.#multiSort()))}>
|
|
1007
2024
|
<svg viewBox="0 0 10 10" aria-hidden="true"><path d=${sorted
|
|
1008
2025
|
? descending ? "M2 3.5 5 6.5l3-3" : "M2 6.5 5 3.5l3 3"
|
|
1009
2026
|
: "M2 3.5 5 .5l3 3M2 6.5 5 9.5l3-3"}/></svg>
|
|
1010
2027
|
</button>`;
|
|
1011
2028
|
};
|
|
1012
2029
|
return html `<div class="aster-grid-panel aster-grid-sort-panel aster-popover" popover="manual" ${openWhen(true)}
|
|
1013
|
-
style=${placeStyle(`${this.#id}-tool-sort`)} id=${`${this.#id}-sort`} role="group" aria-label
|
|
2030
|
+
style=${placeStyle(`${this.#id}-tool-sort`)} id=${`${this.#id}-sort`} role="group" aria-label=${this.#text("sortRows")}
|
|
1014
2031
|
@keydown=${(e) => this.#panelKey(e)}>
|
|
1015
|
-
<p class="aster-grid-panel-title"
|
|
2032
|
+
<p class="aster-grid-panel-title">${this.#text("sortRows")}</p>
|
|
1016
2033
|
${rows.length === 0
|
|
1017
|
-
? html `<p class="aster-grid-panel-note"
|
|
2034
|
+
? html `<p class="aster-grid-panel-note">${this.#text("noSortableColumns")}</p>`
|
|
1018
2035
|
: html `<ul>
|
|
1019
|
-
${rows.map(({ field, offered }) => html `<li data-field=${field}>
|
|
1020
|
-
<span class="aster-grid-sort-name"><span class="aster-grid-sort-title">${fieldTitle(field)}</span
|
|
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>
|
|
1021
2039
|
<span class="aster-grid-sort-actions">
|
|
1022
|
-
<small class="aster-grid-sort-priority" aria-hidden="true">${priority.has(field) &&
|
|
1023
|
-
${priority.has(field) &&
|
|
1024
|
-
${toggle(field)}
|
|
1025
|
-
<button type="button" class="aster-grid-sort-clear" aria-label=${
|
|
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)}
|
|
1026
2044
|
@click=${(e) => {
|
|
1027
2045
|
// Clearing disables this button, so focus a control that survives the render.
|
|
1028
2046
|
if (offered)
|
|
@@ -1041,29 +2059,33 @@ export class Grid extends AsterElement {
|
|
|
1041
2059
|
const first = this.querySelector(".aster-grid-sort-panel .aster-grid-sort-direction");
|
|
1042
2060
|
(first ?? this.querySelector(`.aster-grid-tool[aria-controls="${this.#id}-sort"]`))?.focus();
|
|
1043
2061
|
this.#setView({ ...view, sort: [] });
|
|
1044
|
-
}}
|
|
2062
|
+
}}>${this.#text("clearSorting")}</button>
|
|
1045
2063
|
</div>`;
|
|
1046
2064
|
}
|
|
1047
2065
|
#columnsPanel(view) {
|
|
1048
|
-
const
|
|
1049
|
-
const
|
|
1050
|
-
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));
|
|
1051
2068
|
return html `<div class="aster-grid-panel aster-popover" popover="manual" ${openWhen(true)} style=${placeStyle(`${this.#id}-tool-columns`)}
|
|
1052
|
-
id=${`${this.#id}-columns`} role="group" aria-label
|
|
2069
|
+
id=${`${this.#id}-columns`} role="group" aria-label=${this.#text("columns")}
|
|
1053
2070
|
@keydown=${(e) => this.#panelKey(e)}>
|
|
1054
|
-
<p class="aster-grid-panel-title"
|
|
2071
|
+
<p class="aster-grid-panel-title">${this.#text("columns")}</p>
|
|
1055
2072
|
<ul>
|
|
1056
|
-
${view.columns.map((c, i) =>
|
|
1057
|
-
|
|
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}
|
|
1058
2079
|
@change=${(e) => this.#setView(setHidden(view, c.field, !e.target.checked))}>
|
|
1059
2080
|
${title(c.field)}</label>
|
|
1060
|
-
<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}
|
|
1061
2082
|
@click=${() => this.#setView(moveColumn(view, c.field, i - 1))}>↑</button>
|
|
1062
|
-
<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}
|
|
1063
2084
|
@click=${() => this.#setView(moveColumn(view, c.field, i + 1))}>↓</button>
|
|
1064
|
-
</li
|
|
2085
|
+
</li>`;
|
|
2086
|
+
})}
|
|
1065
2087
|
</ul>
|
|
1066
|
-
<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>
|
|
1067
2089
|
</div>`;
|
|
1068
2090
|
}
|
|
1069
2091
|
#viewsPanel(view) {
|
|
@@ -1078,29 +2100,29 @@ export class Grid extends AsterElement {
|
|
|
1078
2100
|
this.#panel.value = undefined;
|
|
1079
2101
|
};
|
|
1080
2102
|
return html `<div class="aster-grid-panel aster-popover" popover="manual" ${openWhen(true)} style=${placeStyle(`${this.#id}-tool-views`)}
|
|
1081
|
-
id=${`${this.#id}-views`} role="group" aria-label
|
|
2103
|
+
id=${`${this.#id}-views`} role="group" aria-label=${this.#text("views")}
|
|
1082
2104
|
@keydown=${(e) => this.#panelKey(e)}>
|
|
1083
|
-
<p class="aster-grid-panel-title"
|
|
1084
|
-
${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}
|
|
1085
2107
|
<ul>
|
|
1086
2108
|
${names.map((n) => html `<li>
|
|
1087
2109
|
<button type="button" class="aster-grid-panel-view" @click=${() => {
|
|
1088
2110
|
this.#setView(reconcile(saved.named[n], this.#offered ?? view));
|
|
1089
2111
|
this.#panel.value = undefined;
|
|
1090
2112
|
}}>${n}</button>
|
|
1091
|
-
<button type="button" aria-label=${
|
|
2113
|
+
<button type="button" aria-label=${this.#text("deleteView", { name: n })} @click=${() => {
|
|
1092
2114
|
const { [n]: _, ...rest } = saved.named;
|
|
1093
2115
|
this.#saved.value = { ...saved, named: rest };
|
|
1094
|
-
}}
|
|
2116
|
+
}}>${this.#text("delete")}</button>
|
|
1095
2117
|
</li>`)}
|
|
1096
2118
|
</ul>
|
|
1097
2119
|
<form class="aster-grid-panel-save" @submit=${(e) => (e.preventDefault(), save(e.currentTarget))}>
|
|
1098
2120
|
<label class="aster-field" data-ring-owner>
|
|
1099
|
-
<input aria-label
|
|
2121
|
+
<input aria-label=${this.#text("viewName")} placeholder=${this.#text("viewNamePlaceholder")}>
|
|
1100
2122
|
</label>
|
|
1101
|
-
<button type="submit" class="aster-grid-panel-action"
|
|
2123
|
+
<button type="submit" class="aster-grid-panel-action">${this.#text("save")}</button>
|
|
1102
2124
|
</form>
|
|
1103
|
-
<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>
|
|
1104
2126
|
</div>`;
|
|
1105
2127
|
}
|
|
1106
2128
|
#reset() {
|
|
@@ -1118,98 +2140,126 @@ export class Grid extends AsterElement {
|
|
|
1118
2140
|
this.querySelector(`.aster-grid-tool[aria-controls="${this.#id}-${panel}"]`)?.focus();
|
|
1119
2141
|
}
|
|
1120
2142
|
// ── header ──
|
|
1121
|
-
#header(column, field, i, view, numeric, frozen, active) {
|
|
1122
|
-
|
|
1123
|
-
|
|
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;
|
|
1124
2149
|
const ariaSort = sort === 0 ? (key.descending ? "descending" : "ascending") : nothing;
|
|
1125
|
-
const title =
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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}
|
|
1131
2166
|
@pointerdown=${(e) => this.#dragColumn(e, column.field)}
|
|
1132
2167
|
@click=${(e) => this.#sort(column.field, e.shiftKey)}>
|
|
1133
2168
|
<span>${title}</span>
|
|
1134
2169
|
${key
|
|
1135
2170
|
? html `<svg class="aster-grid-sort-mark" viewBox="0 0 10 10" aria-hidden="true">
|
|
1136
2171
|
<path d=${key.descending ? "M2 3.5 5 6.5l3-3" : "M2 6.5 5 3.5l3 3"}/></svg>
|
|
1137
|
-
${
|
|
2172
|
+
${keys.length > 1 ? html `<small>${sort + 1}</small>` : nothing}`
|
|
1138
2173
|
: nothing}
|
|
1139
2174
|
</button>
|
|
1140
|
-
|
|
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 })}
|
|
1141
2177
|
@click=${(e) => this.#openMenu(column.field, e.currentTarget)}>
|
|
1142
2178
|
<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.8 5 6.8l3-3"/></svg>
|
|
1143
|
-
</button
|
|
1144
|
-
|
|
1145
|
-
@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>`}
|
|
1146
2182
|
</div>`;
|
|
1147
2183
|
}
|
|
1148
2184
|
#menuItems(field) {
|
|
1149
2185
|
const view = this.#view.peek();
|
|
1150
|
-
const
|
|
1151
|
-
const title = type.field(field)?.title ?? field;
|
|
2186
|
+
const title = this.#title(field);
|
|
1152
2187
|
const sorted = view.sort.find((s) => s.field === field);
|
|
1153
|
-
const visible = view
|
|
2188
|
+
const visible = this.#visibleColumns(view);
|
|
2189
|
+
const frozen = this.#frozenCount(view, visible);
|
|
1154
2190
|
const at = visible.findIndex((c) => c.field === field);
|
|
1155
2191
|
const set = (v) => this.#setView(v);
|
|
1156
2192
|
const width = (delta) => set(setWidth(view, field, (visible[at]?.width ?? this.#measuredWidth(field)) + delta));
|
|
1157
|
-
const frozenHere = at + 1 ===
|
|
2193
|
+
const frozenHere = at + 1 === frozen;
|
|
1158
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);
|
|
1159
2203
|
return [
|
|
2204
|
+
...custom,
|
|
2205
|
+
...(custom.length ? [{ kind: "separator" }] : []),
|
|
1160
2206
|
...(this.#sortable(field)
|
|
1161
2207
|
? [
|
|
1162
2208
|
{
|
|
1163
2209
|
kind: "group",
|
|
1164
|
-
label: "
|
|
2210
|
+
label: this.#text("menuSort"),
|
|
1165
2211
|
items: [
|
|
1166
|
-
{ kind: "radio", label: "
|
|
1167
|
-
{ kind: "radio", label: "
|
|
1168
|
-
{ kind: "radio", label: "
|
|
1169
|
-
...(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)) }] : []),
|
|
1170
2216
|
],
|
|
1171
2217
|
},
|
|
1172
2218
|
{ kind: "separator" },
|
|
1173
2219
|
]
|
|
1174
2220
|
: []),
|
|
1175
|
-
|
|
1176
|
-
?
|
|
1177
|
-
: []),
|
|
1178
|
-
|
|
1179
|
-
?
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
}
|
|
1185
|
-
|
|
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
|
+
},
|
|
1186
2232
|
{
|
|
1187
2233
|
kind: "submenu",
|
|
1188
|
-
label: "
|
|
2234
|
+
label: this.#text("menuMove"),
|
|
1189
2235
|
items: [
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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")),
|
|
1194
2240
|
],
|
|
1195
2241
|
},
|
|
1196
2242
|
{
|
|
1197
2243
|
kind: "submenu",
|
|
1198
|
-
label: "
|
|
1199
|
-
items:
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
+
],
|
|
1204
2252
|
},
|
|
1205
2253
|
{ kind: "separator" },
|
|
1206
|
-
|
|
1207
|
-
|
|
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") },
|
|
1208
2258
|
];
|
|
1209
2259
|
}
|
|
1210
2260
|
#openMenu(field, anchor, returnTo = anchor) {
|
|
1211
|
-
const title = this.#
|
|
1212
|
-
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);
|
|
1213
2263
|
}
|
|
1214
2264
|
/**
|
|
1215
2265
|
* The row's menu: the page's actions for the record, then the column's
|
|
@@ -1217,7 +2267,7 @@ export class Grid extends AsterElement {
|
|
|
1217
2267
|
*/
|
|
1218
2268
|
#openRowMenu(row, col, point) {
|
|
1219
2269
|
const item = untracked(() => this.#layout()).at(row);
|
|
1220
|
-
const field = this.#view.peek()
|
|
2270
|
+
const field = this.#visibleColumns(this.#view.peek())[col]?.field;
|
|
1221
2271
|
if (!field)
|
|
1222
2272
|
return;
|
|
1223
2273
|
const title = this.#collection.type.field(field)?.title ?? field;
|
|
@@ -1227,20 +2277,39 @@ export class Grid extends AsterElement {
|
|
|
1227
2277
|
const items = [
|
|
1228
2278
|
...actions,
|
|
1229
2279
|
...(actions.length ? [{ kind: "separator" }] : []),
|
|
1230
|
-
{ kind: "submenu", label:
|
|
2280
|
+
{ kind: "submenu", label: this.#text("rowColumn", { field: title }), items: this.#menuItems(field) },
|
|
1231
2281
|
];
|
|
1232
2282
|
const cell = this.querySelector(`#${CSS.escape(`${this.#id}-${row}-${col}`)}`);
|
|
1233
2283
|
const rect = cell?.getBoundingClientRect();
|
|
1234
2284
|
const at = point ?? { x: rect?.left ?? 0, y: rect?.bottom ?? 0 };
|
|
1235
|
-
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"));
|
|
1236
2286
|
}
|
|
1237
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
|
+
}
|
|
1238
2292
|
#filter(column, field, view) {
|
|
1239
2293
|
if (!field)
|
|
1240
2294
|
return nothing;
|
|
1241
|
-
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);
|
|
1242
2300
|
const current = view.filters[column.field];
|
|
1243
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
|
+
}
|
|
1244
2313
|
const pick = (options, value, to) => html `<aster-choice .label=${`Filter ${title}`} .placeholder=${"Any"} .options=${[{ value: "\u0000any", label: "Any" }, ...options]}
|
|
1245
2314
|
.value=${value ?? "\u0000any"}
|
|
1246
2315
|
@change=${(e) => this.#setView(setFilter(this.#view.peek(), column.field, e.detail === "\u0000any" ? undefined : to(e.detail)))}></aster-choice>`;
|
|
@@ -1258,7 +2327,6 @@ export class Grid extends AsterElement {
|
|
|
1258
2327
|
return pick([{ value: true, label: "Yes" }, { value: false, label: "No" }], chosen, eq);
|
|
1259
2328
|
if (field.allowedValues)
|
|
1260
2329
|
return pick(field.allowedValues.map((a) => ({ value: a.value, label: a.label ?? String(a.value) })), chosen, eq);
|
|
1261
|
-
const ranged = isNumeric(field) || field.type === "Edm.Date";
|
|
1262
2330
|
const text = this.#filterText.value[column.field] ?? (ranged ? rangeText(current) : current && "value" in current ? String(current.value) : "");
|
|
1263
2331
|
const error = this.#filterErrors.value[column.field];
|
|
1264
2332
|
const errorId = `${this.#id}-filter-${column.field}`;
|
|
@@ -1273,14 +2341,14 @@ export class Grid extends AsterElement {
|
|
|
1273
2341
|
this.querySelector(`#${CSS.escape(button)}`)?.focus();
|
|
1274
2342
|
};
|
|
1275
2343
|
return html `<label class="aster-field" data-ring-owner style=${date ? anchorStyle(anchor) : nothing}>
|
|
1276
|
-
<input .value=${live(text)} aria-label=${
|
|
2344
|
+
<input .value=${live(text)} aria-label=${this.#text("filterField", { field: title })} aria-invalid=${error ? "true" : nothing}
|
|
1277
2345
|
aria-describedby=${error ? errorId : nothing}
|
|
1278
2346
|
placeholder=${ranged ? (date ? ">=1 Oct 2026" : ">5 or 1..3") : "Contains"}
|
|
1279
2347
|
@input=${(e) => this.#typeFilter(column.field, field, e.target.value, ranged)}
|
|
1280
2348
|
@keydown=${(e) => e.key === "Enter" && this.#flushFilter(column.field)}
|
|
1281
2349
|
@blur=${() => this.#flushFilter(column.field)}>
|
|
1282
2350
|
${date
|
|
1283
|
-
? 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 })}
|
|
1284
2352
|
aria-haspopup="dialog" aria-expanded=${open ? "true" : "false"} aria-controls=${dialog}
|
|
1285
2353
|
@click=${(e) => {
|
|
1286
2354
|
e.preventDefault();
|
|
@@ -1327,6 +2395,9 @@ export class Grid extends AsterElement {
|
|
|
1327
2395
|
const parsed = ranged
|
|
1328
2396
|
? parseRange(field, text)
|
|
1329
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;
|
|
1330
2401
|
const { [name]: _, ...errors } = this.#filterErrors.peek();
|
|
1331
2402
|
this.#filterErrors.value = parsed.error ? { ...errors, [name]: parsed.error } : errors;
|
|
1332
2403
|
if (!parsed.error)
|
|
@@ -1336,12 +2407,17 @@ export class Grid extends AsterElement {
|
|
|
1336
2407
|
this.#filterTimers.set(name, setTimeout(apply, FILTER_DELAY));
|
|
1337
2408
|
}
|
|
1338
2409
|
#toggleFilters(view) {
|
|
1339
|
-
|
|
2410
|
+
if (this.#chrome.peek().filterRow === false)
|
|
2411
|
+
return;
|
|
2412
|
+
const showing = this.#filtersShown();
|
|
1340
2413
|
if (showing && Object.keys(view.filters).length > 0) {
|
|
1341
2414
|
this.#filterText.value = {};
|
|
1342
2415
|
this.#filterErrors.value = {};
|
|
1343
2416
|
this.#setView({ ...view, filters: {} });
|
|
1344
2417
|
}
|
|
2418
|
+
// A forced row stays open; the button still clears its filters.
|
|
2419
|
+
if (this.#chrome.peek().filterRow === true)
|
|
2420
|
+
return;
|
|
1345
2421
|
this.#showFilters.value = !showing;
|
|
1346
2422
|
}
|
|
1347
2423
|
#optionsFor(field, lookup) {
|
|
@@ -1357,7 +2433,7 @@ export class Grid extends AsterElement {
|
|
|
1357
2433
|
return options;
|
|
1358
2434
|
}
|
|
1359
2435
|
// ── cells ──
|
|
1360
|
-
#cell(column, field, entity, key, ctx) {
|
|
2436
|
+
#cell(column, field, entity, key, ctx, mapped) {
|
|
1361
2437
|
const render = this.#renderers.value[column.field];
|
|
1362
2438
|
if (render)
|
|
1363
2439
|
return render(entity, key);
|
|
@@ -1365,27 +2441,32 @@ export class Grid extends AsterElement {
|
|
|
1365
2441
|
const formatter = this.#formatters.value[column.field];
|
|
1366
2442
|
if (formatter)
|
|
1367
2443
|
return formatter(value, entity, field);
|
|
2444
|
+
if (mapped)
|
|
2445
|
+
return mapped.label;
|
|
1368
2446
|
const lookup = this.#lookups.value[column.field];
|
|
1369
2447
|
if (lookup && value !== null && value !== undefined) {
|
|
1370
2448
|
const related = ctx.lookup(lookup.set, keyText([value])).value;
|
|
1371
2449
|
return related ? String(related[lookup.display] ?? "") : html `<span class="aster-grid-skeleton"></span>`;
|
|
1372
2450
|
}
|
|
1373
|
-
|
|
1374
|
-
return value ? "Yes" : "";
|
|
1375
|
-
if (field?.type === "Edm.Date" && typeof value === "string")
|
|
1376
|
-
return formatDate(value);
|
|
1377
|
-
if (field?.type === "Edm.DateTimeOffset" && typeof value === "string")
|
|
1378
|
-
return absoluteTime(value);
|
|
1379
|
-
return formatValue(field, value);
|
|
2451
|
+
return cellText(field, value, this.#presentation.value.columns?.[column.field]?.format);
|
|
1380
2452
|
}
|
|
1381
2453
|
#external() {
|
|
1382
|
-
|
|
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;
|
|
1383
2461
|
}
|
|
1384
2462
|
#syncBound() {
|
|
1385
2463
|
const collection = this.#external();
|
|
1386
2464
|
const view = this.#view.value;
|
|
1387
2465
|
const base = this.#base.value;
|
|
1388
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;
|
|
1389
2470
|
if (!collection) {
|
|
1390
2471
|
this.#bound = undefined;
|
|
1391
2472
|
return;
|
|
@@ -1407,9 +2488,10 @@ export class Grid extends AsterElement {
|
|
|
1407
2488
|
}
|
|
1408
2489
|
if (!view)
|
|
1409
2490
|
return;
|
|
1410
|
-
const parts = [this.#boundBase, base
|
|
2491
|
+
const parts = [this.#boundBase, base].filter((c) => c !== undefined);
|
|
1411
2492
|
const filter = parts.length > 1 ? { op: "and", args: parts } : parts[0];
|
|
1412
|
-
|
|
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);
|
|
1413
2495
|
if (JSON.stringify([query.filter, query.orderBy]) === JSON.stringify([projected.filter, projected.orderBy]))
|
|
1414
2496
|
return;
|
|
1415
2497
|
const { filter: _filter, orderBy: _orderBy, ...rest } = query;
|
|
@@ -1420,6 +2502,9 @@ export class Grid extends AsterElement {
|
|
|
1420
2502
|
}
|
|
1421
2503
|
#recoverActive() {
|
|
1422
2504
|
this.#version.value;
|
|
2505
|
+
// Paged mode keeps the active row inside the page it chose.
|
|
2506
|
+
if (this.#pagedMode())
|
|
2507
|
+
return;
|
|
1423
2508
|
const grouping = this.#grouping;
|
|
1424
2509
|
if (grouping) {
|
|
1425
2510
|
const rows = grouping.rows.value;
|
|
@@ -1468,19 +2553,20 @@ export class Grid extends AsterElement {
|
|
|
1468
2553
|
});
|
|
1469
2554
|
}
|
|
1470
2555
|
#choose(key, index, extend, toggle) {
|
|
1471
|
-
if (!this.#
|
|
1472
|
-
return;
|
|
2556
|
+
if (!this.#selectionAllowed())
|
|
2557
|
+
return false;
|
|
1473
2558
|
const selection = this.#selection.peek();
|
|
1474
2559
|
if (!selection)
|
|
1475
|
-
return;
|
|
2560
|
+
return false;
|
|
1476
2561
|
let accepted = true;
|
|
1477
2562
|
if (extend && selection.multiple) {
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
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 };
|
|
1484
2570
|
}
|
|
1485
2571
|
else {
|
|
1486
2572
|
if (toggle || selection.ordered)
|
|
@@ -1490,9 +2576,80 @@ export class Grid extends AsterElement {
|
|
|
1490
2576
|
this.#selectionAnchor = { key, index, query: selection.collection.query };
|
|
1491
2577
|
}
|
|
1492
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);
|
|
1493
2650
|
}
|
|
1494
2651
|
#moveSelection(key, delta) {
|
|
1495
|
-
if (!this.#
|
|
2652
|
+
if (!this.#selectionAllowed())
|
|
1496
2653
|
return;
|
|
1497
2654
|
const selection = this.#selection.peek();
|
|
1498
2655
|
if (!selection)
|
|
@@ -1538,6 +2695,88 @@ export class Grid extends AsterElement {
|
|
|
1538
2695
|
?disabled=${index === chosen.length - 1} @click=${() => this.#moveSelection(key, 1)}>↓</button>` : nothing}
|
|
1539
2696
|
</span>`;
|
|
1540
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
|
+
}
|
|
1541
2780
|
// ── data and view ──
|
|
1542
2781
|
/** With `persist`, the person's last view replaces the page's, reconciled with today's columns. */
|
|
1543
2782
|
#restore() {
|
|
@@ -1564,6 +2803,9 @@ export class Grid extends AsterElement {
|
|
|
1564
2803
|
}
|
|
1565
2804
|
/** Stored views have been read; until then, writing would replace them. */
|
|
1566
2805
|
#restored = false;
|
|
2806
|
+
/** The view and effective selection the change events last reported. */
|
|
2807
|
+
#viewBefore;
|
|
2808
|
+
#selectionBefore;
|
|
1567
2809
|
/** Publishes the shared selection or active row, plus view and total. */
|
|
1568
2810
|
#announce() {
|
|
1569
2811
|
const key = this.#publish.value;
|
|
@@ -1592,9 +2834,52 @@ export class Grid extends AsterElement {
|
|
|
1592
2834
|
...(publication?.hidden ? { hidden: publication.hidden } : {}),
|
|
1593
2835
|
});
|
|
1594
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
|
+
}
|
|
1595
2880
|
#setView(view) {
|
|
1596
2881
|
const request = ++this.#viewRequest;
|
|
1597
|
-
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))) {
|
|
1598
2883
|
void this.#endEdit(true).then((saved) => {
|
|
1599
2884
|
if (saved && this.isConnected && request === this.#viewRequest)
|
|
1600
2885
|
this.#commitView(view);
|
|
@@ -1604,8 +2889,8 @@ export class Grid extends AsterElement {
|
|
|
1604
2889
|
this.#commitView(view);
|
|
1605
2890
|
}
|
|
1606
2891
|
#commitView(view) {
|
|
1607
|
-
const beforeGroups =
|
|
1608
|
-
const afterGroups =
|
|
2892
|
+
const beforeGroups = this.#groupedFields(this.#view.peek());
|
|
2893
|
+
const afterGroups = this.#groupedFields(view);
|
|
1609
2894
|
const focused = this.contains(document.activeElement) ? document.activeElement?.closest(".aster-grid-group-tools") : null;
|
|
1610
2895
|
if (focused && JSON.stringify(beforeGroups) !== JSON.stringify(afterGroups)) {
|
|
1611
2896
|
const field = focused.dataset.groupField;
|
|
@@ -1627,6 +2912,7 @@ export class Grid extends AsterElement {
|
|
|
1627
2912
|
if (binding) {
|
|
1628
2913
|
untracked(() => {
|
|
1629
2914
|
this.#selected.value = undefined;
|
|
2915
|
+
this.#ownedOptions = undefined;
|
|
1630
2916
|
if (this.#collection === binding) {
|
|
1631
2917
|
this.#ownsCollection = false;
|
|
1632
2918
|
return;
|
|
@@ -1643,11 +2929,14 @@ export class Grid extends AsterElement {
|
|
|
1643
2929
|
if (!this.#view.peek())
|
|
1644
2930
|
this.view = defaultView(ctx.schema.set(set).type);
|
|
1645
2931
|
});
|
|
1646
|
-
|
|
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)))
|
|
1647
2935
|
return;
|
|
1648
2936
|
const treeOptions = untracked(() => this.#treeOptions.value);
|
|
1649
2937
|
if (treeOptions) {
|
|
1650
2938
|
untracked(() => {
|
|
2939
|
+
this.#ownedOptions = undefined;
|
|
1651
2940
|
this.#tree?.dispose();
|
|
1652
2941
|
const filters = computed(() => {
|
|
1653
2942
|
return this.#query(set, this.#view.value ?? { columns: [], sort: [], filters: {}, frozen: 0 }).filter;
|
|
@@ -1657,9 +2946,10 @@ export class Grid extends AsterElement {
|
|
|
1657
2946
|
...treeOptions,
|
|
1658
2947
|
base: computed(() => this.#base.value),
|
|
1659
2948
|
filter: filters,
|
|
1660
|
-
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 ?? []),
|
|
1661
2950
|
});
|
|
1662
2951
|
this.#collection = this.#tree.level(undefined);
|
|
2952
|
+
this.#autoOpen(this.#tree, treeOptions.autoOpen ?? 0);
|
|
1663
2953
|
this.#version.value++;
|
|
1664
2954
|
});
|
|
1665
2955
|
this.onDisconnect(() => {
|
|
@@ -1673,20 +2963,24 @@ export class Grid extends AsterElement {
|
|
|
1673
2963
|
untracked(() => {
|
|
1674
2964
|
if (this.#ownsCollection && this.#collection)
|
|
1675
2965
|
ctx.release(this.#collection);
|
|
1676
|
-
this.#collection = ctx.collection(query,
|
|
2966
|
+
this.#collection = ctx.collection(query, options);
|
|
1677
2967
|
this.#ownsCollection = true;
|
|
2968
|
+
this.#ownedOptions = optionsKey;
|
|
1678
2969
|
this.#version.value++;
|
|
1679
2970
|
});
|
|
1680
2971
|
}
|
|
2972
|
+
/** The owned collection's data options, so a change recreates it. */
|
|
2973
|
+
#ownedOptions;
|
|
1681
2974
|
/** A new query (filter, sort, grouping, base) starts at the top, with its first row active. */
|
|
1682
2975
|
#toTop() {
|
|
1683
2976
|
const view = this.#view.value;
|
|
1684
2977
|
// Before the view arrives there's no query to compare with.
|
|
1685
2978
|
if (!view)
|
|
1686
2979
|
return;
|
|
1687
|
-
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]);
|
|
1688
2981
|
untracked(() => {
|
|
1689
2982
|
if (this.#lastQuery !== undefined && this.#lastQuery !== key) {
|
|
2983
|
+
this.#page.value = 0;
|
|
1690
2984
|
const scroll = this.querySelector(".aster-grid-scroll");
|
|
1691
2985
|
if (scroll)
|
|
1692
2986
|
scroll.scrollTop = 0;
|
|
@@ -1706,13 +3000,18 @@ export class Grid extends AsterElement {
|
|
|
1706
3000
|
#load() {
|
|
1707
3001
|
const collection = this.#collection;
|
|
1708
3002
|
this.#version.value;
|
|
1709
|
-
if (!collection)
|
|
3003
|
+
if (!collection || this.#deferred())
|
|
1710
3004
|
return;
|
|
1711
3005
|
const layout = this.#layout();
|
|
1712
3006
|
this.#heights.setKeys(layout.keys);
|
|
1713
3007
|
const { start, end } = this.#heights.window(this.#top.value, this.#viewport.value, OVERSCAN);
|
|
1714
3008
|
const wanted = new Map();
|
|
1715
|
-
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)
|
|
1716
3015
|
wanted.set(collection, { from: start, to: Math.max(end, 1) });
|
|
1717
3016
|
else {
|
|
1718
3017
|
// A tree's top level loads before it has lines to look at.
|
|
@@ -1765,7 +3064,25 @@ export class Grid extends AsterElement {
|
|
|
1765
3064
|
},
|
|
1766
3065
|
};
|
|
1767
3066
|
}
|
|
1768
|
-
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) {
|
|
1769
3086
|
const keys = collection.keys.value;
|
|
1770
3087
|
const total = collection.count.value;
|
|
1771
3088
|
return { keys, total, ...atLeast(collection), groups: undefined, at: (i) => ({ kind: "row", key: keys[i], collection, local: i }) };
|
|
@@ -1778,7 +3095,8 @@ export class Grid extends AsterElement {
|
|
|
1778
3095
|
#groupRow(item, index, rowIndex, columns, _view, active) {
|
|
1779
3096
|
const field = this.#collection.type.field(item.field);
|
|
1780
3097
|
const label = this.#groupLabel(item.field, field, item.group.value);
|
|
1781
|
-
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(" / ");
|
|
1782
3100
|
return html `<div class="aster-grid-row aster-grid-group" role="row" aria-rowindex=${rowIndex} data-index=${index}
|
|
1783
3101
|
data-key=${item.id} aria-level=${item.level} aria-posinset=${item.position} aria-setsize=${item.siblings}
|
|
1784
3102
|
aria-expanded=${item.expanded ? "true" : "false"} aria-busy=${item.loading ? "true" : "false"} style=${`--level:${item.level - 1}`}>
|
|
@@ -1786,13 +3104,13 @@ export class Grid extends AsterElement {
|
|
|
1786
3104
|
?data-active=${active} @click=${(e) => { if (!e.target.closest("button"))
|
|
1787
3105
|
this.#toggleGroup(item.id); }}>
|
|
1788
3106
|
<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M3.5 2 6.5 5l-3 3"/></svg>
|
|
1789
|
-
<span class="aster-sr-only">${
|
|
3107
|
+
<span class="aster-sr-only">${this.#title(item.field)}: </span>
|
|
1790
3108
|
<span class="aster-grid-group-label">${label}</span>
|
|
1791
3109
|
<span class="aster-grid-group-count">${new Intl.NumberFormat("en-GB").format(item.group.count)}</span>
|
|
1792
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}
|
|
1793
|
-
${item.loading ? html `<span class="aster-grid-group-loading" role="status"
|
|
1794
|
-
${item.error !== undefined ? html `<span class="aster-error" role="status"
|
|
1795
|
-
<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}
|
|
1796
3114
|
</div>
|
|
1797
3115
|
</div>`;
|
|
1798
3116
|
}
|
|
@@ -1822,17 +3140,31 @@ export class Grid extends AsterElement {
|
|
|
1822
3140
|
if (summary.label)
|
|
1823
3141
|
return summary.label;
|
|
1824
3142
|
if (summary.method === "count")
|
|
1825
|
-
return "
|
|
1826
|
-
const title = this.#
|
|
1827
|
-
|
|
1828
|
-
|
|
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
|
+
}
|
|
1829
3159
|
}
|
|
1830
3160
|
#summaryValue(summary, value) {
|
|
1831
3161
|
if (value === null)
|
|
1832
3162
|
return "—";
|
|
1833
3163
|
if (summary.method === "count" || summary.method === "countdistinct")
|
|
1834
3164
|
return new Intl.NumberFormat("en-GB").format(Number(value));
|
|
1835
|
-
|
|
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);
|
|
1836
3168
|
}
|
|
1837
3169
|
#summaryView() {
|
|
1838
3170
|
const summary = this.#summary;
|
|
@@ -1840,20 +3172,26 @@ export class Grid extends AsterElement {
|
|
|
1840
3172
|
return nothing;
|
|
1841
3173
|
const values = summary.values.value;
|
|
1842
3174
|
const status = summary.status.value;
|
|
1843
|
-
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"}>
|
|
1844
3176
|
<aster-progress-status class="aster-grid-summary-status" .busy=${status === "loading"}
|
|
1845
|
-
.error=${status === "error" ? "
|
|
1846
|
-
.pendingText=${"
|
|
1847
|
-
${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}
|
|
1848
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}
|
|
1849
3181
|
</section>`;
|
|
1850
3182
|
}
|
|
1851
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;
|
|
1852
3188
|
if (value === null)
|
|
1853
3189
|
return "None";
|
|
1854
3190
|
const lookup = this.#lookups.value[name];
|
|
1855
3191
|
if (lookup)
|
|
1856
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);
|
|
1857
3195
|
if (field?.type === "Edm.Boolean")
|
|
1858
3196
|
return value ? "Yes" : "No";
|
|
1859
3197
|
if (field?.type === "Edm.Date" && typeof value === "string")
|
|
@@ -1862,14 +3200,6 @@ export class Grid extends AsterElement {
|
|
|
1862
3200
|
return absoluteTime(value);
|
|
1863
3201
|
return formatValue(field, value);
|
|
1864
3202
|
}
|
|
1865
|
-
#groupable(name) {
|
|
1866
|
-
if (this.#tree || this.#external())
|
|
1867
|
-
return false;
|
|
1868
|
-
const field = this.#collection?.type.field(name);
|
|
1869
|
-
if (!field)
|
|
1870
|
-
return false;
|
|
1871
|
-
return !(field.type === "Edm.String" && !field.allowedValues && (field.maxLength ?? 0) > 500);
|
|
1872
|
-
}
|
|
1873
3203
|
async #toggleGroup(id) {
|
|
1874
3204
|
const grouping = this.#grouping;
|
|
1875
3205
|
if (!grouping)
|
|
@@ -1899,16 +3229,21 @@ export class Grid extends AsterElement {
|
|
|
1899
3229
|
}
|
|
1900
3230
|
void grouping.toggle(id).catch(() => { });
|
|
1901
3231
|
}
|
|
1902
|
-
#reorderGroup(
|
|
3232
|
+
#reorderGroup(field, step) {
|
|
1903
3233
|
const view = this.#view.peek();
|
|
1904
3234
|
if (!view)
|
|
1905
3235
|
return;
|
|
1906
|
-
const
|
|
1907
|
-
const
|
|
1908
|
-
|
|
3236
|
+
const stored = [...groupFields(view)];
|
|
3237
|
+
const effective = this.#groupedFields(view);
|
|
3238
|
+
const neighbour = effective[effective.indexOf(field) + step];
|
|
3239
|
+
if (neighbour === undefined)
|
|
1909
3240
|
return;
|
|
1910
|
-
|
|
1911
|
-
|
|
3241
|
+
const a = stored.indexOf(field);
|
|
3242
|
+
const b = stored.indexOf(neighbour);
|
|
3243
|
+
if (a < 0 || b < 0)
|
|
3244
|
+
return;
|
|
3245
|
+
[stored[a], stored[b]] = [stored[b], stored[a]];
|
|
3246
|
+
this.#setView(setGroup(view, stored));
|
|
1912
3247
|
}
|
|
1913
3248
|
#groupSummaryConfig;
|
|
1914
3249
|
#groupFieldsKey = "";
|
|
@@ -1917,7 +3252,7 @@ export class Grid extends AsterElement {
|
|
|
1917
3252
|
#group() {
|
|
1918
3253
|
const ctx = this.#ctx.value;
|
|
1919
3254
|
const set = this.set;
|
|
1920
|
-
const fields = this.#
|
|
3255
|
+
const fields = this.#groupedFields(this.#view.value);
|
|
1921
3256
|
const key = JSON.stringify([set, fields]);
|
|
1922
3257
|
const summaries = this.#summaries.value;
|
|
1923
3258
|
untracked(() => {
|
|
@@ -1976,9 +3311,9 @@ export class Grid extends AsterElement {
|
|
|
1976
3311
|
this.#anchor = { index, delta };
|
|
1977
3312
|
this.#version.value++;
|
|
1978
3313
|
}
|
|
1979
|
-
#frozenOffsets(columns, frozen) {
|
|
3314
|
+
#frozenOffsets(columns, frozen, base = 0) {
|
|
1980
3315
|
const out = [];
|
|
1981
|
-
let left =
|
|
3316
|
+
let left = base;
|
|
1982
3317
|
for (let i = 0; i < Math.min(frozen, columns.length); i++) {
|
|
1983
3318
|
out.push(left);
|
|
1984
3319
|
left += columns[i].width ?? 0;
|
|
@@ -1996,7 +3331,7 @@ export class Grid extends AsterElement {
|
|
|
1996
3331
|
* doesn't sort until the server can order by the related field.
|
|
1997
3332
|
*/
|
|
1998
3333
|
#sortable(field) {
|
|
1999
|
-
return
|
|
3334
|
+
return this.#sortRefusal(field) === undefined;
|
|
2000
3335
|
}
|
|
2001
3336
|
/**
|
|
2002
3337
|
* Lookup columns sort by the related record's display field, through the
|
|
@@ -2019,15 +3354,22 @@ export class Grid extends AsterElement {
|
|
|
2019
3354
|
return paths;
|
|
2020
3355
|
});
|
|
2021
3356
|
#sort(field, add) {
|
|
2022
|
-
if (this.#dragged
|
|
3357
|
+
if (this.#dragged)
|
|
3358
|
+
return;
|
|
3359
|
+
const refusal = this.#sortRefusal(field);
|
|
3360
|
+
if (refusal) {
|
|
3361
|
+
this.#refused.value = refusal;
|
|
3362
|
+
announce(refusal);
|
|
2023
3363
|
return;
|
|
3364
|
+
}
|
|
3365
|
+
this.#refused.value = undefined;
|
|
2024
3366
|
const view = this.#view.peek();
|
|
2025
3367
|
if (view)
|
|
2026
|
-
this.#setView(sortBy(view, field, add));
|
|
3368
|
+
this.#setView(sortBy(view, field, add && this.#multiSort()));
|
|
2027
3369
|
}
|
|
2028
3370
|
/** Drag the right edge of a header; JS writes only the column's width. */
|
|
2029
3371
|
#resize(e, field) {
|
|
2030
|
-
if (e.button !== 0)
|
|
3372
|
+
if (e.button !== 0 || this.#resizeRefusal(field))
|
|
2031
3373
|
return;
|
|
2032
3374
|
e.preventDefault();
|
|
2033
3375
|
e.stopPropagation();
|
|
@@ -2046,6 +3388,12 @@ export class Grid extends AsterElement {
|
|
|
2046
3388
|
#dragColumn(e, field) {
|
|
2047
3389
|
if (e.button !== 0)
|
|
2048
3390
|
return;
|
|
3391
|
+
const refusal = this.#moveRefusal(field);
|
|
3392
|
+
if (refusal) {
|
|
3393
|
+
this.#refused.value = refusal;
|
|
3394
|
+
announce(refusal);
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
2049
3397
|
const button = e.currentTarget;
|
|
2050
3398
|
const from = e.clientX;
|
|
2051
3399
|
this.#dragged = false;
|
|
@@ -2087,7 +3435,7 @@ export class Grid extends AsterElement {
|
|
|
2087
3435
|
#dragRow(e) {
|
|
2088
3436
|
const tree = this.#tree;
|
|
2089
3437
|
const target = e.target;
|
|
2090
|
-
if (!tree || !this.#treeOptions.peek()?.draggable || e.button !== 0 || !this.#
|
|
3438
|
+
if (!tree || !this.#treeOptions.peek()?.draggable || e.button !== 0 || !this.#editEnabled())
|
|
2091
3439
|
return;
|
|
2092
3440
|
if (target.closest("button, input, textarea, select, a, [data-editing], .aster-grid-expansion, .aster-grid-controls"))
|
|
2093
3441
|
return;
|
|
@@ -2101,13 +3449,26 @@ export class Grid extends AsterElement {
|
|
|
2101
3449
|
let label;
|
|
2102
3450
|
let over;
|
|
2103
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();
|
|
2104
3457
|
const why = (to) => {
|
|
3458
|
+
// Structure refuses first; a drop rule is host policy on top.
|
|
2105
3459
|
for (let k = to; k !== undefined;) {
|
|
2106
3460
|
if (k === key)
|
|
2107
3461
|
return "A row can’t move under itself or its own subtree.";
|
|
2108
3462
|
const parent = tree.entity(k)?.[tree.options.parentField];
|
|
2109
3463
|
k = parent === null || parent === undefined ? undefined : keyText([parent]);
|
|
2110
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
|
+
}
|
|
2111
3472
|
return undefined;
|
|
2112
3473
|
};
|
|
2113
3474
|
pressDrag(e, { distance: 6, escape: true, capture: scroll, scroller: () => scroll, inset: () => this.querySelector(".aster-grid-headgroup")?.offsetHeight ?? 0 }, {
|
|
@@ -2121,7 +3482,7 @@ export class Grid extends AsterElement {
|
|
|
2121
3482
|
this.toggleAttribute("data-dragging", true);
|
|
2122
3483
|
if (touch)
|
|
2123
3484
|
this.#lifted.value = key;
|
|
2124
|
-
announce(
|
|
3485
|
+
announce(this.#text("dragPickedUp", { field: title }));
|
|
2125
3486
|
},
|
|
2126
3487
|
move: (x, y) => {
|
|
2127
3488
|
// Above a finger, where it can be seen (below it near the top); beside a mouse pointer.
|
|
@@ -2157,7 +3518,7 @@ export class Grid extends AsterElement {
|
|
|
2157
3518
|
const parent = tree.entity(key)?.[tree.options.parentField];
|
|
2158
3519
|
const from = parent === null || parent === undefined ? undefined : keyText([parent]);
|
|
2159
3520
|
if (!over || over.to === from) {
|
|
2160
|
-
announce(
|
|
3521
|
+
announce(this.#text("dragNotMoved", { field: title }));
|
|
2161
3522
|
return;
|
|
2162
3523
|
}
|
|
2163
3524
|
try {
|
|
@@ -2176,6 +3537,13 @@ export class Grid extends AsterElement {
|
|
|
2176
3537
|
if (e.target.closest("button, input, select, textarea, a[href], [contenteditable], [data-editing]"))
|
|
2177
3538
|
return;
|
|
2178
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
|
+
}
|
|
2179
3547
|
if (row?.dataset.key && !this.emit("row-open", { key: row.dataset.key }))
|
|
2180
3548
|
return;
|
|
2181
3549
|
// With expansion, a double-click opens the row rather than editing it in
|
|
@@ -2188,11 +3556,11 @@ export class Grid extends AsterElement {
|
|
|
2188
3556
|
void this.collapse(key);
|
|
2189
3557
|
return;
|
|
2190
3558
|
}
|
|
2191
|
-
if (!this.#
|
|
3559
|
+
if (!this.#editEnabled() || e.target.closest("[data-editing]"))
|
|
2192
3560
|
return;
|
|
2193
3561
|
const cell = e.target.closest('[role="gridcell"]');
|
|
2194
3562
|
if (row?.dataset.key)
|
|
2195
|
-
this.#startEdit(row.dataset.key, Number(cell?.
|
|
3563
|
+
this.#startEdit(row.dataset.key, Number(cell?.dataset.col ?? 0));
|
|
2196
3564
|
}
|
|
2197
3565
|
/**
|
|
2198
3566
|
* Starts editing a row: an edit model with the grid's rules in a child
|
|
@@ -2200,7 +3568,15 @@ export class Grid extends AsterElement {
|
|
|
2200
3568
|
* saves first, and stays if it can't.
|
|
2201
3569
|
*/
|
|
2202
3570
|
async #startEdit(key, col) {
|
|
2203
|
-
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 }))
|
|
2204
3580
|
return;
|
|
2205
3581
|
if (!(await this.#endEdit(true)))
|
|
2206
3582
|
return;
|
|
@@ -2212,7 +3588,18 @@ export class Grid extends AsterElement {
|
|
|
2212
3588
|
const rules = this.#rules.peek();
|
|
2213
3589
|
if (scope && Object.keys(rules).length)
|
|
2214
3590
|
edit.applyRules(scope, `${this.#publish.peek() || "grid"}.row`, rules);
|
|
2215
|
-
|
|
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()) };
|
|
2216
3603
|
requestAnimationFrame(() => this.#focusEditor(key, col));
|
|
2217
3604
|
}
|
|
2218
3605
|
#focusEditor(key, col) {
|
|
@@ -2220,8 +3607,8 @@ export class Grid extends AsterElement {
|
|
|
2220
3607
|
// A user may choose another control before deferred initial focus runs.
|
|
2221
3608
|
if (row?.contains(document.activeElement))
|
|
2222
3609
|
return;
|
|
2223
|
-
const cells = row ? [...row.querySelectorAll('[role="gridcell"]')] : [];
|
|
2224
|
-
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])") ??
|
|
2225
3612
|
row?.querySelector("input:not([readonly]), textarea:not([readonly]), [role=combobox]:not([aria-readonly])");
|
|
2226
3613
|
target?.focus();
|
|
2227
3614
|
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
|
|
@@ -2237,8 +3624,22 @@ export class Grid extends AsterElement {
|
|
|
2237
3624
|
if (!editing || (only && editing !== only))
|
|
2238
3625
|
return true;
|
|
2239
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;
|
|
2240
3636
|
const saved = await editing.edit.save();
|
|
2241
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 });
|
|
2242
3643
|
requestAnimationFrame(() => this.querySelector(`.aster-grid-row[data-editing] [aria-invalid="true"]`)?.focus());
|
|
2243
3644
|
return false;
|
|
2244
3645
|
}
|
|
@@ -2308,9 +3709,14 @@ export class Grid extends AsterElement {
|
|
|
2308
3709
|
return;
|
|
2309
3710
|
e.preventDefault();
|
|
2310
3711
|
const index = Number(row.dataset.index);
|
|
2311
|
-
const col = Number(cell.
|
|
2312
|
-
|
|
2313
|
-
|
|
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 });
|
|
2314
3720
|
}
|
|
2315
3721
|
#click(e) {
|
|
2316
3722
|
const cell = e.target.closest('[role="gridcell"]');
|
|
@@ -2320,14 +3726,32 @@ export class Grid extends AsterElement {
|
|
|
2320
3726
|
if (e.target.closest("button, input, select, textarea, a[href], [contenteditable], [data-editing]"))
|
|
2321
3727
|
return;
|
|
2322
3728
|
const index = Number(row.dataset.index);
|
|
2323
|
-
const
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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));
|
|
2327
3744
|
}
|
|
2328
3745
|
#key(e, columns) {
|
|
2329
3746
|
if (e.target.closest(".aster-grid-filters, .aster-grid-panel, .aster-grid-selection, [data-editing], .aster-grid-group button"))
|
|
2330
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
|
+
}
|
|
2331
3755
|
const layout = untracked(() => this.#layout());
|
|
2332
3756
|
const count = layout.keys.length;
|
|
2333
3757
|
const { row, col } = this.#active.peek();
|
|
@@ -2347,7 +3771,7 @@ export class Grid extends AsterElement {
|
|
|
2347
3771
|
}
|
|
2348
3772
|
if (e.key === " " && this.#selection.peek() && item.kind === "row" && item.key !== undefined) {
|
|
2349
3773
|
e.preventDefault();
|
|
2350
|
-
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);
|
|
2351
3775
|
return;
|
|
2352
3776
|
}
|
|
2353
3777
|
const page = Math.max(1, Math.floor(this.#viewport.peek() / this.#heights.estimate) - 1);
|
|
@@ -2358,8 +3782,15 @@ export class Grid extends AsterElement {
|
|
|
2358
3782
|
break;
|
|
2359
3783
|
case "ArrowUp":
|
|
2360
3784
|
if (row === 0) {
|
|
2361
|
-
|
|
2362
|
-
|
|
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
|
+
}
|
|
2363
3794
|
return;
|
|
2364
3795
|
}
|
|
2365
3796
|
next = { row: row - 1, col };
|
|
@@ -2386,10 +3817,11 @@ export class Grid extends AsterElement {
|
|
|
2386
3817
|
case "F2": {
|
|
2387
3818
|
if (item.kind !== "row" || item.key === undefined)
|
|
2388
3819
|
return;
|
|
2389
|
-
if (e.key === "F2" && this.#
|
|
3820
|
+
if (e.key === "F2" && this.#selectionAllowed() && this.#selection.peek() && (!this.#editEnabled() || e.shiftKey)) {
|
|
2390
3821
|
e.preventDefault();
|
|
2391
3822
|
this.#selectionInside.value = item.key;
|
|
2392
|
-
|
|
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());
|
|
2393
3825
|
return;
|
|
2394
3826
|
}
|
|
2395
3827
|
if (e.key === "Enter" && !this.emit("row-open", { key: item.key })) {
|
|
@@ -2402,7 +3834,7 @@ export class Grid extends AsterElement {
|
|
|
2402
3834
|
this.expand(item.key);
|
|
2403
3835
|
return;
|
|
2404
3836
|
}
|
|
2405
|
-
if (!this.#
|
|
3837
|
+
if (!this.#editEnabled())
|
|
2406
3838
|
return;
|
|
2407
3839
|
e.preventDefault();
|
|
2408
3840
|
this.#startEdit(item.key, col);
|
|
@@ -2432,27 +3864,89 @@ export class Grid extends AsterElement {
|
|
|
2432
3864
|
if (this.#selection.peek() && e.shiftKey && target !== row) {
|
|
2433
3865
|
const destination = layout.at(target);
|
|
2434
3866
|
if (destination.kind === "row" && destination.key !== undefined)
|
|
2435
|
-
this.#choose(destination.key, target, true, false);
|
|
3867
|
+
this.#choose(destination.key, (layout.first ?? 0) + target, true, false);
|
|
2436
3868
|
}
|
|
2437
3869
|
this.#activate(target, Math.max(0, Math.min(columns - 1, next.col)));
|
|
2438
3870
|
}
|
|
2439
3871
|
#sortText(view) {
|
|
2440
|
-
const
|
|
2441
|
-
if (!
|
|
2442
|
-
return "
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
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. */
|
|
2446
3882
|
#treeLead(node) {
|
|
3883
|
+
const options = this.#treeOptions.value;
|
|
2447
3884
|
const folder = node.folder !== false && node.key !== undefined;
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
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(() => { });
|
|
2456
3950
|
}
|
|
2457
3951
|
/**
|
|
2458
3952
|
* → on a closed folder opens it, on an open one goes to its first child;
|
|
@@ -2485,7 +3979,15 @@ export class Grid extends AsterElement {
|
|
|
2485
3979
|
return true;
|
|
2486
3980
|
}
|
|
2487
3981
|
#filtersShown() {
|
|
2488
|
-
|
|
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;
|
|
2489
3991
|
}
|
|
2490
3992
|
#groupKey(e, row, col) {
|
|
2491
3993
|
const grouping = this.#grouping;
|
|
@@ -2535,7 +4037,7 @@ export class Grid extends AsterElement {
|
|
|
2535
4037
|
* row (Enter or F2 goes into the filter; Escape there comes back).
|
|
2536
4038
|
*/
|
|
2537
4039
|
#headKey(e, row, col, columns, count) {
|
|
2538
|
-
const field = this.#view.peek()
|
|
4040
|
+
const field = this.#visibleColumns(this.#view.peek())[col]?.field;
|
|
2539
4041
|
const move = (to) => {
|
|
2540
4042
|
e.preventDefault();
|
|
2541
4043
|
this.#active.value = { row: to.row, col: Math.max(0, Math.min(columns - 1, to.col)) };
|
|
@@ -2550,7 +4052,7 @@ export class Grid extends AsterElement {
|
|
|
2550
4052
|
case "End":
|
|
2551
4053
|
return move({ row, col: columns - 1 });
|
|
2552
4054
|
case "ArrowUp":
|
|
2553
|
-
return row === FILTER_ROW ? move({ row: HEADER, col }) : undefined;
|
|
4055
|
+
return row === FILTER_ROW && this.#headerShown() ? move({ row: HEADER, col }) : undefined;
|
|
2554
4056
|
case "ArrowDown":
|
|
2555
4057
|
if (row === HEADER && this.#filtersShown())
|
|
2556
4058
|
return move({ row: FILTER_ROW, col });
|
|
@@ -2574,7 +4076,7 @@ export class Grid extends AsterElement {
|
|
|
2574
4076
|
return;
|
|
2575
4077
|
case "F10":
|
|
2576
4078
|
case "ContextMenu": {
|
|
2577
|
-
if ((e.key === "F10" && !e.shiftKey) || row !== HEADER || !field)
|
|
4079
|
+
if ((e.key === "F10" && !e.shiftKey) || row !== HEADER || !field || !this.showHeaderMenu)
|
|
2578
4080
|
return;
|
|
2579
4081
|
e.preventDefault();
|
|
2580
4082
|
const opener = this.querySelector(`.aster-grid-header[data-field="${CSS.escape(field)}"] .aster-grid-header-menu`);
|
|
@@ -2592,7 +4094,7 @@ export class Grid extends AsterElement {
|
|
|
2592
4094
|
if (!cell)
|
|
2593
4095
|
return;
|
|
2594
4096
|
e.preventDefault();
|
|
2595
|
-
this.#active.value = { row: FILTER_ROW, col: Number(cell.
|
|
4097
|
+
this.#active.value = { row: FILTER_ROW, col: Number(cell.dataset.col ?? 0) };
|
|
2596
4098
|
this.querySelector(".aster-grid-scroll")?.focus();
|
|
2597
4099
|
}
|
|
2598
4100
|
/** Reveals the active cell; only an owned grid without shared selection chooses its row. */
|
|
@@ -2608,7 +4110,7 @@ export class Grid extends AsterElement {
|
|
|
2608
4110
|
}
|
|
2609
4111
|
if (item.kind === "row" && item.key !== undefined) {
|
|
2610
4112
|
this.#activeKey.value = item.key;
|
|
2611
|
-
if (this.#
|
|
4113
|
+
if (this.#selectionAllowed() && !this.#selection.peek() && !this.#external())
|
|
2612
4114
|
this.#selected.value = item.key;
|
|
2613
4115
|
}
|
|
2614
4116
|
else
|