@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.
Files changed (93) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +13 -4
  3. package/bin/aster-theme-check.js +35 -4
  4. package/dist/base/element.d.ts +8 -1
  5. package/dist/base/element.js +7 -2
  6. package/dist/button/button.d.ts +17 -2
  7. package/dist/button/button.js +42 -8
  8. package/dist/composition/builtins.d.ts +623 -1
  9. package/dist/composition/builtins.js +232 -4
  10. package/dist/date/calendar.d.ts +3 -0
  11. package/dist/date/calendar.js +8 -2
  12. package/dist/date/date-field.d.ts +23 -3
  13. package/dist/date/date-field.js +77 -9
  14. package/dist/date/dates.d.ts +10 -2
  15. package/dist/date/dates.js +23 -3
  16. package/dist/dialog/config.d.ts +4 -0
  17. package/dist/dialog/config.js +8 -0
  18. package/dist/dialog/dialog.d.ts +34 -6
  19. package/dist/dialog/dialog.js +151 -14
  20. package/dist/field/bound-field.d.ts +17 -1
  21. package/dist/field/bound-field.js +68 -7
  22. package/dist/field/check.js +1 -0
  23. package/dist/field/field.js +1 -1
  24. package/dist/field/input.d.ts +1 -0
  25. package/dist/field/input.js +2 -2
  26. package/dist/field/number-field.js +2 -2
  27. package/dist/filter/filter-builder.d.ts +7 -1
  28. package/dist/filter/filter-builder.js +8 -1
  29. package/dist/form/controls.d.ts +40 -8
  30. package/dist/form/controls.js +58 -15
  31. package/dist/form/custom-field.d.ts +1 -0
  32. package/dist/form/custom-field.js +23 -0
  33. package/dist/form/features.d.ts +27 -0
  34. package/dist/form/features.js +52 -0
  35. package/dist/form/form.d.ts +67 -7
  36. package/dist/form/form.js +267 -24
  37. package/dist/form/policy.d.ts +29 -0
  38. package/dist/form/policy.js +16 -0
  39. package/dist/grid/appearance.d.ts +365 -0
  40. package/dist/grid/appearance.js +814 -0
  41. package/dist/grid/features.d.ts +38 -0
  42. package/dist/grid/features.js +78 -0
  43. package/dist/grid/grid.d.ts +216 -4
  44. package/dist/grid/grid.js +1810 -308
  45. package/dist/grid/policy.d.ts +58 -0
  46. package/dist/grid/policy.js +17 -0
  47. package/dist/grid/view.d.ts +32 -2
  48. package/dist/grid/view.js +89 -4
  49. package/dist/index.d.ts +14 -11
  50. package/dist/index.js +2 -1
  51. package/dist/menu/config.d.ts +7 -0
  52. package/dist/menu/config.js +44 -0
  53. package/dist/menu/menu.d.ts +23 -0
  54. package/dist/menu/menu.js +69 -17
  55. package/dist/picker/picker.js +8 -3
  56. package/dist/section/accordion.d.ts +26 -0
  57. package/dist/section/accordion.js +86 -8
  58. package/dist/section/policy.d.ts +8 -0
  59. package/dist/section/policy.js +17 -0
  60. package/dist/section/section.d.ts +30 -0
  61. package/dist/section/section.js +106 -3
  62. package/dist/select/choice-element.d.ts +12 -0
  63. package/dist/select/choice-element.js +36 -0
  64. package/dist/select/choice.d.ts +12 -2
  65. package/dist/select/choice.js +93 -28
  66. package/dist/select/select.d.ts +12 -0
  67. package/dist/select/select.js +37 -1
  68. package/dist/styles/contract.d.ts +14 -0
  69. package/dist/styles/contract.js +15 -0
  70. package/dist/switch/switch.d.ts +16 -0
  71. package/dist/switch/switch.js +56 -6
  72. package/dist/tree/navigator.d.ts +3 -0
  73. package/dist/tree/navigator.js +26 -2
  74. package/dist/window/chrome.d.ts +5 -1
  75. package/dist/window/chrome.js +4 -3
  76. package/dist/window/config.d.ts +71 -0
  77. package/dist/window/config.js +40 -0
  78. package/dist/window/window.d.ts +51 -13
  79. package/dist/window/window.js +233 -38
  80. package/dist/yaml.d.ts +19 -0
  81. package/dist/yaml.js +57 -0
  82. package/package.json +10 -4
  83. package/styles/aster-ui.css +181 -10
  84. package/styles/base.css +14 -0
  85. package/styles/contract.ts +16 -0
  86. package/styles/elements.css +167 -10
  87. package/styles/fonts/OFL-bricolage-grotesque.txt +93 -0
  88. package/styles/fonts/OFL-fraunces.txt +93 -0
  89. package/styles/fonts/bricolage-grotesque-latin-opsz-normal.woff2 +0 -0
  90. package/styles/fonts/fraunces-latin-full-normal.woff2 +0 -0
  91. package/styles/fonts.css +17 -0
  92. package/styles/theme-harbour.css +5 -0
  93. 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
- #selectable = this.prop(true);
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
- #editable = this.prop(false);
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.columns.filter((c) => !c.hidden).map((c) => ({ field: c.field }));
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.#external())
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
- announce(`Only ${expansion.limit} ${expansion.limit === 1 ? noun : `${noun}s`} can be open at once. Close one first.`);
327
- this.#refused.value = `Only ${expansion.limit} ${expansion.limit === 1 ? noun : `${noun}s`} can be open at once. Close one first.`;
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.#editable.value };
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
- /** Rows can be edited in place: Enter, F2 or a double-click start, Enter saves, Escape cancels. */
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
- return this.#editable.value;
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
- set editable(v) {
495
- this.#editable.value = v;
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
- /** Allows this view to change selection; borrowed selection state remains visible. */
529
- get selectable() { return this.#selectable.value; }
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
- const restore = !value && document.activeElement instanceof Element && this.contains(document.activeElement) && !!document.activeElement.closest(".aster-grid-selection");
532
- this.#selectable.value = value;
533
- if (restore) {
534
- this.#selectionInside.value = undefined;
535
- queueMicrotask(() => this.focus({ preventScroll: true }));
536
- }
1289
+ this.policy = { ...this.#policy.peek(), selectable: value };
537
1290
  }
538
1291
  set selection(v) {
539
- if (v && (this.#treeOptions.peek() || groupFields(this.#view.peek()).length))
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 columns = this.#view.peek().columns.filter((c) => !c.hidden).length;
568
- this.#activate(index, Math.max(0, Math.min(columns - 1, column)));
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 = "Could not load that row. Move to another row or retry.";
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.columns.filter((c) => !c.hidden);
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 titles = columns.map((c, i) => fields[i]?.title ?? c.field);
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 frozenLeft = this.#frozenOffsets(columns, view.frozen);
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.#showFilters.value || Object.keys(view.filters).length > 0;
700
- const activeId = active.row === HEADER ? `${this.#id}-h-${active.col}`
701
- : active.row === FILTER_ROW ? `${this.#id}-f-${active.col}`
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(active.row, active.row < count && layout.at(active.row).kind === "group" ? 0 : active.col);
704
- const frozenStyle = (i) => (i < view.frozen ? `left:var(--frozen-${i})` : nothing);
705
- return html `${this.#toolbar(view, layout)}
706
- <div class="aster-grid-scroll" role=${groupFields(view).length || this.#tree ? "treegrid" : "grid"} tabindex="0" aria-label=${this.#label.value}
707
- ?data-fixed-columns=${columns.length > 0 && columns.every((c) => c.width !== undefined)}
708
- aria-rowcount=${layout.atLeast !== undefined || layout.estimate ? -1 : (count ?? 0) + (filters ? 2 : 1)} aria-colcount=${columns.length + (this.#rowControls.value ? 1 : 0)}
709
- aria-describedby=${layout.atLeast !== undefined || layout.estimate ? `${this.#id}-total` : nothing}
710
- aria-multiselectable=${this.#selectable.value && selection?.multiple ? "true" : nothing}
711
- aria-activedescendant=${activeId && (active.row < 0 || (active.row >= start && active.row < end)) ? activeId : nothing} aria-busy=${this.#grouping ? this.#grouping.root.status.value === "loading" ? "true" : "false" : collection.status.value === "loading" || count === undefined && collection.status.value !== "error" ? "true" : "false"}
712
- ?data-draggable=${!!this.#treeOptions.value?.draggable && this.#editable.value}
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.#editable.value ? html `<div class="aster-grid-root-drop">Top level</div>` : nothing}
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
- ${columns.map((c, i) => this.#header(c, fields[i], i, view, numeric[i], frozenStyle(i), active.row === HEADER && active.col === i))}
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
- ${columns.map((c, i) => html `<div class="aster-grid-cell aster-grid-filter" role="gridcell" aria-colindex=${i + 1}
730
- id=${`${this.#id}-f-${i}`} ?data-active=${active.row === FILTER_ROW && active.col === i}
731
- ?data-frozen=${i < view.frozen} style=${frozenStyle(i)}>${this.#filter(c, fields[i], view)}</div>`)}
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), columns.length, view, active.row === i);
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(columns.flatMap((c) => {
749
- const st = fields.some((f) => f?.name === c.field) ? edit.state(c.field).value : undefined;
750
- return st?.readOnly && st.reasons.readOnly && !fields.find((f) => f?.name === c.field)?.computed ? [st.reasons.readOnly] : [];
751
- }))]
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
- return html `<div class="aster-grid-row" role="row" aria-rowindex=${i + (filters ? 3 : 2)} data-index=${i}
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 : key !== undefined && (selection ? selectedKeys.includes(key) : key === selected) ? "true" : "false"}
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=${active.row === i} ?data-expanded=${key !== undefined && this.#open.value.includes(key)}
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="aster-grid-cell" role="gridcell" id=${cellId(i, col)}
772
- aria-colindex=${col + 1} ?data-active=${active.row === i && active.col === col}
773
- ?data-numeric=${numeric[col]} ?data-frozen=${col < view.frozen} style=${`grid-column:${col + 1};${col < view.frozen ? `left:var(--frozen-${col})` : ""}`} data-label=${titles[col]}>
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.#selectable.value ? html `<div class="aster-grid-selection-value">
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), active.row === i, columns.length) : nothing}
781
- ${key !== undefined && this.#open.value.includes(key) ? this.#expansionCell(key, columns.length) : nothing}
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
- ${count === 0 ? html `<div class="aster-grid-empty">${this.#empty.value}</div>` : nothing}
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() || !model.apply())
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 ? "Advanced filter applied" : "Advanced filter cleared");
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 count = layout.total;
935
- const fmt = (n) => new Intl.NumberFormat("en-GB").format(n);
936
- const grouped = this.#treeOptions.value ? [] : groupFields(view);
937
- const groupTitle = (field) => this.#collection?.type.field(field)?.title ?? field;
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=${`${this.#label.value} view`}
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
- <span class="aster-grid-total" id=${`${this.#id}-total`} aria-live="polite">${count === undefined
945
- ? "Loading…"
946
- : layout.estimate
947
- ? `About ${fmt(roughly(count))} rows`
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">Couldn’t load groups.</span>
954
- <button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void this.#grouping?.retry(); }}>Retry groups</button>` : nothing}
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">Finding where the matches are…</span>`
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">Too many matches to show where they are. Narrow the filter.</span>`
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">Couldn’t find where the matches are. Change the filter to try again.</span>`
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 ? "Stop grouping" : `Remove grouping ${groupTitle(field)}`}
965
- @click=${() => this.#setView(setGroup(view, grouped.filter((f) => f !== field)))}>${i ? "Then by" : "Grouped by"} ${groupTitle(field)} ✕</button>
966
- ${grouped.length > 1 ? html `<button type="button" class="aster-grid-tool" data-group-action="earlier" aria-label=${`Move ${groupTitle(field)} grouping earlier`} ?disabled=${i === 0}
967
- @click=${() => this.#reorderGroup(i, -1)}>↑</button>
968
- <button type="button" class="aster-grid-tool" data-group-action="later" aria-label=${`Move ${groupTitle(field)} grouping later`} ?disabled=${i === grouped.length - 1}
969
- @click=${() => this.#reorderGroup(i, 1)}>↓</button>` : nothing}
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
- <button type="button" class="aster-grid-tool" aria-pressed=${this.#showFilters.value || filters > 0 ? "true" : "false"}
972
- @click=${() => this.#toggleFilters(view)}>${filters ? `Filters (${filters})` : "Filters"}</button>
973
- <button type="button" class="aster-grid-tool" aria-haspopup="dialog" aria-pressed=${view.criteria ? "true" : "false"}
974
- @click=${(e) => this.#openAdvanced(e.currentTarget)}>Advanced filter</button>
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
- ? "Saved filter needs repair. No matches will be returned."
1988
+ ? this.#text("criteriaNeedsRepair")
977
1989
  : describeCriteria(view.criteria, this.#collection.type)}</span>` : nothing}
978
- ${tool("columns", "Columns")}
979
- ${this.#persist.value ? tool("views", "Views") : nothing}
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 type = this.#collection.type;
985
- const fieldTitle = (field) => type.field(field)?.title ?? field;
986
- const rows = view.columns
987
- .filter((column) => !column.hidden && this.#sortable(column.field))
988
- .map((column) => ({ field: column.field, offered: true }));
989
- const shown = new Set(rows.map((row) => row.field));
990
- // A sort on a hidden column still shows, so the panel never says nothing is sorted.
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 priority = new Map(view.sort.map((key, i) => [key.field, i + 1]));
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 ? `Sort ${name}, now ${descending ? "descending" : "ascending"}` : `Sort ${name}`;
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 ? "Click to reverse" : "Click to sort ascending"}.`}
1006
- @click=${() => this.#setView(sortDirection(view, field, sorted ? !descending : false, true))}>
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="Sort rows"
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">Sort rows</p>
2032
+ <p class="aster-grid-panel-title">${this.#text("sortRows")}</p>
1016
2033
  ${rows.length === 0
1017
- ? html `<p class="aster-grid-panel-note">This grid has no sortable columns.</p>`
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></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) && view.sort.length > 1 ? priority.get(field) : ""}</small>
1023
- ${priority.has(field) && view.sort.length > 1 ? html `<span class="aster-sr-only">Sort priority ${priority.get(field)}.</span>` : nothing}
1024
- ${toggle(field)}
1025
- <button type="button" class="aster-grid-sort-clear" aria-label=${`Clear ${fieldTitle(field)} sorting`} ?disabled=${!priority.has(field)}
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
- }}>Clear sorting</button>
2062
+ }}>${this.#text("clearSorting")}</button>
1045
2063
  </div>`;
1046
2064
  }
1047
2065
  #columnsPanel(view) {
1048
- const type = this.#collection.type;
1049
- const title = (field) => type.field(field)?.title ?? field;
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="Columns"
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">Columns</p>
2071
+ <p class="aster-grid-panel-title">${this.#text("columns")}</p>
1055
2072
  <ul>
1056
- ${view.columns.map((c, i) => html `<li>
1057
- <label><input type="checkbox" .checked=${!c.hidden} ?disabled=${!c.hidden && visible === 1}
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=${`Move ${title(c.field)} up`} ?disabled=${i === 0}
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=${`Move ${title(c.field)} down`} ?disabled=${i === view.columns.length - 1}
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()}>Reset to the page’s view</button>
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="Views"
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">Saved views</p>
1084
- ${names.length === 0 ? html `<p class="aster-grid-panel-note">None yet. Shape the grid, then save it here by name.</p>` : nothing}
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=${`Delete the view ${n}`} @click=${() => {
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
- }}>Delete</button>
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="Name for this view" placeholder="Name this view">
2121
+ <input aria-label=${this.#text("viewName")} placeholder=${this.#text("viewNamePlaceholder")}>
1100
2122
  </label>
1101
- <button type="submit" class="aster-grid-panel-action">Save</button>
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()}>Reset to the page’s view</button>
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
- const sort = view.sort.findIndex((s) => s.field === column.field);
1123
- const key = sort >= 0 ? view.sort[sort] : undefined;
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 = field?.title ?? column.field;
1126
- return html `<div class="aster-grid-cell aster-grid-header" role="columnheader" aria-colindex=${i + 1} id=${`${this.#id}-h-${i}`}
1127
- aria-sort=${ariaSort} ?data-numeric=${numeric} ?data-frozen=${i < view.frozen} ?data-active=${active} style=${frozen}
1128
- data-field=${column.field}>
1129
- <button type="button" class="aster-grid-sort" tabindex="-1"
1130
- title=${this.#sortable(column.field) ? `Sort by ${title}; Shift adds it to the sort; drag to move` : "Drag to move"}
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
- ${view.sort.length > 1 ? html `<small>${sort + 1}</small>` : nothing}`
2172
+ ${keys.length > 1 ? html `<small>${sort + 1}</small>` : nothing}`
1138
2173
  : nothing}
1139
2174
  </button>
1140
- <button type="button" class="aster-grid-header-menu" tabindex="-1" aria-label=${`${title} column options`}
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
- <span class="aster-grid-resize" aria-hidden="true" @pointerdown=${(e) => this.#resize(e, column.field)}
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 type = this.#collection.type;
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.columns.filter((c) => !c.hidden);
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 === view.frozen;
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: "Sort",
2210
+ label: this.#text("menuSort"),
1165
2211
  items: [
1166
- { kind: "radio", label: "Ascending", checked: !!sorted && !sorted.descending, run: () => set(sortDirection(view, field, false, false)) },
1167
- { kind: "radio", label: "Descending", checked: !!sorted?.descending, run: () => set(sortDirection(view, field, true, false)) },
1168
- { kind: "radio", label: "Not sorted", checked: !sorted, run: () => set(removeSort(view, field)) },
1169
- ...(view.sort.length > 0 && !sorted ? [{ label: "Add to the sort", run: () => set(sortDirection(view, field, false, true)) }] : []),
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
- ...(this.#groupable(field)
1176
- ? [{ kind: "check", label: `Group by ${title}`, checked: groupFields(view).includes(field), run: () => set(setGroup(view, groupFields(view).includes(field) ? groupFields(view).filter((f) => f !== field) : [...groupFields(view), field])) }]
1177
- : []),
1178
- ...(at < visible.length - 1
1179
- ? [{
1180
- kind: "check",
1181
- label: `Freeze up to ${title}`,
1182
- checked: frozenHere,
1183
- run: () => set(frozenHere ? { ...view, frozen: 0 } : freezeThrough(view, field, (f) => this.#measuredWidth(f))),
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: "Move",
2234
+ label: this.#text("menuMove"),
1189
2235
  items: [
1190
- { label: "Left", run: () => set(nudgeColumn(view, field, -1)), ...(at > 0 ? {} : { disabled: "It's the first column." }) },
1191
- { label: "Right", run: () => set(nudgeColumn(view, field, 1)), ...(at < visible.length - 1 ? {} : { disabled: "It's the last column." }) },
1192
- { label: "To the start", run: () => move(0), ...(at > 0 ? {} : { disabled: "It's the first column." }) },
1193
- { label: "To the end", run: () => move(visible.length - 1), ...(at < visible.length - 1 ? {} : { disabled: "It's the last column." }) },
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: "Width",
1199
- items: [
1200
- { label: "Wider", run: () => width(40) },
1201
- { label: "Narrower", run: () => width(-40) },
1202
- { label: "Fit the space", run: () => set(setWidth(view, field, undefined)) },
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
- ...(visible.length > 1 ? [{ label: `Hide ${title}`, run: () => set(setHidden(view, field, true)) }] : []),
1207
- { label: "Columns…", run: () => (this.#panel.value = "columns") },
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.#collection?.type.field(field)?.title ?? field;
1212
- this.querySelector(".aster-grid-menu")?.openFrom(anchor, this.#menuItems(field), `${title} column`, returnTo);
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().columns.filter((c) => !c.hidden)[col]?.field;
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: `${title} column`, items: this.#menuItems(field) },
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 ? "Row" : `${title} column`, this.querySelector(".aster-grid-scroll"));
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 title = field.title ?? column.field;
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=${`Filter ${title}`} aria-invalid=${error ? "true" : nothing}
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=${`Choose a date to filter ${title}`}
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
- const showing = this.#showFilters.peek() || Object.keys(view.filters).length > 0;
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
- if (field?.type === "Edm.Boolean")
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
- return this.#binding.value ?? this.#selection.value?.collection;
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, ...Object.values(view.filters)].filter((c) => c !== undefined);
2491
+ const parts = [this.#boundBase, base].filter((c) => c !== undefined);
1411
2492
  const filter = parts.length > 1 ? { op: "and", args: parts } : parts[0];
1412
- const projected = this.#query(query.set, { ...view, filters: {} }, filter, paths);
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.#selectable.peek())
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
- const anchor = this.#selectionAnchor;
1479
- const slots = selection.collection.keys.peek();
1480
- const loaded = anchor ? slots.indexOf(anchor.key) : -1;
1481
- const from = loaded >= 0 ? loaded : anchor?.query === selection.collection.query ? anchor.index : index;
1482
- const range = slots.slice(Math.min(from, index), Math.max(from, index) + 1).filter((k) => k !== undefined);
1483
- accepted = selection.selectAll(range);
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.#selectable.peek())
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(groupFields(view)) !== JSON.stringify(groupFields(this.#view.peek())) || JSON.stringify(view.criteria) !== JSON.stringify(this.#view.peek()?.criteria))) {
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 = groupFields(this.#view.peek());
1608
- const afterGroups = groupFields(view);
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
- if (this.#collection?.query.set === set && (this.#ownsCollection || this.#tree))
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, { pageSize: 100 });
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 (!groupFields(this.#view.value).length && !this.#tree)
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 (!groupFields(view).length) {
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 path = item.path.map((value, i) => String(this.#groupLabel(groupFields(this.#view.value)[i], this.#collection.type.field(groupFields(this.#view.value)[i]), value))).join(" / ");
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">${field?.title ?? item.field}: </span>
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">Loading…</span>` : nothing}
1794
- ${item.error !== undefined ? html `<span class="aster-error" role="status">Couldn’t load group.</span>
1795
- <button type="button" class="aster-grid-tool" aria-label=${`Retry group ${path}`} @click=${() => { this.#activate(index, 0); this.focus({ preventScroll: true }); void this.#grouping?.retry(item.id).catch(() => { }); }}>Retry</button>` : nothing}
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 "Rows";
1826
- const title = this.#collection?.type.field(summary.field)?.title ?? summary.field;
1827
- const names = { sum: "Sum", min: "Minimum", max: "Maximum", average: "Average", countdistinct: "Distinct" };
1828
- return `${names[summary.method]} ${title}`;
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
- return formatValue(this.#collection?.type.field(summary.field), value);
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=${`${this.#label.value} summaries`} aria-busy=${status === "loading" ? "true" : "false"}>
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" ? "Couldn’t load summaries." : undefined} .progressPolicy=${this.#progressPolicy.value}
1846
- .pendingText=${"Loading summaries…"} .completeText=${"Summaries updated"}></aster-progress-status>
1847
- ${status === "error" ? html `<button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void summary.refresh(); }}>Retry summaries</button>` : nothing}
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(index, step) {
3232
+ #reorderGroup(field, step) {
1903
3233
  const view = this.#view.peek();
1904
3234
  if (!view)
1905
3235
  return;
1906
- const fields = [...groupFields(view)];
1907
- const to = index + step;
1908
- if (to < 0 || to >= fields.length)
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
- [fields[index], fields[to]] = [fields[to], fields[index]];
1911
- this.#setView(setGroup(view, fields));
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.#treeOptions.value ? [] : groupFields(this.#view.value);
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 = 0;
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 !this.#lookups.peek()[field] || this.#sortPaths.peek()[field] !== undefined;
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 || !this.#sortable(field))
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.#editable.peek())
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(`Picked up ${title}. Drop it on another row to move it under that row, or on Top level to move it to the top level.`);
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(`${title} not moved.`);
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.#editable.peek() || e.target.closest("[data-editing]"))
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?.getAttribute("aria-colindex") ?? 1) - 1);
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
- this.#editing.value = { key, edit, stop: () => (withdraw?.(), edit.dispose()) };
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[col]?.querySelector("input:not([readonly]), textarea:not([readonly]), [role=combobox]:not([aria-readonly])") ??
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.getAttribute("aria-colindex")) - 1;
2312
- this.#activate(index, col);
2313
- this.#openRowMenu(index, col, { x: e.clientX, y: e.clientY });
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 item = untracked(() => this.#layout()).at(index);
2324
- if (item.kind === "row" && item.key !== undefined)
2325
- this.#choose(item.key, index, e.shiftKey, e.ctrlKey || e.metaKey);
2326
- this.#activate(index, row.classList.contains("aster-grid-group") ? this.#active.peek().col : Number(cell.getAttribute("aria-colindex")) - 1);
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
- e.preventDefault();
2362
- this.#active.value = { row: this.#filtersShown() ? FILTER_ROW : HEADER, col };
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.#selectable.peek() && this.#selection.peek() && (!this.#editable.peek() || e.shiftKey)) {
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
- queueMicrotask(() => this.querySelector(`.aster-grid-row[data-key="${CSS.escape(item.key)}"] .aster-grid-pick`)?.focus());
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.#editable.peek())
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 title = (f) => this.#collection?.type.field(f)?.title ?? f;
2441
- if (!view.sort.length)
2442
- return "Not sorted";
2443
- return `Sorted by ${view.sort.map((k) => `${title(k.field)}, ${k.descending ? "descending" : "ascending"}`).join(", then ")}`;
2444
- }
2445
- /** The first column's indent and opener in a tree grid. */
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
- return html `<span class="aster-grid-tree" style=${`--level:${node.level - 1}`}>${folder
2449
- ? html `<button type="button" class="aster-grid-opener" tabindex="-1" aria-hidden="true" ?data-open=${node.open}
2450
- @pointerdown=${(e) => e.stopPropagation()}
2451
- @click=${(e) => {
2452
- e.stopPropagation();
2453
- void this.#tree?.toggle(node.key);
2454
- }}><svg viewBox="0 0 10 10"><path d="M3.5 2 6.5 5l-3 3"/></svg></button>`
2455
- : html `<span class="aster-grid-opener" aria-hidden="true"></span>`}</span>`;
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
- return this.#showFilters.peek() || Object.keys(this.#view.peek()?.filters ?? {}).length > 0;
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().columns.filter((c) => !c.hidden)[col]?.field;
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.getAttribute("aria-colindex")) - 1 };
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.#selectable.peek() && !this.#selection.peek() && !this.#external())
4113
+ if (this.#selectionAllowed() && !this.#selection.peek() && !this.#external())
2612
4114
  this.#selected.value = item.key;
2613
4115
  }
2614
4116
  else