@getaster/elements 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +1853 -301
  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 +233 -10
  84. package/styles/base.css +36 -0
  85. package/styles/contract.ts +16 -0
  86. package/styles/elements.css +197 -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,107 +1870,222 @@ export class Grid extends AsterElement {
913
1870
  this.#filterNote.value = "The current row could not be saved. Your filter draft is still available.";
914
1871
  return;
915
1872
  }
916
- if (this.#filterStale() || !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;
938
- const chooseSort = (field, descending) => {
939
- this.querySelector(".aster-grid-menu")?.close();
940
- this.#setView(sortDirection(view, field, descending, false));
941
- };
1940
+ const storedGroups = groupFields(view);
1941
+ const grouped = this.#groupedFields(view);
1942
+ const refusedGroups = storedGroups.filter((field) => !grouped.includes(field));
1943
+ const groupTitle = (field) => this.#title(field);
1944
+ const visible = this.#visibleColumns(view);
1945
+ const filterRefusal = visible.map((column) => this.#filterRefusal(column.field)).find((reason) => reason !== undefined);
1946
+ const anyFilterable = visible.some((column) => this.#filterRefusal(column.field) === undefined);
942
1947
  const tool = (name, label) => html `<button type="button" class="aster-grid-tool"
943
1948
  style=${anchorStyle(`${this.#id}-tool-${name}`)}
944
1949
  aria-expanded=${panel === name ? "true" : "false"} aria-controls=${`${this.#id}-${name}`}
945
1950
  @click=${() => (this.#panel.value = panel === name ? undefined : name)}>${label}</button>`;
946
- return html `<div class="aster-grid-toolbar" role="toolbar" aria-label=${`${this.#label.value} view`}
1951
+ return html `<div class="aster-grid-toolbar" role="toolbar" aria-label=${this.#text("toolbarLabel", { label: this.#label.value })}
947
1952
  @keydown=${(e) => this.#panelKey(e)}>
948
- <span class="aster-grid-total" id=${`${this.#id}-total`} aria-live="polite">${count === undefined
949
- ? "Loading…"
950
- : layout.estimate
951
- ? `About ${fmt(roughly(count))} rows`
952
- : layout.atLeast !== undefined
953
- ? `${fmt(layout.atLeast)}+ rows`
954
- : `${fmt(count)} ${count === 1 ? "row" : "rows"}${layout.groups === undefined ? "" : ` in ${fmt(layout.groups)} ${layout.groups === 1 ? "group" : "groups"}`}`}</span>
955
- <button type="button" class="aster-grid-tool" aria-haspopup="menu" @click=${(event) => {
956
- const items = view.columns.filter(column => !column.hidden && this.#sortable(column.field)).map(column => {
957
- const sorted = view.sort.find(key => key.field === column.field);
958
- return { kind: "group", label: groupTitle(column.field), items: [
959
- { kind: "radio", label: "Ascending", checked: !!sorted && !sorted.descending, run: () => chooseSort(column.field, false) },
960
- { kind: "radio", label: "Descending", checked: !!sorted?.descending, run: () => chooseSort(column.field, true) },
961
- ] };
962
- });
963
- items.push({ label: "Clear sorting", run: () => this.#setView({ ...view, sort: [] }) });
964
- this.querySelector(".aster-grid-menu")?.openFrom(event.currentTarget, items, "Sort rows");
965
- }}>Sort</button>
1953
+ ${options.count === false ? nothing : html `<span class="aster-grid-total" id=${`${this.#id}-total`} aria-live="polite">${this.#countText(layout)}</span>`}
1954
+ ${options.expandAll === false || !this.#tree ? nothing : html `<button type="button" class="aster-grid-tool" @click=${() => void this.#expandAll()}>${this.#text("expandAll")}</button>
1955
+ <button type="button" class="aster-grid-tool" @click=${() => this.#collapseAll()}>${this.#text("collapseAll")}</button>`}
1956
+ ${options.sort === false ? nothing : tool("sort", this.#text("sort"))}
966
1957
  ${this.#selectionNote.value ? html `<span class="aster-grid-note" role="status">${this.#selectionNote.value}</span>` : nothing}
967
- ${this.#grouping?.root.error.value !== undefined ? html `<span class="aster-grid-note aster-error" role="status">Couldn’t load groups.</span>
968
- <button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void this.#grouping?.retry(); }}>Retry groups</button>` : nothing}
1958
+ ${this.#grouping?.root.error.value !== undefined ? html `<span class="aster-grid-note aster-error" role="status">${this.#text("groupsFailed")}</span>
1959
+ <button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void this.#grouping?.retry(); }}>${this.#text("retryGroups")}</button>` : nothing}
969
1960
  ${this.#refused.value ? html `<span class="aster-grid-note aster-grid-refused">${this.#refused.value}</span>` : nothing}
970
1961
  ${this.#tree?.searching.value
971
- ? html `<span class="aster-grid-note" role="status">Finding where the matches are…</span>`
1962
+ ? html `<span class="aster-grid-note" role="status">${this.#text("treeSearching")}</span>`
972
1963
  : this.#tree?.tooBroad.value
973
- ? html `<span class="aster-grid-note" role="status">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>`
974
1965
  : this.#tree?.error.value !== undefined
975
- ? html `<span class="aster-grid-note aster-error" role="status">Couldn’t find where the matches are. Change the filter to try again.</span>`
1966
+ ? html `<span class="aster-grid-note aster-error" role="status">${this.#text("treeSearchFailed")}</span>`
976
1967
  : nothing}
977
1968
  ${grouped.map((field, i) => html `<span class="aster-grid-group-tools" data-group-field=${field}>
978
- <button type="button" class="aster-grid-tool" data-group-action="remove" aria-pressed="true" title=${grouped.length === 1 ? "Stop grouping" : `Remove grouping ${groupTitle(field)}`}
979
- @click=${() => this.#setView(setGroup(view, grouped.filter((f) => f !== field)))}>${i ? "Then by" : "Grouped by"} ${groupTitle(field)} ✕</button>
980
- ${grouped.length > 1 ? html `<button type="button" class="aster-grid-tool" data-group-action="earlier" aria-label=${`Move ${groupTitle(field)} grouping earlier`} ?disabled=${i === 0}
981
- @click=${() => this.#reorderGroup(i, -1)}>↑</button>
982
- <button type="button" class="aster-grid-tool" data-group-action="later" aria-label=${`Move ${groupTitle(field)} grouping later`} ?disabled=${i === grouped.length - 1}
983
- @click=${() => this.#reorderGroup(i, 1)}>↓</button>` : nothing}
1969
+ <button type="button" class="aster-grid-tool" data-group-action="remove" aria-pressed="true" title=${grouped.length === 1 ? this.#text("stopGrouping") : this.#text("removeGrouping", { field: groupTitle(field) })}
1970
+ @click=${() => this.#setView(setGroup(view, storedGroups.filter((f) => f !== field)))}>${i ? this.#text("thenBy", { field: groupTitle(field) }) : this.#text("groupedBy", { field: groupTitle(field) })} ✕</button>
1971
+ ${grouped.length > 1 ? html `<button type="button" class="aster-grid-tool" data-group-action="earlier" aria-label=${this.#text("moveGroupingEarlier", { field: groupTitle(field) })} ?disabled=${i === 0}
1972
+ @click=${() => this.#reorderGroup(field, -1)}>↑</button>
1973
+ <button type="button" class="aster-grid-tool" data-group-action="later" aria-label=${this.#text("moveGroupingLater", { field: groupTitle(field) })} ?disabled=${i === grouped.length - 1}
1974
+ @click=${() => this.#reorderGroup(field, 1)}>↓</button>` : nothing}
1975
+ </span>`)}
1976
+ ${refusedGroups.map((field) => html `<span class="aster-grid-group-tools" data-group-field=${field} data-refused>
1977
+ <button type="button" class="aster-grid-tool" data-group-action="remove" title=${this.#text("removeRefusedGrouping", { reason: this.#groupRefusal(field) ?? this.#text("groupingRefused") })}
1978
+ @click=${() => this.#setView(setGroup(view, storedGroups.filter((f) => f !== field)))}>${this.#text("groupedBy", { field: groupTitle(field) })} ✕</button>
984
1979
  </span>`)}
985
- <button type="button" class="aster-grid-tool" aria-pressed=${this.#showFilters.value || filters > 0 ? "true" : "false"}
986
- @click=${() => this.#toggleFilters(view)}>${filters ? `Filters (${filters})` : "Filters"}</button>
987
- <button type="button" class="aster-grid-tool" aria-haspopup="dialog" aria-pressed=${view.criteria ? "true" : "false"}
988
- @click=${(e) => this.#openAdvanced(e.currentTarget)}>Advanced filter</button>
1980
+ ${options.filters === false || this.#chrome.value.filterRow === false ? nothing : html `<button type="button" class="aster-grid-tool" aria-pressed=${this.#filtersShown() ? "true" : "false"}
1981
+ ?disabled=${!anyFilterable && filters === 0} title=${!anyFilterable && filters === 0 ? (filterRefusal ?? this.#text("filterRefused")) : nothing}
1982
+ @click=${() => this.#toggleFilters(view)}>${filters ? this.#text("filtersActive", { count: filters }) : this.#text("filters")}</button>`}
1983
+ ${options.advanced === false ? nothing : html `<button type="button" class="aster-grid-tool" aria-haspopup="dialog" aria-pressed=${view.criteria ? "true" : "false"}
1984
+ ?disabled=${!anyFilterable} title=${!anyFilterable ? (filterRefusal ?? this.#text("filterRefused")) : nothing}
1985
+ @click=${(e) => this.#openAdvanced(e.currentTarget)}>${this.#text("advancedFilter")}</button>`}
1986
+ ${!anyFilterable ? html `<span class="aster-sr-only">${filterRefusal ?? this.#text("filterRefused")}</span>` : nothing}
989
1987
  ${view.criteria ? html `<span class="aster-grid-expression" role="status">${this.#criteriaCheck.value?.valid === false
990
- ? "Saved filter needs repair. No matches will be returned."
1988
+ ? this.#text("criteriaNeedsRepair")
991
1989
  : describeCriteria(view.criteria, this.#collection.type)}</span>` : nothing}
992
- ${tool("columns", "Columns")}
993
- ${this.#persist.value ? tool("views", "Views") : nothing}
994
- ${panel === "columns" ? this.#columnsPanel(view) : panel === "views" ? this.#viewsPanel(view) : nothing}
1990
+ ${options.columns === false ? nothing : tool("columns", this.#text("columns"))}
1991
+ ${options.views === false || !this.#persist.value ? nothing : tool("views", this.#text("views"))}
1992
+ ${options.extra ?? nothing}
1993
+ ${panel === "sort" ? this.#sortPanel(view) : panel === "columns" ? this.#columnsPanel(view) : panel === "views" ? this.#viewsPanel(view) : nothing}
1994
+ </div>`;
1995
+ }
1996
+ #sortPanel(view) {
1997
+ const fieldTitle = (field) => this.#title(field);
1998
+ const offered = this.#visibleColumns(view)
1999
+ .filter((column) => this.#sortable(column.field))
2000
+ .map((column) => ({ field: column.field, offered: true, refusal: undefined }));
2001
+ const shown = new Set(offered.map((row) => row.field));
2002
+ // A sort on a hidden or now-refused column still shows, with its reason:
2003
+ // the panel never says nothing is sorted, and the stored key stays clearable.
2004
+ const rows = [...offered];
2005
+ for (const key of view.sort) {
2006
+ if (shown.has(key.field))
2007
+ continue;
2008
+ shown.add(key.field);
2009
+ rows.push({ field: key.field, offered: false, refusal: this.#sortRefusal(key.field) });
2010
+ }
2011
+ const active = view.sort.filter((key) => this.#sortable(key.field));
2012
+ const priority = new Map(active.map((key, i) => [key.field, i + 1]));
2013
+ const toggle = (field) => {
2014
+ const key = view.sort.find((sortKey) => sortKey.field === field);
2015
+ const sorted = key !== undefined;
2016
+ const descending = key?.descending === true;
2017
+ const name = fieldTitle(field);
2018
+ const action = sorted
2019
+ ? this.#text("sortActionNow", { field: name, direction: descending ? this.#text("directionDescending") : this.#text("directionAscending") })
2020
+ : this.#text("sortAction", { field: name });
2021
+ return html `<button type="button" class="aster-grid-sort-direction" aria-label=${action} aria-pressed=${sorted ? "true" : "false"}
2022
+ title=${`${action}. ${sorted ? this.#text("sortReverse") : this.#text("sortAscending")}.`}
2023
+ @click=${() => this.#setView(sortDirection(view, field, sorted ? !descending : false, this.#multiSort()))}>
2024
+ <svg viewBox="0 0 10 10" aria-hidden="true"><path d=${sorted
2025
+ ? descending ? "M2 3.5 5 6.5l3-3" : "M2 6.5 5 3.5l3 3"
2026
+ : "M2 3.5 5 .5l3 3M2 6.5 5 9.5l3-3"}/></svg>
2027
+ </button>`;
2028
+ };
2029
+ return html `<div class="aster-grid-panel aster-grid-sort-panel aster-popover" popover="manual" ${openWhen(true)}
2030
+ style=${placeStyle(`${this.#id}-tool-sort`)} id=${`${this.#id}-sort`} role="group" aria-label=${this.#text("sortRows")}
2031
+ @keydown=${(e) => this.#panelKey(e)}>
2032
+ <p class="aster-grid-panel-title">${this.#text("sortRows")}</p>
2033
+ ${rows.length === 0
2034
+ ? html `<p class="aster-grid-panel-note">${this.#text("noSortableColumns")}</p>`
2035
+ : html `<ul>
2036
+ ${rows.map(({ field, offered, refusal }) => html `<li data-field=${field} ?data-refused=${refusal !== undefined}>
2037
+ <span class="aster-grid-sort-name"><span class="aster-grid-sort-title">${fieldTitle(field)}</span>
2038
+ ${refusal ? html `<small class="aster-grid-sort-reason">${refusal}</small>` : nothing}</span>
2039
+ <span class="aster-grid-sort-actions">
2040
+ <small class="aster-grid-sort-priority" aria-hidden="true">${priority.has(field) && active.length > 1 ? priority.get(field) : ""}</small>
2041
+ ${priority.has(field) && active.length > 1 ? html `<span class="aster-sr-only">${this.#text("sortPriority", { priority: priority.get(field) })}</span>` : nothing}
2042
+ ${refusal ? nothing : toggle(field)}
2043
+ <button type="button" class="aster-grid-sort-clear" aria-label=${this.#text("clearFieldSorting", { field: fieldTitle(field) })} ?disabled=${!view.sort.some((key) => key.field === field)}
2044
+ @click=${(e) => {
2045
+ // Clearing disables this button, so focus a control that survives the render.
2046
+ if (offered)
2047
+ e.currentTarget.closest("li")?.querySelector(".aster-grid-sort-direction")?.focus();
2048
+ else
2049
+ this.querySelector(`.aster-grid-tool[aria-controls="${this.#id}-sort"]`)?.focus();
2050
+ this.#setView(removeSort(view, field));
2051
+ }}>
2052
+ <svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2.5 2.5 7.5 7.5M7.5 2.5 2.5 7.5"/></svg>
2053
+ </button>
2054
+ </span>
2055
+ </li>`)}
2056
+ </ul>`}
2057
+ <button type="button" class="aster-grid-panel-action" ?disabled=${view.sort.length === 0}
2058
+ @click=${() => {
2059
+ const first = this.querySelector(".aster-grid-sort-panel .aster-grid-sort-direction");
2060
+ (first ?? this.querySelector(`.aster-grid-tool[aria-controls="${this.#id}-sort"]`))?.focus();
2061
+ this.#setView({ ...view, sort: [] });
2062
+ }}>${this.#text("clearSorting")}</button>
995
2063
  </div>`;
996
2064
  }
997
2065
  #columnsPanel(view) {
998
- const type = this.#collection.type;
999
- const title = (field) => type.field(field)?.title ?? field;
1000
- const visible = view.columns.filter((c) => !c.hidden).length;
2066
+ const title = (field) => this.#title(field);
2067
+ const shown = new Set(this.#visibleColumns(view).map((column) => column.field));
1001
2068
  return html `<div class="aster-grid-panel aster-popover" popover="manual" ${openWhen(true)} style=${placeStyle(`${this.#id}-tool-columns`)}
1002
- id=${`${this.#id}-columns`} role="group" aria-label="Columns"
2069
+ id=${`${this.#id}-columns`} role="group" aria-label=${this.#text("columns")}
1003
2070
  @keydown=${(e) => this.#panelKey(e)}>
1004
- <p class="aster-grid-panel-title">Columns</p>
2071
+ <p class="aster-grid-panel-title">${this.#text("columns")}</p>
1005
2072
  <ul>
1006
- ${view.columns.map((c, i) => html `<li>
1007
- <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}
1008
2079
  @change=${(e) => this.#setView(setHidden(view, c.field, !e.target.checked))}>
1009
2080
  ${title(c.field)}</label>
1010
- <button type="button" aria-label=${`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}
1011
2082
  @click=${() => this.#setView(moveColumn(view, c.field, i - 1))}>↑</button>
1012
- <button type="button" aria-label=${`Move ${title(c.field)} down`} ?disabled=${i === view.columns.length - 1}
2083
+ <button type="button" aria-label=${this.#text("moveFieldDown", { field: title(c.field) })} ?disabled=${i === view.columns.length - 1 || move !== undefined} title=${move ?? nothing}
1013
2084
  @click=${() => this.#setView(moveColumn(view, c.field, i + 1))}>↓</button>
1014
- </li>`)}
2085
+ </li>`;
2086
+ })}
1015
2087
  </ul>
1016
- <button type="button" class="aster-grid-panel-action" @click=${() => this.#reset()}>Reset to the page’s view</button>
2088
+ <button type="button" class="aster-grid-panel-action" @click=${() => this.#reset()}>${this.#text("resetView")}</button>
1017
2089
  </div>`;
1018
2090
  }
1019
2091
  #viewsPanel(view) {
@@ -1028,29 +2100,29 @@ export class Grid extends AsterElement {
1028
2100
  this.#panel.value = undefined;
1029
2101
  };
1030
2102
  return html `<div class="aster-grid-panel aster-popover" popover="manual" ${openWhen(true)} style=${placeStyle(`${this.#id}-tool-views`)}
1031
- id=${`${this.#id}-views`} role="group" aria-label="Views"
2103
+ id=${`${this.#id}-views`} role="group" aria-label=${this.#text("views")}
1032
2104
  @keydown=${(e) => this.#panelKey(e)}>
1033
- <p class="aster-grid-panel-title">Saved views</p>
1034
- ${names.length === 0 ? html `<p class="aster-grid-panel-note">None yet. Shape the grid, then save it here by name.</p>` : nothing}
2105
+ <p class="aster-grid-panel-title">${this.#text("savedViews")}</p>
2106
+ ${names.length === 0 ? html `<p class="aster-grid-panel-note">${this.#text("noSavedViews")}</p>` : nothing}
1035
2107
  <ul>
1036
2108
  ${names.map((n) => html `<li>
1037
2109
  <button type="button" class="aster-grid-panel-view" @click=${() => {
1038
2110
  this.#setView(reconcile(saved.named[n], this.#offered ?? view));
1039
2111
  this.#panel.value = undefined;
1040
2112
  }}>${n}</button>
1041
- <button type="button" aria-label=${`Delete the view ${n}`} @click=${() => {
2113
+ <button type="button" aria-label=${this.#text("deleteView", { name: n })} @click=${() => {
1042
2114
  const { [n]: _, ...rest } = saved.named;
1043
2115
  this.#saved.value = { ...saved, named: rest };
1044
- }}>Delete</button>
2116
+ }}>${this.#text("delete")}</button>
1045
2117
  </li>`)}
1046
2118
  </ul>
1047
2119
  <form class="aster-grid-panel-save" @submit=${(e) => (e.preventDefault(), save(e.currentTarget))}>
1048
2120
  <label class="aster-field" data-ring-owner>
1049
- <input aria-label="Name for this view" placeholder="Name this view">
2121
+ <input aria-label=${this.#text("viewName")} placeholder=${this.#text("viewNamePlaceholder")}>
1050
2122
  </label>
1051
- <button type="submit" class="aster-grid-panel-action">Save</button>
2123
+ <button type="submit" class="aster-grid-panel-action">${this.#text("save")}</button>
1052
2124
  </form>
1053
- <button type="button" class="aster-grid-panel-action" @click=${() => this.#reset()}>Reset to the page’s view</button>
2125
+ <button type="button" class="aster-grid-panel-action" @click=${() => this.#reset()}>${this.#text("resetView")}</button>
1054
2126
  </div>`;
1055
2127
  }
1056
2128
  #reset() {
@@ -1068,98 +2140,126 @@ export class Grid extends AsterElement {
1068
2140
  this.querySelector(`.aster-grid-tool[aria-controls="${this.#id}-${panel}"]`)?.focus();
1069
2141
  }
1070
2142
  // ── header ──
1071
- #header(column, field, i, view, numeric, frozen, active) {
1072
- const sort = view.sort.findIndex((s) => s.field === column.field);
1073
- 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;
1074
2149
  const ariaSort = sort === 0 ? (key.descending ? "descending" : "ascending") : nothing;
1075
- const title = field?.title ?? column.field;
1076
- return html `<div class="aster-grid-cell aster-grid-header" role="columnheader" aria-colindex=${i + 1} id=${`${this.#id}-h-${i}`}
1077
- aria-sort=${ariaSort} ?data-numeric=${numeric} ?data-frozen=${i < view.frozen} ?data-active=${active} style=${frozen}
1078
- data-field=${column.field}>
1079
- <button type="button" class="aster-grid-sort" tabindex="-1"
1080
- title=${this.#sortable(column.field) ? `Sort by ${title}; Shift adds it to the sort; drag to move` : "Drag to move"}
2150
+ const title = this.#title(column.field);
2151
+ const align = this.#presentation.value.columns?.[column.field]?.align;
2152
+ const refusal = this.#sortRefusal(column.field);
2153
+ const move = this.#moveRefusal(column.field);
2154
+ const resize = this.#resizeRefusal(column.field);
2155
+ const multi = this.#multiSort();
2156
+ const hint = [
2157
+ refusal ?? this.#text("sortBy", { field: title }) + (multi ? this.#text("addToSort") : ""),
2158
+ move ?? this.#text("dragToMove"),
2159
+ resize ?? this.#text("dragToResize"),
2160
+ ].join(". ");
2161
+ return html `<div class="aster-grid-cell aster-grid-header" role="columnheader" aria-colindex=${i + offset} id=${`${this.#id}-h-${i}`}
2162
+ data-col=${i} aria-sort=${ariaSort} ?data-numeric=${numeric} ?data-frozen=${i < frozen} ?data-active=${active} style=${style}
2163
+ data-align=${align ?? nothing}
2164
+ data-field=${column.field} data-sortable=${refusal ? "false" : "true"} ?data-movable=${!move} ?data-resizable=${!resize}>
2165
+ <button type="button" class="aster-grid-sort" tabindex="-1" title=${hint}
1081
2166
  @pointerdown=${(e) => this.#dragColumn(e, column.field)}
1082
2167
  @click=${(e) => this.#sort(column.field, e.shiftKey)}>
1083
2168
  <span>${title}</span>
1084
2169
  ${key
1085
2170
  ? html `<svg class="aster-grid-sort-mark" viewBox="0 0 10 10" aria-hidden="true">
1086
2171
  <path d=${key.descending ? "M2 3.5 5 6.5l3-3" : "M2 6.5 5 3.5l3 3"}/></svg>
1087
- ${view.sort.length > 1 ? html `<small>${sort + 1}</small>` : nothing}`
2172
+ ${keys.length > 1 ? html `<small>${sort + 1}</small>` : nothing}`
1088
2173
  : nothing}
1089
2174
  </button>
1090
- <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 })}
1091
2177
  @click=${(e) => this.#openMenu(column.field, e.currentTarget)}>
1092
2178
  <svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.8 5 6.8l3-3"/></svg>
1093
- </button>
1094
- <span class="aster-grid-resize" aria-hidden="true" @pointerdown=${(e) => this.#resize(e, column.field)}
1095
- @dblclick=${() => this.#setView(setWidth(this.#view.peek(), column.field, undefined))}></span>
2179
+ </button>` : nothing}
2180
+ ${resize ? nothing : html `<span class="aster-grid-resize" aria-hidden="true" @pointerdown=${(e) => this.#resize(e, column.field)}
2181
+ @dblclick=${() => this.#setView(setWidth(this.#view.peek(), column.field, undefined))}></span>`}
1096
2182
  </div>`;
1097
2183
  }
1098
2184
  #menuItems(field) {
1099
2185
  const view = this.#view.peek();
1100
- const type = this.#collection.type;
1101
- const title = type.field(field)?.title ?? field;
2186
+ const title = this.#title(field);
1102
2187
  const sorted = view.sort.find((s) => s.field === field);
1103
- const visible = view.columns.filter((c) => !c.hidden);
2188
+ const visible = this.#visibleColumns(view);
2189
+ const frozen = this.#frozenCount(view, visible);
1104
2190
  const at = visible.findIndex((c) => c.field === field);
1105
2191
  const set = (v) => this.#setView(v);
1106
2192
  const width = (delta) => set(setWidth(view, field, (visible[at]?.width ?? this.#measuredWidth(field)) + delta));
1107
- const frozenHere = at + 1 === view.frozen;
2193
+ const frozenHere = at + 1 === frozen;
1108
2194
  const move = (to) => set(moveColumn(view, field, view.columns.findIndex((c) => c.field === visible[to]?.field)));
2195
+ const sortedFields = groupFields(view);
2196
+ const groupRefusal = this.#groupRefusal(field);
2197
+ const freezeRefusal = this.#freezeThroughRefusal(field);
2198
+ const moveRefusal = this.#moveRefusal(field);
2199
+ const resizeRefusal = this.#resizeRefusal(field);
2200
+ const hideRefusal = at < 0 ? undefined : this.#hideRefusal(field);
2201
+ const moveItem = (label, run, unavailable) => moveRefusal ? { label, run: () => { }, disabled: moveRefusal } : { label, run, ...(unavailable ? { disabled: unavailable } : {}) };
2202
+ const custom = this.#columnMenu.value(field);
1109
2203
  return [
2204
+ ...custom,
2205
+ ...(custom.length ? [{ kind: "separator" }] : []),
1110
2206
  ...(this.#sortable(field)
1111
2207
  ? [
1112
2208
  {
1113
2209
  kind: "group",
1114
- label: "Sort",
2210
+ label: this.#text("menuSort"),
1115
2211
  items: [
1116
- { kind: "radio", label: "Ascending", checked: !!sorted && !sorted.descending, run: () => set(sortDirection(view, field, false, false)) },
1117
- { kind: "radio", label: "Descending", checked: !!sorted?.descending, run: () => set(sortDirection(view, field, true, false)) },
1118
- { kind: "radio", label: "Not sorted", checked: !sorted, run: () => set(removeSort(view, field)) },
1119
- ...(view.sort.length > 0 && !sorted ? [{ label: "Add to the sort", run: () => set(sortDirection(view, field, false, true)) }] : []),
2212
+ { kind: "radio", label: this.#text("ascending"), checked: !!sorted && !sorted.descending, run: () => set(sortDirection(view, field, false, false)) },
2213
+ { kind: "radio", label: this.#text("descending"), checked: !!sorted?.descending, run: () => set(sortDirection(view, field, true, false)) },
2214
+ { kind: "radio", label: this.#text("notSorted"), checked: !sorted, run: () => set(removeSort(view, field)) },
2215
+ ...(this.#multiSort() && view.sort.length > 0 && !sorted ? [{ label: this.#text("addToSortMenu"), run: () => set(sortDirection(view, field, false, true)) }] : []),
1120
2216
  ],
1121
2217
  },
1122
2218
  { kind: "separator" },
1123
2219
  ]
1124
2220
  : []),
1125
- ...(this.#groupable(field)
1126
- ? [{ kind: "check", label: `Group by ${title}`, checked: groupFields(view).includes(field), run: () => set(setGroup(view, groupFields(view).includes(field) ? groupFields(view).filter((f) => f !== field) : [...groupFields(view), field])) }]
1127
- : []),
1128
- ...(at < visible.length - 1
1129
- ? [{
1130
- kind: "check",
1131
- label: `Freeze up to ${title}`,
1132
- checked: frozenHere,
1133
- run: () => set(frozenHere ? { ...view, frozen: 0 } : freezeThrough(view, field, (f) => this.#measuredWidth(f))),
1134
- }]
1135
- : []),
2221
+ groupRefusal
2222
+ ? { label: this.#text("groupBy", { field: title }), run: () => { }, disabled: groupRefusal }
2223
+ : { kind: "check", label: this.#text("groupBy", { field: title }), checked: sortedFields.includes(field), run: () => set(setGroup(view, sortedFields.includes(field) ? sortedFields.filter((f) => f !== field) : [...sortedFields, field])) },
2224
+ freezeRefusal
2225
+ ? { label: this.#text("freezeUpTo", { field: title }), run: () => { }, disabled: freezeRefusal }
2226
+ : {
2227
+ kind: "check",
2228
+ label: this.#text("freezeUpTo", { field: title }),
2229
+ checked: frozenHere,
2230
+ run: () => set(frozenHere ? { ...view, frozen: 0 } : freezeThrough(view, field, (f) => this.#measuredWidth(f))),
2231
+ },
1136
2232
  {
1137
2233
  kind: "submenu",
1138
- label: "Move",
2234
+ label: this.#text("menuMove"),
1139
2235
  items: [
1140
- { label: "Left", run: () => set(nudgeColumn(view, field, -1)), ...(at > 0 ? {} : { disabled: "It's the first column." }) },
1141
- { label: "Right", run: () => set(nudgeColumn(view, field, 1)), ...(at < visible.length - 1 ? {} : { disabled: "It's the last column." }) },
1142
- { label: "To the start", run: () => move(0), ...(at > 0 ? {} : { disabled: "It's the first column." }) },
1143
- { label: "To the end", run: () => move(visible.length - 1), ...(at < visible.length - 1 ? {} : { disabled: "It's the last column." }) },
2236
+ moveItem(this.#text("moveLeft"), () => set(nudgeColumn(view, field, -1)), at > 0 ? undefined : this.#text("firstColumn")),
2237
+ moveItem(this.#text("moveRight"), () => set(nudgeColumn(view, field, 1)), at < visible.length - 1 ? undefined : this.#text("lastColumn")),
2238
+ moveItem(this.#text("moveToStart"), () => move(0), at > 0 ? undefined : this.#text("firstColumn")),
2239
+ moveItem(this.#text("moveToEnd"), () => move(visible.length - 1), at < visible.length - 1 ? undefined : this.#text("lastColumn")),
1144
2240
  ],
1145
2241
  },
1146
2242
  {
1147
2243
  kind: "submenu",
1148
- label: "Width",
1149
- items: [
1150
- { label: "Wider", run: () => width(40) },
1151
- { label: "Narrower", run: () => width(-40) },
1152
- { label: "Fit the space", run: () => set(setWidth(view, field, undefined)) },
1153
- ],
2244
+ label: this.#text("menuWidth"),
2245
+ items: resizeRefusal
2246
+ ? [this.#text("wider"), this.#text("narrower"), this.#text("fitSpace")].map((label) => ({ label, run: () => { }, disabled: resizeRefusal }))
2247
+ : [
2248
+ { label: this.#text("wider"), run: () => width(40) },
2249
+ { label: this.#text("narrower"), run: () => width(-40) },
2250
+ { label: this.#text("fitSpace"), run: () => set(setWidth(view, field, undefined)) },
2251
+ ],
1154
2252
  },
1155
2253
  { kind: "separator" },
1156
- ...(visible.length > 1 ? [{ label: `Hide ${title}`, run: () => set(setHidden(view, field, true)) }] : []),
1157
- { 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") },
1158
2258
  ];
1159
2259
  }
1160
2260
  #openMenu(field, anchor, returnTo = anchor) {
1161
- const title = this.#collection?.type.field(field)?.title ?? field;
1162
- this.querySelector(".aster-grid-menu")?.openFrom(anchor, this.#menuItems(field), `${title} column`, returnTo);
2261
+ const title = this.#title(field);
2262
+ this.querySelector(".aster-grid-menu")?.openFrom(anchor, this.#menuItems(field), this.#text("rowColumn", { field: title }), returnTo);
1163
2263
  }
1164
2264
  /**
1165
2265
  * The row's menu: the page's actions for the record, then the column's
@@ -1167,7 +2267,7 @@ export class Grid extends AsterElement {
1167
2267
  */
1168
2268
  #openRowMenu(row, col, point) {
1169
2269
  const item = untracked(() => this.#layout()).at(row);
1170
- const field = this.#view.peek().columns.filter((c) => !c.hidden)[col]?.field;
2270
+ const field = this.#visibleColumns(this.#view.peek())[col]?.field;
1171
2271
  if (!field)
1172
2272
  return;
1173
2273
  const title = this.#collection.type.field(field)?.title ?? field;
@@ -1177,20 +2277,39 @@ export class Grid extends AsterElement {
1177
2277
  const items = [
1178
2278
  ...actions,
1179
2279
  ...(actions.length ? [{ kind: "separator" }] : []),
1180
- { kind: "submenu", label: `${title} column`, items: this.#menuItems(field) },
2280
+ { kind: "submenu", label: this.#text("rowColumn", { field: title }), items: this.#menuItems(field) },
1181
2281
  ];
1182
2282
  const cell = this.querySelector(`#${CSS.escape(`${this.#id}-${row}-${col}`)}`);
1183
2283
  const rect = cell?.getBoundingClientRect();
1184
2284
  const at = point ?? { x: rect?.left ?? 0, y: rect?.bottom ?? 0 };
1185
- this.querySelector(".aster-grid-menu")?.openAt(at, items, key !== undefined ? "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"));
1186
2286
  }
1187
2287
  // ── filters ──
2288
+ /** A filter cell whose operator allowlist leaves no usable control. */
2289
+ #filterRefused(reason) {
2290
+ return html `<span class="aster-grid-filter-refused" data-filterable="false" title=${reason}>${this.#text("notFilterable")}<span class="aster-sr-only"> ${reason}</span></span>`;
2291
+ }
1188
2292
  #filter(column, field, view) {
1189
2293
  if (!field)
1190
2294
  return nothing;
1191
- const 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);
1192
2300
  const current = view.filters[column.field];
1193
2301
  const lookup = this.#lookups.value[column.field];
2302
+ if (lookup && !operators.includes("in"))
2303
+ return this.#filterRefused(this.#filterOperatorRefusal(column.field, "in"));
2304
+ if (!lookup && (field.type === "Edm.Boolean" || field.allowedValues) && !operators.includes("eq"))
2305
+ return this.#filterRefused(this.#filterOperatorRefusal(column.field, "eq"));
2306
+ const ranged = isNumeric(field) || field.type === "Edm.Date";
2307
+ if (!lookup && !(field.type === "Edm.Boolean" || field.allowedValues) && !ranged && !operators.includes("contains")) {
2308
+ return this.#filterRefused(this.#filterOperatorRefusal(column.field, "contains"));
2309
+ }
2310
+ if (!lookup && ranged && !["eq", "gt", "ge", "lt", "le"].some((op) => operators.includes(op))) {
2311
+ return this.#filterRefused(this.#filterOperatorRefusal(column.field, "eq"));
2312
+ }
1194
2313
  const pick = (options, value, to) => html `<aster-choice .label=${`Filter ${title}`} .placeholder=${"Any"} .options=${[{ value: "\u0000any", label: "Any" }, ...options]}
1195
2314
  .value=${value ?? "\u0000any"}
1196
2315
  @change=${(e) => this.#setView(setFilter(this.#view.peek(), column.field, e.detail === "\u0000any" ? undefined : to(e.detail)))}></aster-choice>`;
@@ -1208,7 +2327,6 @@ export class Grid extends AsterElement {
1208
2327
  return pick([{ value: true, label: "Yes" }, { value: false, label: "No" }], chosen, eq);
1209
2328
  if (field.allowedValues)
1210
2329
  return pick(field.allowedValues.map((a) => ({ value: a.value, label: a.label ?? String(a.value) })), chosen, eq);
1211
- const ranged = isNumeric(field) || field.type === "Edm.Date";
1212
2330
  const text = this.#filterText.value[column.field] ?? (ranged ? rangeText(current) : current && "value" in current ? String(current.value) : "");
1213
2331
  const error = this.#filterErrors.value[column.field];
1214
2332
  const errorId = `${this.#id}-filter-${column.field}`;
@@ -1223,14 +2341,14 @@ export class Grid extends AsterElement {
1223
2341
  this.querySelector(`#${CSS.escape(button)}`)?.focus();
1224
2342
  };
1225
2343
  return html `<label class="aster-field" data-ring-owner style=${date ? anchorStyle(anchor) : nothing}>
1226
- <input .value=${live(text)} aria-label=${`Filter ${title}`} aria-invalid=${error ? "true" : nothing}
2344
+ <input .value=${live(text)} aria-label=${this.#text("filterField", { field: title })} aria-invalid=${error ? "true" : nothing}
1227
2345
  aria-describedby=${error ? errorId : nothing}
1228
2346
  placeholder=${ranged ? (date ? ">=1 Oct 2026" : ">5 or 1..3") : "Contains"}
1229
2347
  @input=${(e) => this.#typeFilter(column.field, field, e.target.value, ranged)}
1230
2348
  @keydown=${(e) => e.key === "Enter" && this.#flushFilter(column.field)}
1231
2349
  @blur=${() => this.#flushFilter(column.field)}>
1232
2350
  ${date
1233
- ? html `<button type="button" class="aster-date-button" id=${button} aria-label=${`Choose a date to filter ${title}`}
2351
+ ? html `<button type="button" class="aster-date-button" id=${button} aria-label=${this.#text("chooseFilterDate", { field: title })}
1234
2352
  aria-haspopup="dialog" aria-expanded=${open ? "true" : "false"} aria-controls=${dialog}
1235
2353
  @click=${(e) => {
1236
2354
  e.preventDefault();
@@ -1277,6 +2395,9 @@ export class Grid extends AsterElement {
1277
2395
  const parsed = ranged
1278
2396
  ? parseRange(field, text)
1279
2397
  : { criteria: containsText(name, text) };
2398
+ const refused = parsed.criteria && !parsed.error ? this.#criteriaOperatorRefusal(name, parsed.criteria) : undefined;
2399
+ if (refused)
2400
+ parsed.error = refused;
1280
2401
  const { [name]: _, ...errors } = this.#filterErrors.peek();
1281
2402
  this.#filterErrors.value = parsed.error ? { ...errors, [name]: parsed.error } : errors;
1282
2403
  if (!parsed.error)
@@ -1286,12 +2407,17 @@ export class Grid extends AsterElement {
1286
2407
  this.#filterTimers.set(name, setTimeout(apply, FILTER_DELAY));
1287
2408
  }
1288
2409
  #toggleFilters(view) {
1289
- 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();
1290
2413
  if (showing && Object.keys(view.filters).length > 0) {
1291
2414
  this.#filterText.value = {};
1292
2415
  this.#filterErrors.value = {};
1293
2416
  this.#setView({ ...view, filters: {} });
1294
2417
  }
2418
+ // A forced row stays open; the button still clears its filters.
2419
+ if (this.#chrome.peek().filterRow === true)
2420
+ return;
1295
2421
  this.#showFilters.value = !showing;
1296
2422
  }
1297
2423
  #optionsFor(field, lookup) {
@@ -1307,7 +2433,7 @@ export class Grid extends AsterElement {
1307
2433
  return options;
1308
2434
  }
1309
2435
  // ── cells ──
1310
- #cell(column, field, entity, key, ctx) {
2436
+ #cell(column, field, entity, key, ctx, mapped) {
1311
2437
  const render = this.#renderers.value[column.field];
1312
2438
  if (render)
1313
2439
  return render(entity, key);
@@ -1315,27 +2441,32 @@ export class Grid extends AsterElement {
1315
2441
  const formatter = this.#formatters.value[column.field];
1316
2442
  if (formatter)
1317
2443
  return formatter(value, entity, field);
2444
+ if (mapped)
2445
+ return mapped.label;
1318
2446
  const lookup = this.#lookups.value[column.field];
1319
2447
  if (lookup && value !== null && value !== undefined) {
1320
2448
  const related = ctx.lookup(lookup.set, keyText([value])).value;
1321
2449
  return related ? String(related[lookup.display] ?? "") : html `<span class="aster-grid-skeleton"></span>`;
1322
2450
  }
1323
- if (field?.type === "Edm.Boolean")
1324
- return value ? "Yes" : "";
1325
- if (field?.type === "Edm.Date" && typeof value === "string")
1326
- return formatDate(value);
1327
- if (field?.type === "Edm.DateTimeOffset" && typeof value === "string")
1328
- return absoluteTime(value);
1329
- return formatValue(field, value);
2451
+ return cellText(field, value, this.#presentation.value.columns?.[column.field]?.format);
1330
2452
  }
1331
2453
  #external() {
1332
- 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;
1333
2461
  }
1334
2462
  #syncBound() {
1335
2463
  const collection = this.#external();
1336
2464
  const view = this.#view.value;
1337
2465
  const base = this.#base.value;
1338
2466
  const paths = this.#sortPaths.value;
2467
+ // Capability changes alter the projected query even when the view doesn't.
2468
+ this.#policy.value;
2469
+ this.#ctx.value;
1339
2470
  if (!collection) {
1340
2471
  this.#bound = undefined;
1341
2472
  return;
@@ -1357,9 +2488,10 @@ export class Grid extends AsterElement {
1357
2488
  }
1358
2489
  if (!view)
1359
2490
  return;
1360
- const parts = [this.#boundBase, base, ...Object.values(view.filters)].filter((c) => c !== undefined);
2491
+ const parts = [this.#boundBase, base].filter((c) => c !== undefined);
1361
2492
  const filter = parts.length > 1 ? { op: "and", args: parts } : parts[0];
1362
- 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);
1363
2495
  if (JSON.stringify([query.filter, query.orderBy]) === JSON.stringify([projected.filter, projected.orderBy]))
1364
2496
  return;
1365
2497
  const { filter: _filter, orderBy: _orderBy, ...rest } = query;
@@ -1370,6 +2502,9 @@ export class Grid extends AsterElement {
1370
2502
  }
1371
2503
  #recoverActive() {
1372
2504
  this.#version.value;
2505
+ // Paged mode keeps the active row inside the page it chose.
2506
+ if (this.#pagedMode())
2507
+ return;
1373
2508
  const grouping = this.#grouping;
1374
2509
  if (grouping) {
1375
2510
  const rows = grouping.rows.value;
@@ -1418,19 +2553,20 @@ export class Grid extends AsterElement {
1418
2553
  });
1419
2554
  }
1420
2555
  #choose(key, index, extend, toggle) {
1421
- if (!this.#selectable.peek())
1422
- return;
2556
+ if (!this.#selectionAllowed())
2557
+ return false;
1423
2558
  const selection = this.#selection.peek();
1424
2559
  if (!selection)
1425
- return;
2560
+ return false;
1426
2561
  let accepted = true;
1427
2562
  if (extend && selection.multiple) {
1428
- const anchor = this.#selectionAnchor;
1429
- const slots = selection.collection.keys.peek();
1430
- const loaded = anchor ? slots.indexOf(anchor.key) : -1;
1431
- const from = loaded >= 0 ? loaded : anchor?.query === selection.collection.query ? anchor.index : index;
1432
- const range = slots.slice(Math.min(from, index), Math.max(from, index) + 1).filter((k) => k !== undefined);
1433
- accepted = selection.selectAll(range);
2563
+ accepted = selection.selectAll(this.#shiftRange(selection, key, index, this.#selectionAnchor));
2564
+ }
2565
+ else if (!extend && this.#cascadeEnabled(selection)) {
2566
+ // Cascades settle several keys at once: toggling the clicked row first
2567
+ // would keep it selected when an ordered selection rejects the whole.
2568
+ accepted = this.#cascadeChoose(selection, key, !selection.keys.peek().includes(key));
2569
+ this.#selectionAnchor = { key, index, query: selection.collection.query };
1434
2570
  }
1435
2571
  else {
1436
2572
  if (toggle || selection.ordered)
@@ -1440,9 +2576,80 @@ export class Grid extends AsterElement {
1440
2576
  this.#selectionAnchor = { key, index, query: selection.collection.query };
1441
2577
  }
1442
2578
  this.#selectionNote.value = accepted ? "" : `Select at most ${selection.maxOrdered} ordered items. Deselect an item to add another.`;
2579
+ return accepted;
2580
+ }
2581
+ /** The keys between the anchor and the click: visible tree order when there is a tree, membership order otherwise. */
2582
+ #shiftRange(selection, key, index, anchor) {
2583
+ if (this.#tree && anchor) {
2584
+ const visible = this.#layout().keys;
2585
+ const from = visible.indexOf(anchor.key);
2586
+ const to = visible.indexOf(key);
2587
+ if (from >= 0 && to >= 0) {
2588
+ return visible.slice(Math.min(from, to), Math.max(from, to) + 1).filter((k) => k !== undefined);
2589
+ }
2590
+ }
2591
+ const slots = selection.collection.keys.peek();
2592
+ const loaded = anchor ? slots.indexOf(anchor.key) : -1;
2593
+ const from = loaded >= 0 ? loaded : anchor?.query === selection.collection.query ? anchor.index : index;
2594
+ return slots.slice(Math.min(from, index), Math.max(from, index) + 1).filter((k) => k !== undefined);
2595
+ }
2596
+ /** Cascade needs a tree, a multiple shared selection and the tree's switch. */
2597
+ #cascadeEnabled(selection) {
2598
+ return selection.multiple && this.#tree !== undefined && this.#treeOptions.value?.cascadeSelection === true;
2599
+ }
2600
+ /** The loaded tree rows whose parent chain passes through `key`. */
2601
+ #loadedDescendants(key) {
2602
+ const rows = this.#tree?.rows.peek() ?? [];
2603
+ const parentOf = new Map();
2604
+ for (const row of rows)
2605
+ if (row.key !== undefined)
2606
+ parentOf.set(row.key, row.parent);
2607
+ const under = (candidate) => {
2608
+ let parent = parentOf.get(candidate);
2609
+ while (parent !== undefined) {
2610
+ if (parent === key)
2611
+ return true;
2612
+ parent = parentOf.get(parent);
2613
+ }
2614
+ return false;
2615
+ };
2616
+ return rows.flatMap((row) => (row.key !== undefined && under(row.key) ? [row.key] : []));
2617
+ }
2618
+ /**
2619
+ * Applies a selection to a node's loaded descendants, and settles each
2620
+ * ancestor on whether all its loaded children are chosen. `choosing` is
2621
+ * the clicked row's state before this call, which itself changes nothing.
2622
+ */
2623
+ #cascadeChoose(selection, key, choosing) {
2624
+ const rows = this.#tree?.rows.peek() ?? [];
2625
+ const parentOf = new Map();
2626
+ for (const row of rows)
2627
+ if (row.key !== undefined)
2628
+ parentOf.set(row.key, row.parent);
2629
+ const under = new Set([key, ...this.#loadedDescendants(key)]);
2630
+ let next = [...selection.keys.peek()];
2631
+ if (choosing) {
2632
+ for (const row of rows)
2633
+ if (row.key !== undefined && under.has(row.key) && !next.includes(row.key))
2634
+ next.push(row.key);
2635
+ }
2636
+ else {
2637
+ next = next.filter((candidate) => !under.has(candidate));
2638
+ }
2639
+ let parent = parentOf.get(key);
2640
+ while (parent !== undefined) {
2641
+ const children = rows.flatMap((row) => (row.key !== undefined && row.parent === parent ? [row.key] : []));
2642
+ const completed = children.length > 0 && children.every((child) => next.includes(child));
2643
+ if (completed && !next.includes(parent))
2644
+ next.push(parent);
2645
+ if (!completed)
2646
+ next = next.filter((candidate) => candidate !== parent);
2647
+ parent = parentOf.get(parent);
2648
+ }
2649
+ return selection.selectAll(next);
1443
2650
  }
1444
2651
  #moveSelection(key, delta) {
1445
- if (!this.#selectable.peek())
2652
+ if (!this.#selectionAllowed())
1446
2653
  return;
1447
2654
  const selection = this.#selection.peek();
1448
2655
  if (!selection)
@@ -1488,6 +2695,88 @@ export class Grid extends AsterElement {
1488
2695
  ?disabled=${index === chosen.length - 1} @click=${() => this.#moveSelection(key, 1)}>↓</button>` : nothing}
1489
2696
  </span>`;
1490
2697
  }
2698
+ /**
2699
+ * The selection column's header. With a multiple shared selection it is a
2700
+ * select-all for the loaded rows, mixed when only some are chosen; a single
2701
+ * or owned selection has nothing to select in bulk.
2702
+ */
2703
+ #selectionHead(selection, chosen) {
2704
+ const loaded = this.#tree
2705
+ ? this.#tree.rows.peek().flatMap((row) => (row.key !== undefined ? [row.key] : []))
2706
+ : this.#collection?.keys.value.filter((key) => key !== undefined) ?? [];
2707
+ const loadedSet = new Set(loaded);
2708
+ const picked = loaded.filter((key) => chosen.includes(key));
2709
+ const checked = loaded.length > 0 && picked.length === loaded.length;
2710
+ const mixed = picked.length > 0 && !checked;
2711
+ const multiple = selection?.multiple === true;
2712
+ return html `<div class="aster-grid-cell aster-grid-select aster-grid-select-head" role="columnheader" aria-colindex="1" id=${`${this.#id}-h-s`}>
2713
+ ${multiple
2714
+ ? html `<input type="checkbox" .checked=${checked} .indeterminate=${mixed} tabindex="0"
2715
+ aria-label=${checked ? "Clear the selection of loaded rows" : mixed ? "Select all loaded rows, some are selected" : "Select all loaded rows"}
2716
+ @change=${(e) => {
2717
+ const input = e.currentTarget;
2718
+ if (!selection)
2719
+ return;
2720
+ if (checked) {
2721
+ const keep = chosen.filter((key) => !loadedSet.has(key));
2722
+ selection.selectAll(keep);
2723
+ this.#selectionNote.value = keep.length ? `Kept ${keep.length} selected ${keep.length === 1 ? "row" : "rows"} not loaded here.` : "";
2724
+ }
2725
+ else if (selection.selectAll([...new Set([...chosen, ...loaded])])) {
2726
+ this.#selectionNote.value = "";
2727
+ }
2728
+ else {
2729
+ this.#selectionNote.value = `Select at most ${selection.maxOrdered} ordered items. Deselect an item to add another.`;
2730
+ }
2731
+ const now = selection.keys.peek();
2732
+ const picked = loaded.filter((key) => now.includes(key)).length;
2733
+ input.checked = loaded.length > 0 && picked === loaded.length;
2734
+ input.indeterminate = picked > 0 && picked < loaded.length;
2735
+ }}>`
2736
+ : html `<span class="aster-sr-only">Selection</span>`}
2737
+ </div>`;
2738
+ }
2739
+ /** One row's checkbox in the leading selection column. */
2740
+ #selectionCell(key, index, selection, chosen, entity, hidden) {
2741
+ if (key === undefined)
2742
+ return html `<div class="aster-grid-cell aster-grid-select" role="gridcell" aria-colindex="1" data-col="-1"></div>`;
2743
+ const chosenIndex = chosen.indexOf(key);
2744
+ const checked = selection ? chosenIndex >= 0 : key === this.#selected.value;
2745
+ const field = this.#collection?.type.titleField;
2746
+ const name = field && hidden?.has(field) ? "Hidden" : String((entity && field ? entity[field] : undefined) ?? key);
2747
+ // A folder with some chosen descendants is mixed while cascade is on.
2748
+ const mixed = !checked && selection?.multiple === true && this.#tree !== undefined && this.#treeOptions.value?.cascadeSelection === true
2749
+ && this.#loadedDescendants(key).some((descendant) => chosen.includes(descendant));
2750
+ const label = mixed
2751
+ ? `Select all under ${name}, some are selected`
2752
+ : selection?.multiple ? `${checked ? "Deselect" : "Select"} ${name}` : `${checked ? "Selected" : "Select"} ${name}`;
2753
+ return html `<div class="aster-grid-cell aster-grid-select" role="gridcell" aria-colindex="1" data-col="-1" data-label="Select">
2754
+ <input type="checkbox" .checked=${checked} .indeterminate=${mixed} tabindex="-1" aria-label=${label}
2755
+ @change=${(e) => {
2756
+ const input = e.currentTarget;
2757
+ if (selection)
2758
+ this.#choose(key, this.#collection.keys.peek().indexOf(key), false, selection.multiple);
2759
+ else
2760
+ this.#activate(index, this.#active.peek().col);
2761
+ // The native toggle ran first; settle it on the state that resulted.
2762
+ input.checked = selection ? selection.keys.peek().includes(key) : key === this.#selected.peek();
2763
+ const chosenNow = selection?.keys.peek() ?? [];
2764
+ input.indeterminate = !input.checked && selection?.multiple === true && this.#tree !== undefined && this.#treeOptions.peek()?.cascadeSelection === true
2765
+ && this.#loadedDescendants(key).some((descendant) => chosenNow.includes(descendant));
2766
+ }}
2767
+ @keydown=${(e) => {
2768
+ if (e.key === "Escape") {
2769
+ e.preventDefault();
2770
+ this.focus();
2771
+ }
2772
+ else if (e.key !== "Tab")
2773
+ e.stopPropagation();
2774
+ }}
2775
+ @dblclick=${(e) => e.stopPropagation()}
2776
+ @contextmenu=${(e) => e.stopPropagation()}>
2777
+ ${selection?.ordered && chosenIndex >= 0 ? html `<span class="aster-grid-select-rank" aria-hidden="true">${chosenIndex + 1}</span>` : nothing}
2778
+ </div>`;
2779
+ }
1491
2780
  // ── data and view ──
1492
2781
  /** With `persist`, the person's last view replaces the page's, reconciled with today's columns. */
1493
2782
  #restore() {
@@ -1514,6 +2803,9 @@ export class Grid extends AsterElement {
1514
2803
  }
1515
2804
  /** Stored views have been read; until then, writing would replace them. */
1516
2805
  #restored = false;
2806
+ /** The view and effective selection the change events last reported. */
2807
+ #viewBefore;
2808
+ #selectionBefore;
1517
2809
  /** Publishes the shared selection or active row, plus view and total. */
1518
2810
  #announce() {
1519
2811
  const key = this.#publish.value;
@@ -1542,9 +2834,52 @@ export class Grid extends AsterElement {
1542
2834
  ...(publication?.hidden ? { hidden: publication.hidden } : {}),
1543
2835
  });
1544
2836
  }
2837
+ /** Drift is announced once per set of dropped entries; dismissing hides the note, not the announcement. */
2838
+ #driftAnnounced = "";
2839
+ /** Notifies after the view changed, whichever code changed it; never cancelable. */
2840
+ #viewChanged() {
2841
+ const view = this.#view.value;
2842
+ if (view === this.#viewBefore)
2843
+ return;
2844
+ const before = this.#viewBefore;
2845
+ this.#viewBefore = view;
2846
+ if (!before || !view)
2847
+ return;
2848
+ if (JSON.stringify(before.sort) !== JSON.stringify(view.sort))
2849
+ this.emit("sort-change", { sort: view.sort }, { cancelable: false });
2850
+ if (JSON.stringify(before.filters) !== JSON.stringify(view.filters))
2851
+ this.emit("filter-change", { filters: view.filters }, { cancelable: false });
2852
+ if (JSON.stringify(before.criteria) !== JSON.stringify(view.criteria))
2853
+ this.emit("criteria-change", { criteria: view.criteria }, { cancelable: false });
2854
+ }
2855
+ /** Notifies after the effective selection changed; owned selection reports its one key. */
2856
+ #selectionChanged() {
2857
+ const shared = this.#selection.value;
2858
+ const keys = shared ? [...shared.keys.value] : this.#selected.value === undefined ? [] : [this.#selected.value];
2859
+ const id = keys.join("\u0000");
2860
+ if (id === this.#selectionBefore)
2861
+ return;
2862
+ const first = this.#selectionBefore === undefined;
2863
+ this.#selectionBefore = id;
2864
+ if (!first)
2865
+ this.emit("selection-change", { keys }, { cancelable: false });
2866
+ }
2867
+ #driftAnnounce() {
2868
+ const entries = this.#drift.value;
2869
+ const key = this.#driftKey(entries);
2870
+ if (!entries.length) {
2871
+ this.#driftAnnounced = "";
2872
+ this.#driftDismissed.value = "";
2873
+ return;
2874
+ }
2875
+ if (key === this.#driftAnnounced)
2876
+ return;
2877
+ this.#driftAnnounced = key;
2878
+ announce(this.#driftText(entries));
2879
+ }
1545
2880
  #setView(view) {
1546
2881
  const request = ++this.#viewRequest;
1547
- if (this.#editing.peek() && (JSON.stringify(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))) {
1548
2883
  void this.#endEdit(true).then((saved) => {
1549
2884
  if (saved && this.isConnected && request === this.#viewRequest)
1550
2885
  this.#commitView(view);
@@ -1554,8 +2889,8 @@ export class Grid extends AsterElement {
1554
2889
  this.#commitView(view);
1555
2890
  }
1556
2891
  #commitView(view) {
1557
- const beforeGroups = groupFields(this.#view.peek());
1558
- const afterGroups = groupFields(view);
2892
+ const beforeGroups = this.#groupedFields(this.#view.peek());
2893
+ const afterGroups = this.#groupedFields(view);
1559
2894
  const focused = this.contains(document.activeElement) ? document.activeElement?.closest(".aster-grid-group-tools") : null;
1560
2895
  if (focused && JSON.stringify(beforeGroups) !== JSON.stringify(afterGroups)) {
1561
2896
  const field = focused.dataset.groupField;
@@ -1577,6 +2912,7 @@ export class Grid extends AsterElement {
1577
2912
  if (binding) {
1578
2913
  untracked(() => {
1579
2914
  this.#selected.value = undefined;
2915
+ this.#ownedOptions = undefined;
1580
2916
  if (this.#collection === binding) {
1581
2917
  this.#ownsCollection = false;
1582
2918
  return;
@@ -1593,11 +2929,14 @@ export class Grid extends AsterElement {
1593
2929
  if (!this.#view.peek())
1594
2930
  this.view = defaultView(ctx.schema.set(set).type);
1595
2931
  });
1596
- 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)))
1597
2935
  return;
1598
2936
  const treeOptions = untracked(() => this.#treeOptions.value);
1599
2937
  if (treeOptions) {
1600
2938
  untracked(() => {
2939
+ this.#ownedOptions = undefined;
1601
2940
  this.#tree?.dispose();
1602
2941
  const filters = computed(() => {
1603
2942
  return this.#query(set, this.#view.value ?? { columns: [], sort: [], filters: {}, frozen: 0 }).filter;
@@ -1607,9 +2946,10 @@ export class Grid extends AsterElement {
1607
2946
  ...treeOptions,
1608
2947
  base: computed(() => this.#base.value),
1609
2948
  filter: filters,
1610
- orderBy: computed(() => viewQuery(set, this.#view.value ?? { columns: [], sort: [], filters: {}, frozen: 0 }, undefined, this.#sortPaths.value).orderBy ?? []),
2949
+ orderBy: computed(() => viewQuery(set, this.#view.value ?? { columns: [], sort: [], filters: {}, frozen: 0 }, undefined, this.#sortPaths.value, this.#capabilities()).orderBy ?? []),
1611
2950
  });
1612
2951
  this.#collection = this.#tree.level(undefined);
2952
+ this.#autoOpen(this.#tree, treeOptions.autoOpen ?? 0);
1613
2953
  this.#version.value++;
1614
2954
  });
1615
2955
  this.onDisconnect(() => {
@@ -1623,20 +2963,24 @@ export class Grid extends AsterElement {
1623
2963
  untracked(() => {
1624
2964
  if (this.#ownsCollection && this.#collection)
1625
2965
  ctx.release(this.#collection);
1626
- this.#collection = ctx.collection(query, { pageSize: 100 });
2966
+ this.#collection = ctx.collection(query, options);
1627
2967
  this.#ownsCollection = true;
2968
+ this.#ownedOptions = optionsKey;
1628
2969
  this.#version.value++;
1629
2970
  });
1630
2971
  }
2972
+ /** The owned collection's data options, so a change recreates it. */
2973
+ #ownedOptions;
1631
2974
  /** A new query (filter, sort, grouping, base) starts at the top, with its first row active. */
1632
2975
  #toTop() {
1633
2976
  const view = this.#view.value;
1634
2977
  // Before the view arrives there's no query to compare with.
1635
2978
  if (!view)
1636
2979
  return;
1637
- const key = JSON.stringify([view.filters, view.criteria, view.sort, view.groupBy, this.#base.value, this.#external()?.query]);
2980
+ const key = JSON.stringify([view.filters, view.criteria, view.sort, view.groupBy, this.#base.value, this.#external()?.query, this.#capabilitiesVersion.value]);
1638
2981
  untracked(() => {
1639
2982
  if (this.#lastQuery !== undefined && this.#lastQuery !== key) {
2983
+ this.#page.value = 0;
1640
2984
  const scroll = this.querySelector(".aster-grid-scroll");
1641
2985
  if (scroll)
1642
2986
  scroll.scrollTop = 0;
@@ -1656,13 +3000,18 @@ export class Grid extends AsterElement {
1656
3000
  #load() {
1657
3001
  const collection = this.#collection;
1658
3002
  this.#version.value;
1659
- if (!collection)
3003
+ if (!collection || this.#deferred())
1660
3004
  return;
1661
3005
  const layout = this.#layout();
1662
3006
  this.#heights.setKeys(layout.keys);
1663
3007
  const { start, end } = this.#heights.window(this.#top.value, this.#viewport.value, OVERSCAN);
1664
3008
  const wanted = new Map();
1665
- if (!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)
1666
3015
  wanted.set(collection, { from: start, to: Math.max(end, 1) });
1667
3016
  else {
1668
3017
  // A tree's top level loads before it has lines to look at.
@@ -1715,7 +3064,25 @@ export class Grid extends AsterElement {
1715
3064
  },
1716
3065
  };
1717
3066
  }
1718
- if (!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) {
1719
3086
  const keys = collection.keys.value;
1720
3087
  const total = collection.count.value;
1721
3088
  return { keys, total, ...atLeast(collection), groups: undefined, at: (i) => ({ kind: "row", key: keys[i], collection, local: i }) };
@@ -1728,7 +3095,8 @@ export class Grid extends AsterElement {
1728
3095
  #groupRow(item, index, rowIndex, columns, _view, active) {
1729
3096
  const field = this.#collection.type.field(item.field);
1730
3097
  const label = this.#groupLabel(item.field, field, item.group.value);
1731
- const 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(" / ");
1732
3100
  return html `<div class="aster-grid-row aster-grid-group" role="row" aria-rowindex=${rowIndex} data-index=${index}
1733
3101
  data-key=${item.id} aria-level=${item.level} aria-posinset=${item.position} aria-setsize=${item.siblings}
1734
3102
  aria-expanded=${item.expanded ? "true" : "false"} aria-busy=${item.loading ? "true" : "false"} style=${`--level:${item.level - 1}`}>
@@ -1736,13 +3104,13 @@ export class Grid extends AsterElement {
1736
3104
  ?data-active=${active} @click=${(e) => { if (!e.target.closest("button"))
1737
3105
  this.#toggleGroup(item.id); }}>
1738
3106
  <svg viewBox="0 0 10 10" aria-hidden="true"><path d="M3.5 2 6.5 5l-3 3"/></svg>
1739
- <span class="aster-sr-only">${field?.title ?? item.field}: </span>
3107
+ <span class="aster-sr-only">${this.#title(item.field)}: </span>
1740
3108
  <span class="aster-grid-group-label">${label}</span>
1741
3109
  <span class="aster-grid-group-count">${new Intl.NumberFormat("en-GB").format(item.group.count)}</span>
1742
3110
  ${item.totalsAvailable ? this.#summaries.value.map((summary, i) => summary.method === "count" ? nothing : html `<span class="aster-grid-group-summary">${this.#summaryLabel(summary)}: ${this.#summaryValue(summary, item.group.totals[`s${i}`] ?? null)}</span>`) : nothing}
1743
- ${item.loading ? html `<span class="aster-grid-group-loading" role="status">Loading…</span>` : nothing}
1744
- ${item.error !== undefined ? html `<span class="aster-error" role="status">Couldn’t load group.</span>
1745
- <button type="button" class="aster-grid-tool" aria-label=${`Retry group ${path}`} @click=${() => { this.#activate(index, 0); this.focus({ preventScroll: true }); void this.#grouping?.retry(item.id).catch(() => { }); }}>Retry</button>` : nothing}
3111
+ ${item.loading ? html `<span class="aster-grid-group-loading" role="status">${this.#text("loading")}</span>` : nothing}
3112
+ ${item.error !== undefined ? html `<span class="aster-error" role="status">${this.#text("groupLoadFailed")}</span>
3113
+ <button type="button" class="aster-grid-tool" aria-label=${this.#text("retryGroup", { group: path })} @click=${() => { this.#activate(index, 0); this.focus({ preventScroll: true }); void this.#grouping?.retry(item.id).catch(() => { }); }}>${this.#text("retry")}</button>` : nothing}
1746
3114
  </div>
1747
3115
  </div>`;
1748
3116
  }
@@ -1772,17 +3140,31 @@ export class Grid extends AsterElement {
1772
3140
  if (summary.label)
1773
3141
  return summary.label;
1774
3142
  if (summary.method === "count")
1775
- return "Rows";
1776
- const title = this.#collection?.type.field(summary.field)?.title ?? summary.field;
1777
- const names = { sum: "Sum", min: "Minimum", max: "Maximum", average: "Average", countdistinct: "Distinct" };
1778
- return `${names[summary.method]} ${title}`;
3143
+ return this.#text("summaryRows");
3144
+ const title = this.#title(summary.field);
3145
+ switch (summary.method) {
3146
+ case "sum":
3147
+ return this.#text("summarySum", { field: title });
3148
+ case "min":
3149
+ return this.#text("summaryMinimum", { field: title });
3150
+ case "max":
3151
+ return this.#text("summaryMaximum", { field: title });
3152
+ case "average":
3153
+ return this.#text("summaryAverage", { field: title });
3154
+ case "countdistinct":
3155
+ return this.#text("summaryDistinct", { field: title });
3156
+ default:
3157
+ return title;
3158
+ }
1779
3159
  }
1780
3160
  #summaryValue(summary, value) {
1781
3161
  if (value === null)
1782
3162
  return "—";
1783
3163
  if (summary.method === "count" || summary.method === "countdistinct")
1784
3164
  return new Intl.NumberFormat("en-GB").format(Number(value));
1785
- 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);
1786
3168
  }
1787
3169
  #summaryView() {
1788
3170
  const summary = this.#summary;
@@ -1790,20 +3172,26 @@ export class Grid extends AsterElement {
1790
3172
  return nothing;
1791
3173
  const values = summary.values.value;
1792
3174
  const status = summary.status.value;
1793
- return html `<section class="aster-grid-summary" aria-label=${`${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"}>
1794
3176
  <aster-progress-status class="aster-grid-summary-status" .busy=${status === "loading"}
1795
- .error=${status === "error" ? "Couldn’t load summaries." : undefined} .progressPolicy=${this.#progressPolicy.value}
1796
- .pendingText=${"Loading summaries…"} .completeText=${"Summaries updated"}></aster-progress-status>
1797
- ${status === "error" ? html `<button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void summary.refresh(); }}>Retry summaries</button>` : nothing}
3177
+ .error=${status === "error" ? this.#text("summaryLoadFailed") : undefined} .progressPolicy=${this.#progressPolicy.value}
3178
+ .pendingText=${this.#text("summaryLoading")} .completeText=${this.#text("summariesUpdated")}></aster-progress-status>
3179
+ ${status === "error" ? html `<button type="button" class="aster-grid-tool" @click=${() => { this.focus({ preventScroll: true }); void summary.refresh(); }}>${this.#text("retrySummaries")}</button>` : nothing}
1798
3180
  ${values ? html `<dl>${this.#summaries.value.map((s, i) => html `<div><dt>${this.#summaryLabel(s)}</dt><dd>${this.#summaryValue(s, values[s.method === "count" ? "count" : `s${i}`] ?? null)}</dd></div>`)}</dl>` : nothing}
1799
3181
  </section>`;
1800
3182
  }
1801
3183
  #groupLabel(name, field, value) {
3184
+ const column = this.#presentation.value.columns?.[name];
3185
+ const mapped = mappedValue(column, value);
3186
+ if (mapped)
3187
+ return mapped.label;
1802
3188
  if (value === null)
1803
3189
  return "None";
1804
3190
  const lookup = this.#lookups.value[name];
1805
3191
  if (lookup)
1806
3192
  return String(this.#ctx.peek().lookup(lookup.set, keyText([value])).value?.[lookup.display] ?? "…");
3193
+ if (column?.format)
3194
+ return cellText(field, value, column.format);
1807
3195
  if (field?.type === "Edm.Boolean")
1808
3196
  return value ? "Yes" : "No";
1809
3197
  if (field?.type === "Edm.Date" && typeof value === "string")
@@ -1812,14 +3200,6 @@ export class Grid extends AsterElement {
1812
3200
  return absoluteTime(value);
1813
3201
  return formatValue(field, value);
1814
3202
  }
1815
- #groupable(name) {
1816
- if (this.#tree || this.#external())
1817
- return false;
1818
- const field = this.#collection?.type.field(name);
1819
- if (!field)
1820
- return false;
1821
- return !(field.type === "Edm.String" && !field.allowedValues && (field.maxLength ?? 0) > 500);
1822
- }
1823
3203
  async #toggleGroup(id) {
1824
3204
  const grouping = this.#grouping;
1825
3205
  if (!grouping)
@@ -1849,16 +3229,21 @@ export class Grid extends AsterElement {
1849
3229
  }
1850
3230
  void grouping.toggle(id).catch(() => { });
1851
3231
  }
1852
- #reorderGroup(index, step) {
3232
+ #reorderGroup(field, step) {
1853
3233
  const view = this.#view.peek();
1854
3234
  if (!view)
1855
3235
  return;
1856
- const fields = [...groupFields(view)];
1857
- const to = index + step;
1858
- 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)
3240
+ return;
3241
+ const a = stored.indexOf(field);
3242
+ const b = stored.indexOf(neighbour);
3243
+ if (a < 0 || b < 0)
1859
3244
  return;
1860
- [fields[index], fields[to]] = [fields[to], fields[index]];
1861
- this.#setView(setGroup(view, fields));
3245
+ [stored[a], stored[b]] = [stored[b], stored[a]];
3246
+ this.#setView(setGroup(view, stored));
1862
3247
  }
1863
3248
  #groupSummaryConfig;
1864
3249
  #groupFieldsKey = "";
@@ -1867,7 +3252,7 @@ export class Grid extends AsterElement {
1867
3252
  #group() {
1868
3253
  const ctx = this.#ctx.value;
1869
3254
  const set = this.set;
1870
- const fields = this.#treeOptions.value ? [] : groupFields(this.#view.value);
3255
+ const fields = this.#groupedFields(this.#view.value);
1871
3256
  const key = JSON.stringify([set, fields]);
1872
3257
  const summaries = this.#summaries.value;
1873
3258
  untracked(() => {
@@ -1926,9 +3311,9 @@ export class Grid extends AsterElement {
1926
3311
  this.#anchor = { index, delta };
1927
3312
  this.#version.value++;
1928
3313
  }
1929
- #frozenOffsets(columns, frozen) {
3314
+ #frozenOffsets(columns, frozen, base = 0) {
1930
3315
  const out = [];
1931
- let left = 0;
3316
+ let left = base;
1932
3317
  for (let i = 0; i < Math.min(frozen, columns.length); i++) {
1933
3318
  out.push(left);
1934
3319
  left += columns[i].width ?? 0;
@@ -1946,7 +3331,7 @@ export class Grid extends AsterElement {
1946
3331
  * doesn't sort until the server can order by the related field.
1947
3332
  */
1948
3333
  #sortable(field) {
1949
- return !this.#lookups.peek()[field] || this.#sortPaths.peek()[field] !== undefined;
3334
+ return this.#sortRefusal(field) === undefined;
1950
3335
  }
1951
3336
  /**
1952
3337
  * Lookup columns sort by the related record's display field, through the
@@ -1969,15 +3354,22 @@ export class Grid extends AsterElement {
1969
3354
  return paths;
1970
3355
  });
1971
3356
  #sort(field, add) {
1972
- if (this.#dragged || !this.#sortable(field))
3357
+ if (this.#dragged)
1973
3358
  return;
3359
+ const refusal = this.#sortRefusal(field);
3360
+ if (refusal) {
3361
+ this.#refused.value = refusal;
3362
+ announce(refusal);
3363
+ return;
3364
+ }
3365
+ this.#refused.value = undefined;
1974
3366
  const view = this.#view.peek();
1975
3367
  if (view)
1976
- this.#setView(sortBy(view, field, add));
3368
+ this.#setView(sortBy(view, field, add && this.#multiSort()));
1977
3369
  }
1978
3370
  /** Drag the right edge of a header; JS writes only the column's width. */
1979
3371
  #resize(e, field) {
1980
- if (e.button !== 0)
3372
+ if (e.button !== 0 || this.#resizeRefusal(field))
1981
3373
  return;
1982
3374
  e.preventDefault();
1983
3375
  e.stopPropagation();
@@ -1996,6 +3388,12 @@ export class Grid extends AsterElement {
1996
3388
  #dragColumn(e, field) {
1997
3389
  if (e.button !== 0)
1998
3390
  return;
3391
+ const refusal = this.#moveRefusal(field);
3392
+ if (refusal) {
3393
+ this.#refused.value = refusal;
3394
+ announce(refusal);
3395
+ return;
3396
+ }
1999
3397
  const button = e.currentTarget;
2000
3398
  const from = e.clientX;
2001
3399
  this.#dragged = false;
@@ -2037,7 +3435,7 @@ export class Grid extends AsterElement {
2037
3435
  #dragRow(e) {
2038
3436
  const tree = this.#tree;
2039
3437
  const target = e.target;
2040
- if (!tree || !this.#treeOptions.peek()?.draggable || e.button !== 0 || !this.#editable.peek())
3438
+ if (!tree || !this.#treeOptions.peek()?.draggable || e.button !== 0 || !this.#editEnabled())
2041
3439
  return;
2042
3440
  if (target.closest("button, input, textarea, select, a, [data-editing], .aster-grid-expansion, .aster-grid-controls"))
2043
3441
  return;
@@ -2051,13 +3449,26 @@ export class Grid extends AsterElement {
2051
3449
  let label;
2052
3450
  let over;
2053
3451
  // The node's ancestors from stored records: a drop is refused where its own key is among them.
3452
+ const from = (() => {
3453
+ const parent = tree.entity(key)?.[tree.options.parentField];
3454
+ return parent === null || parent === undefined ? undefined : keyText([parent]);
3455
+ })();
3456
+ const options = this.#treeOptions.peek();
2054
3457
  const why = (to) => {
3458
+ // Structure refuses first; a drop rule is host policy on top.
2055
3459
  for (let k = to; k !== undefined;) {
2056
3460
  if (k === key)
2057
3461
  return "A row can’t move under itself or its own subtree.";
2058
3462
  const parent = tree.entity(k)?.[tree.options.parentField];
2059
3463
  k = parent === null || parent === undefined ? undefined : keyText([parent]);
2060
3464
  }
3465
+ if (options.reparent === false && to !== from)
3466
+ return "Rows can’t be moved to another parent on this page.";
3467
+ if (options.dropOnLeaves === false && to !== undefined) {
3468
+ const node = tree.rows.peek().find((row) => row.key === to);
3469
+ if (node?.folder === false)
3470
+ return "A row can’t be dropped on a leaf; drop it on a folder or the top level.";
3471
+ }
2061
3472
  return undefined;
2062
3473
  };
2063
3474
  pressDrag(e, { distance: 6, escape: true, capture: scroll, scroller: () => scroll, inset: () => this.querySelector(".aster-grid-headgroup")?.offsetHeight ?? 0 }, {
@@ -2071,7 +3482,7 @@ export class Grid extends AsterElement {
2071
3482
  this.toggleAttribute("data-dragging", true);
2072
3483
  if (touch)
2073
3484
  this.#lifted.value = key;
2074
- announce(`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 }));
2075
3486
  },
2076
3487
  move: (x, y) => {
2077
3488
  // Above a finger, where it can be seen (below it near the top); beside a mouse pointer.
@@ -2107,7 +3518,7 @@ export class Grid extends AsterElement {
2107
3518
  const parent = tree.entity(key)?.[tree.options.parentField];
2108
3519
  const from = parent === null || parent === undefined ? undefined : keyText([parent]);
2109
3520
  if (!over || over.to === from) {
2110
- announce(`${title} not moved.`);
3521
+ announce(this.#text("dragNotMoved", { field: title }));
2111
3522
  return;
2112
3523
  }
2113
3524
  try {
@@ -2126,6 +3537,13 @@ export class Grid extends AsterElement {
2126
3537
  if (e.target.closest("button, input, select, textarea, a[href], [contenteditable], [data-editing]"))
2127
3538
  return;
2128
3539
  const row = e.target.closest('[role="row"][data-index]:not(.aster-grid-group)');
3540
+ const pressed = e.target.closest('[role="gridcell"]');
3541
+ if (row?.dataset.key && pressed && Number(pressed.dataset.col ?? -1) >= 0) {
3542
+ const column = Number(pressed.dataset.col);
3543
+ const target = { key: row.dataset.key, field: this.#visibleColumns(this.#view.peek())[column]?.field, row: Number(row.dataset.index), column, originalEvent: e };
3544
+ if (!this.emit("cell-double-click", target))
3545
+ return;
3546
+ }
2129
3547
  if (row?.dataset.key && !this.emit("row-open", { key: row.dataset.key }))
2130
3548
  return;
2131
3549
  // With expansion, a double-click opens the row rather than editing it in
@@ -2138,11 +3556,11 @@ export class Grid extends AsterElement {
2138
3556
  void this.collapse(key);
2139
3557
  return;
2140
3558
  }
2141
- if (!this.#editable.peek() || e.target.closest("[data-editing]"))
3559
+ if (!this.#editEnabled() || e.target.closest("[data-editing]"))
2142
3560
  return;
2143
3561
  const cell = e.target.closest('[role="gridcell"]');
2144
3562
  if (row?.dataset.key)
2145
- this.#startEdit(row.dataset.key, Number(cell?.getAttribute("aria-colindex") ?? 1) - 1);
3563
+ this.#startEdit(row.dataset.key, Number(cell?.dataset.col ?? 0));
2146
3564
  }
2147
3565
  /**
2148
3566
  * Starts editing a row: an edit model with the grid's rules in a child
@@ -2150,7 +3568,15 @@ export class Grid extends AsterElement {
2150
3568
  * saves first, and stays if it can't.
2151
3569
  */
2152
3570
  async #startEdit(key, col) {
2153
- if (this.#editing.peek()?.key === key)
3571
+ if (!this.#editEnabled() || this.#editing.peek()?.key === key)
3572
+ return;
3573
+ const columns = this.#visibleColumns(this.#view.peek());
3574
+ const field = columns[col]?.field;
3575
+ const type = this.#collection?.type;
3576
+ // Cell mode edits the pressed cell; a cell that isn't editable starts nothing.
3577
+ if (this.editMode === "cell" && (!field || !this.#fieldEditable(field) || type?.field(field)?.computed))
3578
+ return;
3579
+ if (!this.emit("edit-start", { key, field }, { cancelable: true }))
2154
3580
  return;
2155
3581
  if (!(await this.#endEdit(true)))
2156
3582
  return;
@@ -2162,7 +3588,18 @@ export class Grid extends AsterElement {
2162
3588
  const rules = this.#rules.peek();
2163
3589
  if (scope && Object.keys(rules).length)
2164
3590
  edit.applyRules(scope, `${this.#publish.peek() || "grid"}.row`, rules);
2165
- 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()) };
2166
3603
  requestAnimationFrame(() => this.#focusEditor(key, col));
2167
3604
  }
2168
3605
  #focusEditor(key, col) {
@@ -2170,8 +3607,8 @@ export class Grid extends AsterElement {
2170
3607
  // A user may choose another control before deferred initial focus runs.
2171
3608
  if (row?.contains(document.activeElement))
2172
3609
  return;
2173
- const cells = row ? [...row.querySelectorAll('[role="gridcell"]')] : [];
2174
- const target = cells[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])") ??
2175
3612
  row?.querySelector("input:not([readonly]), textarea:not([readonly]), [role=combobox]:not([aria-readonly])");
2176
3613
  target?.focus();
2177
3614
  if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
@@ -2187,8 +3624,22 @@ export class Grid extends AsterElement {
2187
3624
  if (!editing || (only && editing !== only))
2188
3625
  return true;
2189
3626
  if (save && editing.edit.dirty.peek()) {
3627
+ const changes = {};
3628
+ const values = editing.edit.values.peek();
3629
+ const baseline = editing.edit.baseline.peek();
3630
+ for (const [field, value] of Object.entries(values)) {
3631
+ if (JSON.stringify(value) !== JSON.stringify(baseline[field]))
3632
+ changes[field] = value;
3633
+ }
3634
+ if (!this.emit("edit-save", { key: editing.key, changes }, { cancelable: true }))
3635
+ return false;
2190
3636
  const saved = await editing.edit.save();
2191
3637
  if (!saved) {
3638
+ this.emit("edit-error", {
3639
+ key: editing.key,
3640
+ error: editing.edit.formError.peek() ?? "The row couldn’t be saved.",
3641
+ errors: editing.edit.errors.peek(),
3642
+ }, { cancelable: false });
2192
3643
  requestAnimationFrame(() => this.querySelector(`.aster-grid-row[data-editing] [aria-invalid="true"]`)?.focus());
2193
3644
  return false;
2194
3645
  }
@@ -2258,9 +3709,14 @@ export class Grid extends AsterElement {
2258
3709
  return;
2259
3710
  e.preventDefault();
2260
3711
  const index = Number(row.dataset.index);
2261
- const col = Number(cell.getAttribute("aria-colindex")) - 1;
2262
- this.#activate(index, col);
2263
- 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 });
2264
3720
  }
2265
3721
  #click(e) {
2266
3722
  const cell = e.target.closest('[role="gridcell"]');
@@ -2270,14 +3726,32 @@ export class Grid extends AsterElement {
2270
3726
  if (e.target.closest("button, input, select, textarea, a[href], [contenteditable], [data-editing]"))
2271
3727
  return;
2272
3728
  const index = Number(row.dataset.index);
2273
- const item = untracked(() => this.#layout()).at(index);
2274
- if (item.kind === "row" && item.key !== undefined)
2275
- this.#choose(item.key, index, e.shiftKey, e.ctrlKey || e.metaKey);
2276
- this.#activate(index, row.classList.contains("aster-grid-group") ? this.#active.peek().col : Number(cell.getAttribute("aria-colindex")) - 1);
3729
+ const column = Number(cell.dataset.col ?? 0);
3730
+ const field = this.#visibleColumns(this.#view.peek())[column]?.field;
3731
+ const layout = untracked(() => this.#layout());
3732
+ const item = layout.at(index);
3733
+ if (item.kind === "row" && item.key !== undefined && column >= 0) {
3734
+ const target = { key: item.key, field, row: index, column, originalEvent: e };
3735
+ // Both action events fire; either may veto the default.
3736
+ const cellOk = this.emit("cell-click", target);
3737
+ const rowOk = this.emit("row-click", { key: item.key, row: index, originalEvent: e });
3738
+ if (!cellOk || !rowOk)
3739
+ return;
3740
+ // Selection indices are collection-relative; later pages render rows that start mid-collection.
3741
+ this.#choose(item.key, (layout.first ?? 0) + index, e.shiftKey, e.ctrlKey || e.metaKey);
3742
+ }
3743
+ this.#activate(index, row.classList.contains("aster-grid-group") ? this.#active.peek().col : Math.max(0, column));
2277
3744
  }
2278
3745
  #key(e, columns) {
2279
3746
  if (e.target.closest(".aster-grid-filters, .aster-grid-panel, .aster-grid-selection, [data-editing], .aster-grid-group button"))
2280
3747
  return;
3748
+ // Focus can still be on the grid during the frame an edit starts in; Escape ends it all the same.
3749
+ if (e.key === "Escape" && this.#editing.peek()) {
3750
+ e.preventDefault();
3751
+ this.#editing.peek().edit.revert();
3752
+ void this.#endEdit(false, true);
3753
+ return;
3754
+ }
2281
3755
  const layout = untracked(() => this.#layout());
2282
3756
  const count = layout.keys.length;
2283
3757
  const { row, col } = this.#active.peek();
@@ -2297,7 +3771,7 @@ export class Grid extends AsterElement {
2297
3771
  }
2298
3772
  if (e.key === " " && this.#selection.peek() && item.kind === "row" && item.key !== undefined) {
2299
3773
  e.preventDefault();
2300
- this.#choose(item.key, row, e.shiftKey, this.#selection.peek().multiple);
3774
+ this.#choose(item.key, (layout.first ?? 0) + row, e.shiftKey, this.#selection.peek().multiple);
2301
3775
  return;
2302
3776
  }
2303
3777
  const page = Math.max(1, Math.floor(this.#viewport.peek() / this.#heights.estimate) - 1);
@@ -2308,8 +3782,15 @@ export class Grid extends AsterElement {
2308
3782
  break;
2309
3783
  case "ArrowUp":
2310
3784
  if (row === 0) {
2311
- e.preventDefault();
2312
- 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
+ }
2313
3794
  return;
2314
3795
  }
2315
3796
  next = { row: row - 1, col };
@@ -2336,10 +3817,11 @@ export class Grid extends AsterElement {
2336
3817
  case "F2": {
2337
3818
  if (item.kind !== "row" || item.key === undefined)
2338
3819
  return;
2339
- if (e.key === "F2" && this.#selectable.peek() && this.#selection.peek() && (!this.#editable.peek() || e.shiftKey)) {
3820
+ if (e.key === "F2" && this.#selectionAllowed() && this.#selection.peek() && (!this.#editEnabled() || e.shiftKey)) {
2340
3821
  e.preventDefault();
2341
3822
  this.#selectionInside.value = item.key;
2342
- 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());
2343
3825
  return;
2344
3826
  }
2345
3827
  if (e.key === "Enter" && !this.emit("row-open", { key: item.key })) {
@@ -2352,7 +3834,7 @@ export class Grid extends AsterElement {
2352
3834
  this.expand(item.key);
2353
3835
  return;
2354
3836
  }
2355
- if (!this.#editable.peek())
3837
+ if (!this.#editEnabled())
2356
3838
  return;
2357
3839
  e.preventDefault();
2358
3840
  this.#startEdit(item.key, col);
@@ -2382,27 +3864,89 @@ export class Grid extends AsterElement {
2382
3864
  if (this.#selection.peek() && e.shiftKey && target !== row) {
2383
3865
  const destination = layout.at(target);
2384
3866
  if (destination.kind === "row" && destination.key !== undefined)
2385
- this.#choose(destination.key, target, true, false);
3867
+ this.#choose(destination.key, (layout.first ?? 0) + target, true, false);
2386
3868
  }
2387
3869
  this.#activate(target, Math.max(0, Math.min(columns - 1, next.col)));
2388
3870
  }
2389
3871
  #sortText(view) {
2390
- const title = (f) => this.#collection?.type.field(f)?.title ?? f;
2391
- if (!view.sort.length)
2392
- return "Not sorted";
2393
- return `Sorted by ${view.sort.map((k) => `${title(k.field)}, ${k.descending ? "descending" : "ascending"}`).join(", then ")}`;
2394
- }
2395
- /** The first column's indent and opener in a tree grid. */
3872
+ const sort = view.sort.filter((key) => this.#sortable(key.field));
3873
+ if (!sort.length)
3874
+ return this.#text("notSorted");
3875
+ const entries = sort.map((key) => this.#text("sortEntry", {
3876
+ field: this.#title(key.field),
3877
+ direction: key.descending ? this.#text("directionDescending") : this.#text("directionAscending"),
3878
+ }));
3879
+ return this.#text("sortedBy", { sorts: entries.join(this.#text("sortSeparator")) });
3880
+ }
3881
+ /** The first column's indent, optional opener and node icon in a tree grid. */
2396
3882
  #treeLead(node) {
3883
+ const options = this.#treeOptions.value;
2397
3884
  const folder = node.folder !== false && node.key !== undefined;
2398
- return html `<span class="aster-grid-tree" style=${`--level:${node.level - 1}`}>${folder
2399
- ? html `<button type="button" class="aster-grid-opener" tabindex="-1" aria-hidden="true" ?data-open=${node.open}
2400
- @pointerdown=${(e) => e.stopPropagation()}
2401
- @click=${(e) => {
2402
- e.stopPropagation();
2403
- void this.#tree?.toggle(node.key);
2404
- }}><svg viewBox="0 0 10 10"><path d="M3.5 2 6.5 5l-3 3"/></svg></button>`
2405
- : html `<span class="aster-grid-opener" aria-hidden="true"></span>`}</span>`;
3885
+ const icon = folder ? options?.folderIcon : options?.nodeIcon;
3886
+ const opener = options?.showOpener !== false;
3887
+ return html `<span class="aster-grid-tree" style=${`--level:${node.level - 1}`}>${opener
3888
+ ? folder
3889
+ ? html `<button type="button" class="aster-grid-opener" tabindex="-1" aria-hidden="true" ?data-open=${node.open}
3890
+ @pointerdown=${(e) => e.stopPropagation()}
3891
+ @click=${(e) => {
3892
+ e.stopPropagation();
3893
+ void this.#tree?.toggle(node.key);
3894
+ }}><svg viewBox="0 0 10 10"><path d="M3.5 2 6.5 5l-3 3"/></svg></button>`
3895
+ : html `<span class="aster-grid-opener" aria-hidden="true"></span>`
3896
+ : nothing}
3897
+ ${icon ? html `<span class="aster-grid-tree-icon" aria-hidden="true">${icon}</span>` : nothing}</span>`;
3898
+ }
3899
+ /**
3900
+ * Opens the levels a tree loads with: `autoOpen` levels below the top, or
3901
+ * every folder for Expand all. Bounded by `openLimit` and by the first page
3902
+ * of each level.
3903
+ */
3904
+ async #expandLevels(tree, depth, limit) {
3905
+ let opened = 0;
3906
+ let parents = [undefined];
3907
+ for (let level = 0; level < depth && parents.length > 0 && opened < limit; level++) {
3908
+ const next = [];
3909
+ for (const parent of parents) {
3910
+ const collection = tree.level(parent);
3911
+ await collection.ensure(0, collection.pageSize);
3912
+ if (!this.isConnected || this.#tree !== tree)
3913
+ return opened;
3914
+ for (const key of collection.keys.peek()) {
3915
+ if (key === undefined || tree.isOpen(key))
3916
+ continue;
3917
+ const node = tree.rows.peek().find((row) => row.key === key);
3918
+ if (node?.folder === false)
3919
+ continue;
3920
+ await tree.expand(key);
3921
+ next.push(key);
3922
+ if (++opened >= limit)
3923
+ return opened;
3924
+ }
3925
+ }
3926
+ parents = next;
3927
+ }
3928
+ return opened;
3929
+ }
3930
+ /** Expand all: every folder in the loaded pages, up to `openLimit`. */
3931
+ async #expandAll() {
3932
+ const tree = this.#tree;
3933
+ if (!tree)
3934
+ return;
3935
+ const limit = this.#treeOptions.peek()?.openLimit ?? 200;
3936
+ const opened = await this.#expandLevels(tree, Number.POSITIVE_INFINITY, limit);
3937
+ announce(opened >= limit ? this.#text("foldersLimit", { limit }) : this.#text("foldersOpened"));
3938
+ }
3939
+ #collapseAll() {
3940
+ this.#tree?.setOpen([]);
3941
+ if (this.#tree)
3942
+ announce(this.#text("foldersClosed"));
3943
+ }
3944
+ /** Opens the configured initial depth once, when the tree is built. */
3945
+ #autoOpen(tree, depth) {
3946
+ if (!(depth > 0))
3947
+ return;
3948
+ const limit = this.#treeOptions.peek()?.openLimit ?? 200;
3949
+ void this.#expandLevels(tree, depth, limit).catch(() => { });
2406
3950
  }
2407
3951
  /**
2408
3952
  * → on a closed folder opens it, on an open one goes to its first child;
@@ -2435,7 +3979,15 @@ export class Grid extends AsterElement {
2435
3979
  return true;
2436
3980
  }
2437
3981
  #filtersShown() {
2438
- 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;
2439
3991
  }
2440
3992
  #groupKey(e, row, col) {
2441
3993
  const grouping = this.#grouping;
@@ -2485,7 +4037,7 @@ export class Grid extends AsterElement {
2485
4037
  * row (Enter or F2 goes into the filter; Escape there comes back).
2486
4038
  */
2487
4039
  #headKey(e, row, col, columns, count) {
2488
- const field = this.#view.peek().columns.filter((c) => !c.hidden)[col]?.field;
4040
+ const field = this.#visibleColumns(this.#view.peek())[col]?.field;
2489
4041
  const move = (to) => {
2490
4042
  e.preventDefault();
2491
4043
  this.#active.value = { row: to.row, col: Math.max(0, Math.min(columns - 1, to.col)) };
@@ -2500,7 +4052,7 @@ export class Grid extends AsterElement {
2500
4052
  case "End":
2501
4053
  return move({ row, col: columns - 1 });
2502
4054
  case "ArrowUp":
2503
- return row === FILTER_ROW ? move({ row: HEADER, col }) : undefined;
4055
+ return row === FILTER_ROW && this.#headerShown() ? move({ row: HEADER, col }) : undefined;
2504
4056
  case "ArrowDown":
2505
4057
  if (row === HEADER && this.#filtersShown())
2506
4058
  return move({ row: FILTER_ROW, col });
@@ -2524,7 +4076,7 @@ export class Grid extends AsterElement {
2524
4076
  return;
2525
4077
  case "F10":
2526
4078
  case "ContextMenu": {
2527
- if ((e.key === "F10" && !e.shiftKey) || row !== HEADER || !field)
4079
+ if ((e.key === "F10" && !e.shiftKey) || row !== HEADER || !field || !this.showHeaderMenu)
2528
4080
  return;
2529
4081
  e.preventDefault();
2530
4082
  const opener = this.querySelector(`.aster-grid-header[data-field="${CSS.escape(field)}"] .aster-grid-header-menu`);
@@ -2542,7 +4094,7 @@ export class Grid extends AsterElement {
2542
4094
  if (!cell)
2543
4095
  return;
2544
4096
  e.preventDefault();
2545
- this.#active.value = { row: FILTER_ROW, col: Number(cell.getAttribute("aria-colindex")) - 1 };
4097
+ this.#active.value = { row: FILTER_ROW, col: Number(cell.dataset.col ?? 0) };
2546
4098
  this.querySelector(".aster-grid-scroll")?.focus();
2547
4099
  }
2548
4100
  /** Reveals the active cell; only an owned grid without shared selection chooses its row. */
@@ -2558,7 +4110,7 @@ export class Grid extends AsterElement {
2558
4110
  }
2559
4111
  if (item.kind === "row" && item.key !== undefined) {
2560
4112
  this.#activeKey.value = item.key;
2561
- if (this.#selectable.peek() && !this.#selection.peek() && !this.#external())
4113
+ if (this.#selectionAllowed() && !this.#selection.peek() && !this.#external())
2562
4114
  this.#selected.value = item.key;
2563
4115
  }
2564
4116
  else