@sheetpane/grid 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 (74) hide show
  1. package/README.md +294 -13
  2. package/custom-elements.json +292 -27
  3. package/dist/check.d.ts +75 -0
  4. package/dist/check.d.ts.map +1 -0
  5. package/dist/check.js +151 -0
  6. package/dist/check.js.map +1 -0
  7. package/dist/errors/element.d.ts +90 -12
  8. package/dist/errors/element.d.ts.map +1 -1
  9. package/dist/errors/element.js +572 -50
  10. package/dist/errors/element.js.map +1 -1
  11. package/dist/excel/element.d.ts +168 -15
  12. package/dist/excel/element.d.ts.map +1 -1
  13. package/dist/excel/element.js +752 -90
  14. package/dist/excel/element.js.map +1 -1
  15. package/dist/excel/link.d.ts +40 -0
  16. package/dist/excel/link.d.ts.map +1 -0
  17. package/dist/excel/link.js +90 -0
  18. package/dist/excel/link.js.map +1 -0
  19. package/dist/excel/styles.d.ts.map +1 -1
  20. package/dist/excel/styles.js +37 -5
  21. package/dist/excel/styles.js.map +1 -1
  22. package/dist/frameworks/elements.d.ts +57 -17
  23. package/dist/frameworks/jsx.d.ts +15 -9
  24. package/dist/frameworks/solid.d.ts +51 -11
  25. package/dist/frameworks/svelte.d.ts +28 -16
  26. package/dist/frameworks/vue.d.ts +15 -9
  27. package/dist/index.d.ts +9 -4
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +8 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/llms-full.txt +3027 -239
  32. package/dist/llms.txt +92 -86
  33. package/dist/pane/protection.d.ts +61 -0
  34. package/dist/pane/protection.d.ts.map +1 -0
  35. package/dist/pane/protection.js +89 -0
  36. package/dist/pane/protection.js.map +1 -0
  37. package/dist/pane/rules.d.ts +1 -1
  38. package/dist/pane/rules.d.ts.map +1 -1
  39. package/dist/pane/store.d.ts +336 -15
  40. package/dist/pane/store.d.ts.map +1 -1
  41. package/dist/pane/store.js +891 -44
  42. package/dist/pane/store.js.map +1 -1
  43. package/dist/pane/structure.d.ts +58 -0
  44. package/dist/pane/structure.d.ts.map +1 -0
  45. package/dist/pane/structure.js +238 -0
  46. package/dist/pane/structure.js.map +1 -0
  47. package/dist/pane/types.d.ts +195 -6
  48. package/dist/pane/types.d.ts.map +1 -1
  49. package/dist/pane/types.js +57 -0
  50. package/dist/pane/types.js.map +1 -1
  51. package/dist/plugins/types.d.ts +34 -6
  52. package/dist/plugins/types.d.ts.map +1 -1
  53. package/dist/plugins/types.js.map +1 -1
  54. package/dist/shared/detail.d.ts +24 -0
  55. package/dist/shared/detail.d.ts.map +1 -0
  56. package/dist/shared/detail.js +75 -0
  57. package/dist/shared/detail.js.map +1 -0
  58. package/dist/shared/dom.d.ts +3 -0
  59. package/dist/shared/dom.d.ts.map +1 -1
  60. package/dist/shared/dom.js +3 -0
  61. package/dist/shared/dom.js.map +1 -1
  62. package/dist/static.d.ts +81 -0
  63. package/dist/static.d.ts.map +1 -0
  64. package/dist/static.js +352 -0
  65. package/dist/static.js.map +1 -0
  66. package/dist/style.css +3 -3
  67. package/dist/tokens.d.ts +1 -0
  68. package/dist/tokens.d.ts.map +1 -1
  69. package/dist/tokens.js +2 -2
  70. package/dist/tokens.js.map +1 -1
  71. package/dist/vscode.css-custom-data.json +11 -1
  72. package/dist/vscode.html-custom-data.json +89 -2
  73. package/dist/web-types.json +204 -21
  74. package/package.json +12 -4
@@ -1,13 +1,14 @@
1
1
  // <sheetpane-errors> — the error component (W-02): the list and the New error
2
2
  // form, on the same store as <sheetpane-excel> (SR-LAW-7). It keeps no private
3
3
  // copy of errors or workbook data; no "linked" chip, no export footer.
4
- import { cellRef, colName, parseRange, parseRef } from '@sheetpane/export';
4
+ import { cellRef, colName, parseRange, parseRef, rangeRef } from '@sheetpane/export';
5
5
  import { pluginsFromIds } from '../plugins/host.js';
6
6
  import { acquirePane, attach, detach, isPrimary, releasePane } from '../pane/registry.js';
7
- import { createWorkbook } from '../pane/store.js';
8
- import { BUILTIN_TYPE_NAMES, DEFAULT_ERROR_TYPES, SEVERITY, machineAuthored } from '../pane/types.js';
7
+ import { createWorkbook, editRef, splitKey } from '../pane/store.js';
8
+ import { BUILTIN_TYPE_NAMES, DEFAULT_ERROR_TYPES, DEFAULT_LAYER, SEVERITY, SEVERITY_ORDER, errorScope, isResult, layerOf, machineAuthored, resultsLevelOf } from '../pane/types.js';
9
9
  import { BASE_CSS } from '../excel/styles.js';
10
- import { ElementBase, ICON, esc, fire } from '../shared/dom.js';
10
+ import { DETAIL_CSS, DetailRegion, acceptedHtml, inDetail } from '../shared/detail.js';
11
+ import { ElementBase, ICON, esc, fire, layersAttr } from '../shared/dom.js';
11
12
  const CSS = `.grp{padding:8px 12px 2px;font-size:11px;font-weight:600;color:var(--sp-ink-3);text-transform:uppercase;letter-spacing:.04em}
12
13
  ${BASE_CSS}
13
14
  :host{display:block}
@@ -25,7 +26,7 @@ ${BASE_CSS}
25
26
  .issue:hover{background:var(--sp-ground)}
26
27
  .item.on .issue{background:var(--sp-accent-soft)}
27
28
  .issue .n{font-size:11px;font-weight:700;min-width:22px;height:20px;border-radius:10px;color:var(--sp-on-solid);display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums;flex:none}
28
- .n.error{background:var(--sp-fail)}.n.warning{background:var(--sp-warn)}.n.note{background:var(--sp-accent)}.n.resolved{background:var(--sp-pass)}
29
+ .n.error{background:var(--sp-fail)}.n.warning{background:var(--sp-warn)}.n.note{background:var(--sp-accent)}.n.resolved{background:var(--sp-pass)}.n.accepted{background:var(--sp-surface);color:var(--sp-ink-2);box-shadow:inset 0 0 0 1.5px var(--sp-ink-3)}
29
30
  .issue .bd{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}
30
31
  .issue .ty{font-weight:600;font-size:13px}
31
32
  .item.done .ty{color:var(--sp-ink-3);text-decoration:line-through}
@@ -36,6 +37,8 @@ ${BASE_CSS}
36
37
  .detail{padding:4px 12px 12px 44px;display:flex;flex-direction:column;gap:8px;background:var(--sp-accent-soft)}
37
38
  .detail .why{font-size:13px;margin:0}
38
39
  .detail .acts{display:flex;gap:6px;flex-wrap:wrap}
40
+ .accept{display:flex;flex-direction:column;gap:6px;padding:8px;border:1px solid var(--sp-line);border-radius:var(--sp-r);background:var(--sp-surface)}
41
+ .accept .acts{justify-content:flex-end}
39
42
  .form{border-bottom:1px solid var(--sp-line);display:flex;flex-direction:column;background:var(--sp-surface)}
40
43
  .form .fhd{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--sp-line)}
41
44
  .form .fbd{padding:12px;display:flex;flex-direction:column;gap:10px}
@@ -48,9 +51,38 @@ ${BASE_CSS}
48
51
  .field{display:flex;flex-direction:column;gap:4px}
49
52
  .field>span{font-size:12px;font-weight:500;color:var(--sp-ink-2)}
50
53
  .ex{padding:10px;border-radius:var(--sp-r);background:var(--sp-ground);border:1px dashed var(--sp-line-strong);font-size:12px;color:var(--sp-ink-2);display:flex;flex-direction:column;gap:4px}
54
+ .hd .seg>button{height:24px;padding:0 8px;font-size:12px}
55
+ .hd .seg .lc{font-variant-numeric:tabular-nums;color:var(--sp-ink-3)}
51
56
  .notice{margin:8px 12px 0;padding:8px 10px;border-radius:var(--sp-r);background:var(--sp-warn-soft);border:1px solid var(--sp-warn);font-size:12px}
57
+ ${DETAIL_CSS}
58
+ .summary{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 12px;border-bottom:1px solid var(--sp-line);font-size:12px;color:var(--sp-ink-2)}
59
+ .summary b{color:var(--sp-ink);font-weight:600}.summary .p{color:var(--sp-pass)}.summary .sk{color:var(--sp-ink-3)}
60
+ .sumtext{display:inline-flex;gap:8px;flex-wrap:wrap}
61
+ .sw{margin-left:auto;display:inline-flex;align-items:center;gap:6px;border:0;background:none;padding:2px 0;color:var(--sp-ink-2);font:inherit;font-size:12px;cursor:pointer}
62
+ .sw .tg{width:26px;height:15px;border-radius:8px;background:var(--sp-line-strong);position:relative;flex:none}
63
+ .sw .tg::after{content:"";position:absolute;left:2px;top:2px;width:11px;height:11px;border-radius:50%;background:var(--sp-surface)}
64
+ .sw[aria-checked=true] .tg{background:var(--sp-ink)}.sw[aria-checked=true] .tg::after{left:13px}
65
+ .rg{font-weight:700;min-width:22px;line-height:20px;text-align:center;flex:none}.rg.pass{color:var(--sp-pass)}.rg.skipped{color:var(--sp-skip)}
66
+ .rgrp .grow{display:flex;align-items:center}.rgrp .grow .issue{flex:1;min-width:0}
67
+ .lnk{flex:none;margin-right:8px;color:var(--sp-accent-ink)}
68
+ .cells{overflow:auto;border-top:1px solid var(--sp-line);background:var(--sp-ground)}
69
+ .cells .vsp{position:relative}
70
+ .cellrow{position:absolute;left:0;right:0;height:28px;display:flex;align-items:center;gap:8px;padding:0 12px 0 44px;border:0;background:none;color:var(--sp-ink);font:inherit;font-size:12px;text-align:left;cursor:pointer;white-space:nowrap;overflow:hidden}
71
+ .cellrow:hover{background:var(--sp-surface)}.cellrow .m{color:var(--sp-ink-3);overflow:hidden;text-overflow:ellipsis}
72
+ .edbar{display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid var(--sp-line);font-size:12px;color:var(--sp-ink-2)}
73
+ .edbar .spacer{flex:1}
74
+ .ek{min-width:22px;line-height:20px;text-align:center;flex:none;color:var(--sp-ink-3)}
75
+ .fx{display:flex;flex-wrap:wrap;gap:4px 10px;padding:0 12px 8px 44px;font-size:12px}
76
+ .fxl{border:0;background:none;padding:0;font:inherit;font-size:12px;color:var(--sp-ink-2);cursor:pointer;text-align:left}
77
+ button.fxl{text-decoration:underline;text-underline-offset:2px}button.fxl:hover{color:var(--sp-ink)}
78
+ .fxl .g-cleared{color:var(--sp-pass)}.fxl .g-raised{color:var(--sp-fail)}
79
+ .more{margin:8px 12px}
52
80
  `;
53
- const sevOrder = { error: 3, warning: 2, note: 1 };
81
+ /** The Edits view (W-64) draws this many rows, then *Show more* another page. */
82
+ const EDITS_PAGE = 100;
83
+ /** A result group's cell rows (W-50): 28 px each, drawn only where its box scrolls to. */
84
+ const CELL_ROW = 28, CELLS_MAX = 280;
85
+ const sevOrder = SEVERITY_ORDER;
54
86
  const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
55
87
  /**
56
88
  * `<sheetpane-errors>` — the error list and the Flag issue form, on the same in-memory
@@ -59,25 +91,33 @@ const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
59
91
  *
60
92
  * Attributes:
61
93
  * - `pane` — the pane id shared with the sheet; none gives the element its own store.
62
- * - `readonly` — hides the form, Resolve, Delete, Undo and Redo (a sign-off screen).
63
- * - `author` — written into the errors this element's user adds.
94
+ * - `readonly` — hides the form, Resolve, Accept…, Delete, Undo and Redo (a sign-off screen).
95
+ * - `author` — written into the errors this element's user adds, and as who accepted one (W-69).
64
96
  * - `plugins` — space-separated registered plugin ids; fixed per pane.
65
97
  * - `theme` — `light` (the default), `dark` or `auto`.
98
+ * - `results` — `summary` (the default), `marks` or `off`: how `pass` and `skipped` results show, pane-wide (W-50).
99
+ * - `layers` — space-separated review layers to show (W-51); none shows every layer. Shared by the pane's elements; with two or more layers the header shows a switch.
66
100
  *
67
- * Properties: `workbook`, `errors`, `errorTypes`, `readOnly`, `theme`.
101
+ * Properties: `workbook`, `errors`, `errorTypes`, `readOnly`, `theme`, `results`, `renderDetail`, `layers`, `layerNames`.
68
102
  *
69
- * Methods: `getErrors()`, `setErrors(list, { plugins? })`, `renderNow()`.
103
+ * Methods: `getErrors({ layer? })`, `setErrors(list | envelope, { plugins?, layer? })`, `renderNow()`.
70
104
  *
71
105
  * Every event bubbles and is composed; an action's event fires once, on the element where it
72
106
  * happened.
73
107
  *
74
- * @fires sheetpane-errors `{ errors, plugins }` when the pane's errors change — the envelope to save a review.
108
+ * @fires sheetpane-errors `{ v, errors, plugins, layers, changed }` when the pane's errors change — the envelope to save a review (`v: 2`, W-51); `changed` names the layers whose errors changed.
109
+ * @fires sheetpane-layers `{ visible, layers }` when the visible review layers change — the switch or the `layers` attribute (W-51).
75
110
  * @fires sheetpane-change `{ sheet, ref, kind, was, now }` when a cell changes from here ("Also change the cell", undo, redo).
76
111
  * @fires sheetpane-select `{ sheet, ref }` when picking a row selects its cell.
77
112
  * @fires sheetpane-history `{ reason, canUndo, canRedo }` on undo, redo, or the stack cleared by `setErrors()`.
78
- * @fires sheetpane-reveal `{ error }` on Show in sheet, so the host can switch tab or open the pop-up.
113
+ * @fires sheetpane-reveal `{ error }` on Show in sheet, so the host can switch tab or open the pop-up — for a whole-sheet error too (its tab is opened); never for a workbook-level one (W-55); for a result group's Show ▸, its first entry (W-50).
114
+ *
115
+ * Views (W-50, W-64): *Problems* (the default); *✓ Passed* and *— Not checked* while the pane holds results; *Edits n* —
116
+ * every change in the pane, newest first, each with its place, kind, was → now, time, the errors it cleared or raised,
117
+ * and its own Revert — in Edit mode, and in View mode while there are edits.
79
118
  *
80
119
  * @csspart root - The panel's frame: the header, the form, the filters and the list.
120
+ * @csspart detail - The host's region in an open row, after the message: what `renderDetail` returns (W-68).
81
121
  *
82
122
  * @example
83
123
  * ```ts
@@ -88,14 +128,16 @@ const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
88
128
  * document.body.append(list);
89
129
  * list.addEventListener('sheetpane-errors', (e) => localStorage.setItem('review', JSON.stringify((e as CustomEvent).detail)));
90
130
  * const saved = JSON.parse(localStorage.getItem('review') ?? 'null');
91
- * if (saved) list.setErrors(saved.errors, { plugins: saved.plugins });
131
+ * if (saved) list.setErrors(saved); // the envelope: every layer, and the plugins notice
132
+ * list.setErrors(pdfFindings, { layer: 'pdf' }); // one layer replaced; the others kept
133
+ * list.layerNames = { pdf: 'PDF check' };
92
134
  * ```
93
135
  * @see https://github.com/arpitarya/sheetpane/blob/main/records/0106_ERRORS.md
94
136
  * @see https://github.com/arpitarya/sheetpane/blob/main/packages/grid/README.md#sheetpane-errors
95
137
  */
96
138
  export class SheetpaneErrors extends ElementBase {
97
139
  /** The attributes the element reacts to. */
98
- static observedAttributes = ['pane', 'readonly', 'author', 'plugins'];
140
+ static observedAttributes = ['pane', 'readonly', 'author', 'plugins', 'results', 'layers'];
99
141
  _store;
100
142
  _explicitStore;
101
143
  _paneId;
@@ -109,12 +151,30 @@ export class SheetpaneErrors extends ElementBase {
109
151
  draft = { type: DEFAULT_ERROR_TYPES[0].id, message: '', expected: '', severity: DEFAULT_ERROR_TYPES[0].severity, change: false };
110
152
  formKey = '';
111
153
  savedPlugins;
154
+ detailRegion = new DetailRegion();
155
+ /** W-69: the Accepted chip is on (only accepted errors listed); the error whose Accept form is open, and its reason so far. */
156
+ showAccepted = false;
157
+ /** W-51: the switch's button to focus after a re-draw (`''` is All). */
158
+ focusLayer = '';
159
+ accepting;
160
+ reasonDraft = '';
161
+ focusNext;
162
+ _layerNames = {};
163
+ view = 'problems';
164
+ openGroups = new Set();
165
+ groupEntries = new Map();
166
+ /** A control to focus again once the list is redrawn (a group toggled). */
167
+ refocus;
168
+ /** W-64: how many edits the Edits view draws; the Edits chip to focus once the last row is reverted. */
169
+ editsLimit = EDITS_PAGE;
170
+ focusEditsChip = false;
112
171
  // ── properties ───────────────────────────────────────────────────────────
113
172
  /** The pane's store; assigning one replaces the `pane` lookup. */
114
173
  get workbook() { return this._store; }
115
174
  set workbook(s) { this._explicitStore = s; if (this.isConnected) {
116
175
  this.unbind();
117
176
  this.bind();
177
+ this.applyLayers();
118
178
  this.schedule();
119
179
  } }
120
180
  /** A copy of the pane's errors; assigning restores a saved review, like `setErrors()`. */
@@ -129,24 +189,60 @@ export class SheetpaneErrors extends ElementBase {
129
189
  /** `light` (the default), `dark` or `auto` — the same tokens as the sheet (W-29). */
130
190
  get theme() { const t = this.getAttribute('theme'); return t === 'dark' || t === 'auto' ? t : 'light'; }
131
191
  set theme(t) { this.setAttribute('theme', t); }
132
- /** A copy of the pane's errors, to save a review. */
133
- getErrors() { return this.errors; }
134
- /** Restores a saved review. `plugins` (from the saved `sheetpane-errors` envelope) shows a notice when it differs from this pane's set. */
192
+ /**
193
+ * The host's extra detail for an error (W-68): called with a copy of the error when its row is opened, and drawn after
194
+ * the message in a `part="detail"` region. A string is text, never HTML; a Node is the host's own. Called again only when
195
+ * the error changes (its `meta` included) or this is replaced; a throw is logged as SP-3017. Set it on each element
196
+ * that should show it — the sheet's error card has its own.
197
+ */
198
+ get renderDetail() { return this.detailRegion.render; }
199
+ set renderDetail(f) { this.detailRegion.render = f; this.schedule(); }
200
+ /** The pane's results level (W-50): `summary` (the default), `marks` or `off`; setting it sets the `results` attribute. */
201
+ get results() { return this._store?.results ?? resultsLevelOf(this.getAttribute('results')); }
202
+ set results(v) { this.setAttribute('results', v); }
203
+ /**
204
+ * The review layers shown (W-51) — the pane's, shared with `<sheetpane-excel>`: every layer until the `layers`
205
+ * attribute or the switch names some. Setting it sets the attribute; `null` or `[]` shows every layer.
206
+ */
207
+ get layers() { return [...(this._store?.getSnapshot().visibleLayers ?? layersAttr(this.getAttribute('layers')) ?? [])]; }
208
+ set layers(v) { if (v && v.length)
209
+ this.setAttribute('layers', v.join(' '));
210
+ else
211
+ this.removeAttribute('layers'); this._store?.setVisibleLayers(v, this); }
212
+ /** What the switch and the rows call each layer (W-51), e.g. `{ pdf: 'PDF check' }`; a layer not named here shows its name (`review` as *Review*). */
213
+ get layerNames() { return this._layerNames; }
214
+ set layerNames(m) { this._layerNames = { ...(m ?? {}) }; this.schedule(); }
215
+ /** A copy of the pane's errors, to save a review; `{ layer }` gives one layer's (W-51). */
216
+ getErrors(o = {}) { return this._store?.getErrors(o) ?? []; }
217
+ /**
218
+ * Restores a saved review — a list, or the `sheetpane-errors` envelope with its layers — or, with `layer`, replaces that
219
+ * layer only (W-51). `plugins` (from the saved envelope; read from it when it is passed whole) shows a notice when it
220
+ * differs from this pane's set.
221
+ */
135
222
  setErrors(list, o = {}) {
136
- this.savedPlugins = o.plugins;
137
- this.ensureStore().setErrors(list, this);
223
+ if (o.layer === undefined)
224
+ this.savedPlugins = o.plugins ?? (Array.isArray(list) ? undefined : list.plugins);
225
+ this.ensureStore().setErrors(list, o.layer === undefined ? {} : { layer: o.layer }, this);
138
226
  }
227
+ layerName(l) { return this._layerNames[l] ?? (l === DEFAULT_LAYER ? 'Review' : l); }
228
+ /** The `layers` attribute, given to the store (connect, a new store). */
229
+ applyLayers() { if (this.hasAttribute('layers'))
230
+ this._store?.setVisibleLayers(layersAttr(this.getAttribute('layers')), this); }
139
231
  /** Builds the shadow root once and binds to the pane's store. */
140
232
  connectedCallback() {
141
233
  if (!this.shadow) {
142
234
  this.shadow = this.attachShadow({ mode: 'open' });
143
- this.shadow.innerHTML = `<style>${CSS}</style><div class="spe" part="root"><div class="hd"><span class="t">Errors</span><span class="chip opencount"></span><span class="spacer" style="flex:1"></span><button type="button" class="btn btn-g btn-i" data-act="undo" aria-label="Undo (Ctrl+Z)" title="Undo">${ICON.undo}</button><button type="button" class="btn btn-g btn-i" data-act="redo" aria-label="Redo (Ctrl+Shift+Z)" title="Redo">${ICON.redo}</button></div><div class="scrollarea"><div class="noticebox"></div><div class="formbox"></div><div class="filters" role="toolbar" aria-label="Filter errors"></div><div class="list" role="list"></div></div></div>`;
235
+ this.shadow.innerHTML = `<style>${CSS}</style><div class="spe" part="root"><div class="hd"><span class="t">Errors</span><span class="chip opencount"></span><span class="layerbox"></span><span class="spacer" style="flex:1"></span><button type="button" class="btn btn-g btn-i" data-act="undo" aria-label="Undo (Ctrl+Z)" title="Undo">${ICON.undo}</button><button type="button" class="btn btn-g btn-i" data-act="redo" aria-label="Redo (Ctrl+Shift+Z)" title="Redo">${ICON.redo}</button></div><div class="summary" hidden><span class="sumtext"></span><button type="button" class="sw" role="switch" aria-checked="false" data-act="onsheet"><span class="tg" aria-hidden="true"></span>On sheet</button></div><div class="scrollarea"><div class="noticebox"></div><div class="formbox"></div><div class="filters" role="toolbar" aria-label="Filter errors"></div><div class="edbar" hidden></div><div class="list" role="list"></div></div></div>`;
144
236
  this.shadow.addEventListener('click', (e) => this.onClick(e));
145
237
  this.shadow.addEventListener('input', (e) => this.onInput(e));
146
238
  this.shadow.addEventListener('change', (e) => this.onInput(e));
147
239
  this.shadow.addEventListener('keydown', (e) => this.onKey(e));
240
+ // a result group's cells scroll in their own box: draw the rows it shows (scroll does not bubble — capture)
241
+ this.shadow.addEventListener('scroll', (e) => { const t = e.target; if (t.classList?.contains('cells'))
242
+ this.fillCells(t); }, true);
148
243
  }
149
244
  this.bind();
245
+ this.applyLayers();
150
246
  this.schedule();
151
247
  }
152
248
  /** Releases the pane's store. */
@@ -158,7 +254,12 @@ export class SheetpaneErrors extends ElementBase {
158
254
  if (name === 'pane' || name === 'plugins') {
159
255
  this.unbind();
160
256
  this.bind();
257
+ this.applyLayers();
161
258
  }
259
+ else if (name === 'results' && val !== null)
260
+ this._store?.setResults(resultsLevelOf(val));
261
+ else if (name === 'layers')
262
+ this._store?.setVisibleLayers(layersAttr(val), this);
162
263
  this.schedule();
163
264
  }
164
265
  ensureStore() { if (!this._store)
@@ -178,6 +279,8 @@ export class SheetpaneErrors extends ElementBase {
178
279
  this._store = createWorkbook({ plugins: pluginsFromIds(plugins) });
179
280
  const s = this._store;
180
281
  attach(s, this);
282
+ if (this.hasAttribute('results'))
283
+ s.setResults(resultsLevelOf(this.getAttribute('results')));
181
284
  const detachView = s.attachErrorsView();
182
285
  this.unsub.push(s.subscribe(() => this.schedule()), s.on((e) => this.onStoreEvent(e)), () => detach(s, this), detachView);
183
286
  }
@@ -196,7 +299,9 @@ export class SheetpaneErrors extends ElementBase {
196
299
  return;
197
300
  const s = this._store;
198
301
  if (e.type === 'errors')
199
- fire(this, 'sheetpane-errors', { errors: s.getErrors(), plugins: s.pluginIds });
302
+ fire(this, 'sheetpane-errors', s.envelope(e.layers));
303
+ else if (e.type === 'layers')
304
+ fire(this, 'sheetpane-layers', { visible: [...s.getSnapshot().visibleLayers], layers: [...s.getSnapshot().layers] });
200
305
  else if (e.type === 'change')
201
306
  fire(this, 'sheetpane-change', { sheet: e.sheet, ref: e.ref, kind: e.kind, was: e.was, now: e.now });
202
307
  else if (e.type === 'select')
@@ -213,6 +318,8 @@ export class SheetpaneErrors extends ElementBase {
213
318
  renderNow() { cancelAnimationFrame(this.raf); this.raf = 0; this.render(); }
214
319
  // ── interactions ─────────────────────────────────────────────────────────
215
320
  onClick(e) {
321
+ if (inDetail(e.target))
322
+ return; // the host's region: its clicks are the host's (W-68)
216
323
  const t = e.target, s = this._store;
217
324
  const el = t.closest('[data-act]');
218
325
  if (!el)
@@ -228,28 +335,96 @@ export class SheetpaneErrors extends ElementBase {
228
335
  this.authorFilter = el.dataset.v;
229
336
  this.schedule();
230
337
  break;
338
+ case 'status':
339
+ this.showAccepted = !this.showAccepted;
340
+ this.schedule();
341
+ break;
342
+ case 'view':
343
+ this.view = el.dataset.v;
344
+ this.schedule();
345
+ break;
346
+ case 'onsheet':
347
+ s.setResults(s.results === 'marks' ? 'summary' : 'marks');
348
+ break;
349
+ case 'group': {
350
+ const g = el.closest('[data-g]').dataset.g;
351
+ if (!this.openGroups.delete(g))
352
+ this.openGroups.add(g);
353
+ this.refocus = `[data-g="${globalThis.CSS.escape(g)}"] [data-act=group]`;
354
+ this.schedule();
355
+ break;
356
+ }
357
+ case 'show-group':
358
+ this.showGroup(el.closest('[data-g]').dataset.g);
359
+ break;
360
+ case 'layer': { // W-51: one layer, or every layer
361
+ const v = el.dataset.v;
362
+ if (s.setVisibleLayers(v ? [v] : null, this)) {
363
+ this.expanded = undefined;
364
+ this.focusNext = 'layer';
365
+ this.focusLayer = v ?? '';
366
+ }
367
+ break;
368
+ }
369
+ case 'edit-pick':
370
+ this.showEdit(el.closest('[data-k]').dataset.k);
371
+ break;
372
+ case 'revert-edit':
373
+ this.revertEdit(el.closest('[data-k]').dataset.k);
374
+ break;
375
+ case 'revert-all':
376
+ if (!this.readOnly && s.revertEdits(undefined, this))
377
+ this.focusEditsChip = true;
378
+ break;
379
+ case 'edits-more':
380
+ this.editsLimit += EDITS_PAGE;
381
+ this.refocus = `.item.edit:nth-child(${this.editsLimit - EDITS_PAGE + 1}) [data-act=edit-pick]`;
382
+ this.schedule();
383
+ break;
384
+ case 'goto-error': // an edit's cleared or raised error: shown in the Problems view, opened (W-64)
385
+ if (!err)
386
+ return;
387
+ this.view = 'problems';
388
+ this.filter = 'all';
389
+ this.authorFilter = 'all';
390
+ this.showAccepted = err.status === 'accepted';
391
+ this.expanded = err.id;
392
+ this.showPlace(err);
393
+ this.refocus = `[data-id="${globalThis.CSS.escape(err.id)}"] .issue`;
394
+ this.schedule();
395
+ break;
231
396
  case 'pick':
232
397
  if (!err)
233
398
  return;
234
399
  this.expanded = this.expanded === err.id ? undefined : err.id;
235
- {
236
- const r = parseRange(err.range);
237
- s.select({ sheet: err.sheet, ref: cellRef(r.top, r.left) }, this);
238
- }
400
+ this.showPlace(err);
239
401
  this.schedule();
240
402
  break;
241
403
  case 'reveal':
242
- if (!err)
243
- return;
244
- {
245
- const r = parseRange(err.range);
246
- s.select({ sheet: err.sheet, ref: cellRef(r.top, r.left) }, this);
247
- }
404
+ if (!err || errorScope(err) === 'workbook')
405
+ return; // a workbook-level error has no sheet to show (W-55)
406
+ this.showPlace(err);
407
+ if (isResult(err))
408
+ s.focusResults(err.sheet, err.range); // a result shown from its group is marked, even at `summary` (W-50)
248
409
  fire(this, 'sheetpane-reveal', { error: { ...err } });
249
410
  break;
250
411
  case 'resolve':
251
412
  if (err && !this.readOnly)
252
413
  s.updateError(err.id, { status: err.status === 'open' ? 'resolved' : 'open' }, this);
414
+ break; // Reopen also takes back an acceptance (W-69)
415
+ case 'accept': // W-69: the reason form, in the open row
416
+ if (!err || err.status !== 'open' || this.readOnly || machineAuthored(err.author))
417
+ return;
418
+ this.accepting = this.accepting === err.id ? undefined : err.id;
419
+ this.reasonDraft = '';
420
+ this.focusNext = this.accepting ? 'reason' : undefined;
421
+ this.schedule();
422
+ break;
423
+ case 'accept-cancel':
424
+ this.closeAccept();
425
+ break;
426
+ case 'accept-ok':
427
+ this.acceptNow();
253
428
  break;
254
429
  case 'delete':
255
430
  if (err && !this.readOnly) {
@@ -288,6 +463,8 @@ export class SheetpaneErrors extends ElementBase {
288
463
  }
289
464
  }
290
465
  onInput(e) {
466
+ if (inDetail(e.target))
467
+ return;
291
468
  const t = e.target, f = t.dataset.field;
292
469
  if (f === 'message')
293
470
  this.draft.message = t.value;
@@ -295,9 +472,39 @@ export class SheetpaneErrors extends ElementBase {
295
472
  this.draft.expected = t.value;
296
473
  else if (f === 'change')
297
474
  this.draft.change = t.checked;
475
+ else if (f === 'reason') {
476
+ this.reasonDraft = t.value;
477
+ const ok = this.shadow.querySelector('[data-act=accept-ok]');
478
+ if (ok)
479
+ ok.disabled = !t.value.trim();
480
+ }
298
481
  }
299
482
  onKey(e) {
483
+ if (inDetail(e.composedPath()[0] ?? null))
484
+ return; // the host's keys in its region (W-68)
300
485
  const typing = e.composedPath()[0]?.matches?.('input, textarea');
486
+ if (this.accepting && e.composedPath()[0]?.dataset?.field === 'reason') { // W-69: the reason form
487
+ if (e.key === 'Escape') {
488
+ e.preventDefault();
489
+ this.closeAccept();
490
+ return;
491
+ }
492
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
493
+ e.preventDefault();
494
+ this.acceptNow();
495
+ return;
496
+ }
497
+ }
498
+ const at = e.composedPath()[0];
499
+ if (at?.dataset?.act === 'edit-pick' && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) { // W-64: the Edits list moves by row
500
+ const rows = Array.from(this.shadow.querySelectorAll('.list [data-act=edit-pick]')), i = rows.indexOf(at);
501
+ const to = e.key === 'Home' ? 0 : e.key === 'End' ? rows.length - 1 : i + (e.key === 'ArrowDown' ? 1 : -1);
502
+ if (rows[to]) {
503
+ e.preventDefault();
504
+ rows[to].focus();
505
+ }
506
+ return;
507
+ }
301
508
  if (!typing && (e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')) {
302
509
  e.preventDefault();
303
510
  if (e.shiftKey)
@@ -320,6 +527,54 @@ export class SheetpaneErrors extends ElementBase {
320
527
  this._store.compose(null);
321
528
  }
322
529
  }
530
+ /** Selects an error's cell; a sheet-level error opens its tab (W-55), keeping the selection when it is already there; a workbook-level one has no place. */
531
+ showPlace(err) {
532
+ const s = this._store, scope = errorScope(err);
533
+ if (scope === 'cell') {
534
+ const r = parseRange(err.range);
535
+ if (r)
536
+ s.select({ sheet: err.sheet, ref: cellRef(r.top, r.left) }, this);
537
+ }
538
+ else if (scope === 'sheet' && s.getSnapshot().selection.sheet !== err.sheet && s.sheet(err.sheet))
539
+ s.select({ sheet: err.sheet, ref: 'A1' }, this); // as a tab click does
540
+ }
541
+ /** Selects an edit's cell or range (W-64); an edit about a whole sheet opens its tab, keeping the selection when it is already there. */
542
+ showEdit(k) {
543
+ const s = this._store, { sheet, ref } = splitKey(k), r = parseRange(editRef(ref));
544
+ if (r) {
545
+ const range = rangeRef(r), at = cellRef(r.top, r.left);
546
+ s.select(range === at ? { sheet, ref: at } : { sheet, ref: at, range }, this);
547
+ }
548
+ else if (s.getSnapshot().selection.sheet !== sheet && s.sheet(sheet))
549
+ s.select({ sheet, ref: 'A1' }, this);
550
+ }
551
+ /** Reverts one edit (W-64) — itself one undo step. Focus moves to the next row's Revert, else the previous one's, else the Edits chip. */
552
+ revertEdit(k) {
553
+ if (this.readOnly)
554
+ return;
555
+ const keys = Array.from(this.shadow.querySelectorAll('.list [data-k]')).map((x) => x.dataset.k), i = keys.indexOf(k);
556
+ if (!this._store.revertEdits([k], this))
557
+ return;
558
+ const next = keys[i + 1] ?? keys[i - 1];
559
+ if (next)
560
+ this.refocus = `[data-k="${globalThis.CSS.escape(next)}"] [data-act=revert-edit]`;
561
+ else
562
+ this.focusEditsChip = true;
563
+ }
564
+ /** Closes the reason form; focus goes back to Accept…. */
565
+ closeAccept() { this.accepting = undefined; this.reasonDraft = ''; this.focusNext = 'accept'; this.schedule(); }
566
+ /** Accepts the error whose form is open (W-69): only with a reason; who is this element's `author`. The row moves under the Accepted chip, which takes focus. */
567
+ acceptNow() {
568
+ const id = this.accepting;
569
+ if (!id || this.readOnly || !this.reasonDraft.trim())
570
+ return;
571
+ if (!this._store.acceptError(id, this.reasonDraft, { by: this.getAttribute('author') ?? '' }, this))
572
+ return;
573
+ this.accepting = undefined;
574
+ this.reasonDraft = '';
575
+ this.focusNext = 'chip';
576
+ this.schedule();
577
+ }
323
578
  add() {
324
579
  const s = this._store, c = s.getSnapshot().composing;
325
580
  if (!c || this.readOnly)
@@ -335,6 +590,19 @@ export class SheetpaneErrors extends ElementBase {
335
590
  this.draft = { type: t.id, message: '', expected: '', severity: t.severity, change: false };
336
591
  this.formKey = '';
337
592
  }
593
+ /** Show ▸ (W-50): selects the group's range on its first sheet and marks that range's results, even at `summary`. */
594
+ showGroup(key) {
595
+ const s = this._store, entries = this.groupEntries.get(key);
596
+ if (!entries?.length)
597
+ return;
598
+ const sheet = entries[0].sheet, box = boxOf(entries.filter((e) => e.sheet === sheet));
599
+ if (!box)
600
+ return;
601
+ const range = rangeRef(box), ref = cellRef(box.top, box.left);
602
+ s.select(range === ref ? { sheet, ref } : { sheet, ref, range }, this);
603
+ s.focusResults(sheet, range);
604
+ fire(this, 'sheetpane-reveal', { error: { ...entries[0] } });
605
+ }
338
606
  // ── rendering ────────────────────────────────────────────────────────────
339
607
  authorKind(e) { return e.author === 'workbook' ? 'workbook' : e.author === 'host' ? 'host' : e.author === 'file' ? 'file' : 'person'; }
340
608
  render() {
@@ -343,9 +611,40 @@ export class SheetpaneErrors extends ElementBase {
343
611
  return;
344
612
  const snap = s.getSnapshot();
345
613
  const q = (sel) => this.shadow.querySelector(sel);
346
- const errors = snap.errors;
614
+ // counts that ask for action count problems only; results are counted apart (W-50)
615
+ // W-51: what the visible layers hold — the sheet draws the same
616
+ const errors = snap.visibleErrors.filter((e) => !isResult(e));
347
617
  const open = errors.filter((e) => e.status === 'open');
348
618
  q('.opencount').textContent = `${open.length} open`;
619
+ const rc = s.resultCounts(), hasResults = s.results !== 'off' && rc.pass + rc.skipped > 0;
620
+ // W-64: the Edits view — in Edit mode, and in View mode while the pane holds edits
621
+ const editCount = snap.changes.size, hasEdits = editCount > 0 || snap.mode === 'edit';
622
+ if ((this.view === 'pass' && !(hasResults && rc.pass)) || (this.view === 'skipped' && !(hasResults && rc.skipped)) || (this.view === 'edits' && !hasEdits))
623
+ this.view = 'problems';
624
+ q('.summary').hidden = !hasResults;
625
+ if (hasResults) {
626
+ const sum = `<span><b>${rc.open}</b> problem${rc.open === 1 ? '' : 's'}</span><span class="p">✓ ${rc.pass} passed</span><span class="sk">— ${rc.skipped} not checked</span>`;
627
+ const st = q('.sumtext');
628
+ if (st.dataset.html !== sum) {
629
+ st.innerHTML = sum;
630
+ st.dataset.html = sum;
631
+ }
632
+ q('.sw').setAttribute('aria-checked', String(s.results === 'marks'));
633
+ }
634
+ // W-51: the layers switch, with two or more layers — All, then each layer with its open count
635
+ const multi = snap.layers.length > 1;
636
+ const allOn = snap.visibleLayers.length === snap.layers.length;
637
+ const lb = q('.layerbox');
638
+ const layersHtml = multi ? `<div class="seg" role="group" aria-label="Review layers"><button type="button" data-act="layer" data-v="" aria-pressed="${allOn}">All</button>${snap.layers.map((l) => `<button type="button" data-act="layer" data-v="${esc(l)}" data-sp-layer="${esc(l)}" aria-pressed="${snap.visibleLayers.includes(l)}">${esc(this.layerName(l))} <span class="lc">${snap.errors.filter((e) => e.status === 'open' && !isResult(e) && layerOf(e) === l).length}</span></button>`).join('')}</div>` : '';
639
+ if (lb.dataset.html !== layersHtml) {
640
+ lb.innerHTML = layersHtml;
641
+ lb.dataset.html = layersHtml;
642
+ }
643
+ if (this.focusNext === 'layer') {
644
+ [...lb.querySelectorAll('[data-act=layer]')].find((b) => b.dataset.v === this.focusLayer)?.focus();
645
+ this.focusNext = undefined;
646
+ }
647
+ const tagLayer = multi && snap.visibleLayers.length > 1;
349
648
  q('[data-act=undo]').disabled = !snap.canUndo || this.readOnly;
350
649
  q('[data-act=redo]').disabled = !snap.canRedo || this.readOnly;
351
650
  q('[data-act=undo]').hidden = q('[data-act=redo]').hidden = this.readOnly || snap.mode !== 'edit';
@@ -353,14 +652,32 @@ export class SheetpaneErrors extends ElementBase {
353
652
  const differ = this.savedPlugins !== undefined && this.savedPlugins.join(' ') !== s.pluginIds.join(' ');
354
653
  q('.noticebox').innerHTML = differ ? `<div class="notice" role="status">This review was saved with ${this.savedPlugins.length ? esc(this.savedPlugins.join(', ')) : 'no plugins'}; this pane runs ${s.pluginIds.length ? esc(s.pluginIds.join(', ')) : 'none'} — cells may be formatted differently.</div>` : '';
355
654
  // filters: severity, and author once more than people write errors (W-18 DoD 5)
356
- const counts = { all: errors.length, error: errors.filter((e) => e.severity === 'error').length, warning: errors.filter((e) => e.severity === 'warning').length, note: errors.filter((e) => e.severity === 'note').length };
655
+ // W-69: accepted errors are not counted; the Accepted chip lists them instead of the rest
656
+ const accepted = errors.filter((e) => e.status === 'accepted');
657
+ if (!accepted.length)
658
+ this.showAccepted = false;
659
+ const counted = errors.filter((e) => e.status !== 'accepted');
660
+ const counts = { all: counted.length, error: counted.filter((e) => e.severity === 'error').length, warning: counted.filter((e) => e.severity === 'warning').length, note: counted.filter((e) => e.severity === 'note').length };
661
+ // Problems · Passed · Not checked — the result chips only when results exist; Problems is the default (W-50); then Edits (W-64)
662
+ const views = [['problems', `Problems ${counts.all}`, `Problems: ${counts.all}`], ['pass', `✓ Passed ${rc.pass}`, `Passed: ${rc.pass} cells`], ['skipped', `— Not checked ${rc.skipped}`, `Not checked: ${rc.skipped} cells`], ['edits', `Edits ${editCount}`, `Edits: ${editCount}`]]
663
+ .filter(([v]) => v === 'problems' || (v === 'edits' ? hasEdits : hasResults && rc[v] > 0));
664
+ const viewBtns = views.length > 1
665
+ ? `${views.map(([v, label, name]) => `<button type="button" data-act="view" data-v="${v}" aria-pressed="${this.view === v}" aria-label="${name}">${label}</button>`).join('')}${this.view === 'problems' ? '<span class="sep" aria-hidden="true"></span>' : ''}`
666
+ : '';
357
667
  const authors = new Set(errors.map((e) => this.authorKind(e)));
358
668
  const sevBtns = [['all', `All ${counts.all}`, 'All severities'], ['error', `✕ ${counts.error}`, 'Errors'], ['warning', `! ${counts.warning}`, 'Warnings'], ['note', `i ${counts.note}`, 'Notes']]
359
669
  .map(([v, label, name]) => `<button type="button" data-act="filter" data-v="${v}" aria-pressed="${this.filter === v}" aria-label="${name}: ${counts[v]}">${label}</button>`).join('');
360
670
  const authorBtns = authors.size > 1 || (authors.size === 1 && !authors.has('person'))
361
671
  ? `<span class="sep" aria-hidden="true"></span>${['all', 'person', 'workbook', 'host', 'file'].filter((a) => a === 'all' || authors.has(a)).map((a) => `<button type="button" data-act="author" data-v="${a}" aria-pressed="${this.authorFilter === a}">${a === 'all' ? 'Anyone' : a === 'person' ? 'People' : a === 'workbook' ? 'Workbook rules' : a === 'host' ? (s.hasRulesDocument ? 'Rules file' : 'Column rules') : 'File comments'}</button>`).join('')}`
362
672
  : '';
363
- q('.filters').innerHTML = sevBtns + authorBtns;
673
+ const acceptedBtn = accepted.length ? `<span class="sep" aria-hidden="true"></span><button type="button" data-act="status" data-v="accepted" aria-pressed="${this.showAccepted}">Accepted ${accepted.length}</button>` : '';
674
+ // the severity, Accepted and author filters are the Problems view's (W-50)
675
+ const filtersHtml = viewBtns + (this.view === 'problems' ? sevBtns + acceptedBtn + authorBtns : '');
676
+ const fl = q('.filters');
677
+ if (fl.dataset.html !== filtersHtml) {
678
+ fl.innerHTML = filtersHtml;
679
+ fl.dataset.html = filtersHtml;
680
+ }
364
681
  // the form (contract decision 4: this element shows it whenever it is connected to the pane)
365
682
  const formbox = q('.formbox');
366
683
  const c = snap.composing;
@@ -378,33 +695,147 @@ export class SheetpaneErrors extends ElementBase {
378
695
  }
379
696
  // the list
380
697
  const list = errors
381
- .filter((e) => (this.filter === 'all' || e.severity === this.filter) && (this.authorFilter === 'all' || this.authorKind(e) === this.authorFilter))
382
- .sort((a, b) => (a.status === b.status ? errNum(a) - errNum(b) : a.status === 'open' ? -1 : 1));
383
- // rule rows are read by sheet (W-33): under the host filter, grouped by the workbook's sheet order
384
- const grouped = this.authorFilter === 'host' && new Set(list.map((e) => e.sheet)).size > 1;
385
- if (grouped) {
386
- const order = (snap.book?.sheets ?? []).map((x) => x.name);
387
- list.sort((a, b) => order.indexOf(a.sheet) - order.indexOf(b.sheet));
698
+ .filter((e) => (this.showAccepted ? e.status === 'accepted' : e.status !== 'accepted') && (this.filter === 'all' || e.severity === this.filter) && (this.authorFilter === 'all' || this.authorKind(e) === this.authorFilter))
699
+ .sort((a, b) => (a.status === 'open' ? 0 : 1) - (b.status === 'open' ? 0 : 1) || errNum(a) - errNum(b));
700
+ if (this.accepting && !list.some((e) => e.id === this.accepting && e.id === this.expanded && e.status === 'open')) {
701
+ this.accepting = undefined;
702
+ this.reasonDraft = '';
388
703
  }
389
- let lastSheet = '';
704
+ // W-55: errors with no cell come first — the Workbook group, then each sheet's whole-sheet errors in sheet order
705
+ const order = (snap.book?.sheets ?? []).map((x) => x.name);
706
+ const rank = (e) => { const sc = errorScope(e); return sc === 'workbook' ? 0 : sc === 'sheet' ? 1 : 2; };
707
+ const sheetAt = (n) => { const i = order.indexOf(n); return i < 0 ? order.length : i; };
708
+ const scoped = list.some((e) => rank(e) < 2);
709
+ // rule rows are read by sheet (W-33): under the host filter, grouped by the workbook's sheet order
710
+ const grouped = this.authorFilter === 'host' && new Set(list.filter((e) => rank(e) === 2).map((e) => e.sheet)).size > 1;
711
+ list.sort((a, b) => rank(a) - rank(b) || (rank(a) === 1 || grouped ? sheetAt(a.sheet) - sheetAt(b.sheet) : 0)); // stable: open first, by number, within each
712
+ let lastHead = '';
390
713
  const types = new Map([...Object.entries(BUILTIN_TYPE_NAMES), ...this._errorTypes.map((t) => [t.id, t.name])]);
391
714
  const sel = snap.selection;
392
715
  const html = list.length ? list.map((e) => {
393
- const head = grouped && e.sheet !== lastSheet ? `<div class="grp" role="presentation">${esc(e.sheet)}</div>` : '';
394
- lastSheet = e.sheet;
716
+ const scope = errorScope(e);
717
+ const title = scope === 'workbook' ? 'Workbook' : scope === 'sheet' ? `${e.sheet} · whole sheet` : grouped ? e.sheet : scoped ? 'Cells' : '';
718
+ const head = title && title !== lastHead ? `<div class="grp" role="presentation">${esc(title)}</div>` : '';
719
+ lastHead = title;
720
+ const place = scope === 'workbook' ? 'workbook' : scope === 'sheet' ? 'whole sheet' : e.range;
395
721
  const r = parseRange(e.range);
396
722
  const on = this.expanded === e.id || (!!r && e.sheet === sel.sheet && parseRef(sel.ref) && r.top === parseRef(sel.ref).row && r.left <= parseRef(sel.ref).col && r.right >= parseRef(sel.ref).col);
397
- const tags = [e.cellChanged ? 'cell changed' : '', e.status === 'resolved' ? 'resolved' : '', e.matchesExpected ? 'matches suggestion' : '', s.isStale(e) ? 'stale' : '', ...hiddenTags(s, e)].filter(Boolean);
723
+ const tags = [tagLayer ? this.layerName(layerOf(e)) : '', e.cellChanged ? 'cell changed' : '', e.status === 'resolved' ? 'resolved' : e.status === 'accepted' ? 'accepted' : '', e.matchesExpected ? 'matches suggestion' : '', s.isStale(e) ? 'stale' : '', ...hiddenTags(s, e)].filter(Boolean);
398
724
  const ro = this.readOnly || machineAuthored(e.author);
399
725
  const sv = SEVERITY[e.severity];
400
726
  const time = e.createdAt ? new Date(e.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '';
401
- const detail = this.expanded === e.id ? `<div class="detail">${e.message ? `<p class="why">${esc(e.message)}</p>` : ''}<span class="xs faint">${esc([e.author === 'workbook' ? 'Workbook rule' : e.author === 'file' ? 'File comment' : e.author === 'host' ? (s.hasRulesDocument ? 'Rules file' : 'Column rule') : e.author, e.code, time].filter(Boolean).join(' · '))}</span><div class="acts"><button type="button" class="btn" data-act="reveal">Show in sheet</button>${ro ? '' : `<button type="button" class="btn" data-act="resolve" data-sp="resolve">${e.status === 'resolved' ? 'Reopen' : '✓ Resolve'}</button><span class="spacer" style="flex:1"></span><button type="button" class="btn btn-g btn-d" data-act="delete">Delete</button>`}</div></div>` : '';
402
- return `${head}<div class="item${e.status === 'resolved' ? ' done' : ''}${on ? ' on' : ''}" role="listitem" data-id="${esc(e.id)}" data-sp-error="${esc(e.id)}" data-sp-ref="${esc(e.sheet)}!${esc(e.range)}"><button type="button" class="issue" data-act="pick" aria-expanded="${this.expanded === e.id}" aria-label="Error ${errNum(e)}: ${esc(types.get(e.type) ?? e.type)}, ${esc(e.sheet)} ${esc(e.range)}, ${sv.name}${e.status === 'resolved' ? ', resolved' : ''}"><span class="n ${e.status === 'resolved' ? 'resolved' : e.severity}" aria-hidden="true">${e.status === 'resolved' ? '✓' : errNum(e)}</span><span class="bd"><span class="ty">${esc(types.get(e.type) ?? e.type)}</span><span class="ba"><span class="found">${esc(e.found)}</span> → <span class="want">${esc(e.expected || '—')}</span></span><span class="m">${esc(e.range)} · <span class="g g-${e.severity}" aria-hidden="true">${sv.glyph}</span> ${sv.name}${tags.length ? ` · ${esc(tags.join(' · '))}` : ''}</span></span></button>${detail}</div>`;
727
+ const detail = this.expanded === e.id ? `<div class="detail">${e.message ? `<p class="why">${esc(e.message)}</p>` : ''}${this.detailRegion.html()}${acceptedHtml(e)}<span class="xs faint">${esc([e.author === 'workbook' ? 'Workbook rule' : e.author === 'file' ? 'File comment' : e.author === 'host' ? (s.hasRulesDocument ? 'Rules file' : 'Column rule') : e.author, e.code, time].filter(Boolean).join(' · '))}</span><div class="acts">${scope === 'workbook' ? '<button type="button" class="btn" data-act="reveal" disabled aria-describedby="nowhere">Show in sheet</button><span class="xs faint" id="nowhere">About the whole workbook — no sheet to show</span>' : `<button type="button" class="btn" data-act="reveal">Show in sheet</button>`}${ro ? '' : `<button type="button" class="btn" data-act="resolve" data-sp="resolve">${e.status === 'open' ? '✓ Resolve' : 'Reopen'}</button>${e.status === 'open' ? `<button type="button" class="btn" data-act="accept" data-sp="accept-open" aria-expanded="${this.accepting === e.id}">Accept…</button>` : ''}<span class="spacer" style="flex:1"></span><button type="button" class="btn btn-g btn-d" data-act="delete">Delete</button>`}</div>${this.accepting === e.id && !ro ? ACCEPT_FORM : ''}</div>` : '';
728
+ return `${head}<div class="item${e.status === 'resolved' ? ' done' : ''}${on ? ' on' : ''}" role="listitem" data-id="${esc(e.id)}" data-sp-error="${esc(e.id)}" data-sp-ref="${esc(e.sheet)}!${esc(e.range)}"><button type="button" class="issue" data-act="pick" aria-expanded="${this.expanded === e.id}" aria-label="Error ${errNum(e)}: ${esc(types.get(e.type) ?? e.type)}, ${scope === 'workbook' ? 'whole workbook' : `${esc(e.sheet)} ${esc(place)}`}, ${sv.name}${e.status === 'open' ? '' : `, ${e.status}`}"><span class="n ${e.status === 'open' ? e.severity : e.status}" aria-hidden="true">${e.status === 'resolved' ? '✓' : e.status === 'accepted' ? 'A' : errNum(e)}</span><span class="bd"><span class="ty">${esc(types.get(e.type) ?? e.type)}</span>${scope !== 'cell' && !e.found && !e.expected ? '' : `<span class="ba"><span class="found">${esc(scope === 'cell' ? e.found : e.found || '—')}</span> → <span class="want">${esc(e.expected || '—')}</span></span>`}<span class="m">${esc(place)} · <span class="g g-${e.severity}" aria-hidden="true">${sv.glyph}</span> ${sv.name}${tags.length ? ` · ${esc(tags.join(' · '))}` : ''}</span></span></button>${detail}</div>`;
403
729
  }).join('') : `<p class="empty">${errors.length ? 'Nothing matches this filter.' : 'Nothing flagged yet. Select a cell in the sheet, then Flag issue.'}</p>`;
404
730
  const listEl = q('.list');
405
- if (listEl.dataset.html !== html) {
406
- listEl.innerHTML = html;
407
- listEl.dataset.html = html;
731
+ // the Problems view lists the errors (W-55's groups first); Passed and Not checked list grouped results (W-50); Edits the changes (W-64)
732
+ const edits = this.view === 'edits' ? s.edits() : [];
733
+ const canRevert = !this.readOnly && snap.mode === 'edit';
734
+ const edbar = q('.edbar');
735
+ edbar.hidden = this.view !== 'edits' || !edits.length;
736
+ const edbarHtml = edbar.hidden ? '' : `<span>${edits.length} edit${edits.length === 1 ? '' : 's'} · newest first</span><span class="spacer"></span>${canRevert ? '<button type="button" class="btn btn-g" data-act="revert-all" data-sp="revert-all">Revert all</button>' : ''}`;
737
+ if (edbar.dataset.html !== edbarHtml) {
738
+ edbar.innerHTML = edbarHtml;
739
+ edbar.dataset.html = edbarHtml;
740
+ }
741
+ const shown = this.view === 'problems' ? html : this.view === 'edits' ? this.editsHtml(edits, canRevert, types) : this.groupsHtml(snap.visibleErrors, this.view, types);
742
+ const hadReason = this.shadow.activeElement?.matches('[data-field=reason]') ?? false;
743
+ const rebuilt = listEl.dataset.html !== shown;
744
+ if (rebuilt) {
745
+ listEl.innerHTML = shown;
746
+ listEl.dataset.html = shown;
747
+ if (this.refocus)
748
+ listEl.querySelector(this.refocus)?.focus();
749
+ }
750
+ this.refocus = undefined;
751
+ this.detailRegion.sync(listEl, this.view === 'problems' ? list.find((e) => e.id === this.expanded) : undefined); // the host's region, after the message (W-68)
752
+ // W-69: the reason lives outside the markup, so typing never re-draws the list; focus follows the form
753
+ const reason = listEl.querySelector('[data-field=reason]');
754
+ if (reason) {
755
+ if (reason.value !== this.reasonDraft)
756
+ reason.value = this.reasonDraft;
757
+ listEl.querySelector('[data-act=accept-ok]').disabled = !this.reasonDraft.trim();
758
+ if (this.focusNext === 'reason' || (rebuilt && hadReason))
759
+ reason.focus();
760
+ }
761
+ if (this.focusEditsChip) {
762
+ this.focusEditsChip = false;
763
+ q('.filters').querySelector('[data-act=view][data-v=edits]')?.focus();
764
+ }
765
+ if (this.focusNext === 'accept')
766
+ listEl.querySelector('[data-act=accept]')?.focus();
767
+ else if (this.focusNext === 'chip')
768
+ q('.filters').querySelector('[data-act=status]')?.focus();
769
+ this.focusNext = undefined;
770
+ for (const box of Array.from(listEl.querySelectorAll('.cells')))
771
+ this.fillCells(box);
772
+ }
773
+ /** The Passed or Not checked view (W-50): one row per check (passes, by type + message) or reason (not checked, by message). */
774
+ groupsHtml(all, sev, types) {
775
+ const groups = new Map();
776
+ for (const e of all) {
777
+ if (e.severity !== sev)
778
+ continue;
779
+ const key = sev === 'pass' ? `${e.type}|${e.message}` : e.message ? `|${e.message}` : `${e.type}|`;
780
+ let g = groups.get(key);
781
+ if (!g)
782
+ groups.set(key, (g = { key, label: e.message || (types.get(e.type) ?? e.type), sev, entries: [], cells: 0, where: '' }));
783
+ g.entries.push(e);
784
+ const r = parseRange(e.range);
785
+ g.cells += r ? (r.bottom - r.top + 1) * (r.right - r.left + 1) : 1;
786
+ }
787
+ this.groupEntries.clear();
788
+ const list = [...groups.values()].sort((a, b) => b.cells - a.cells || a.label.localeCompare(b.label));
789
+ return list.map((g) => {
790
+ this.groupEntries.set(g.key, g.entries);
791
+ const open = this.openGroups.has(g.key), sv = SEVERITY[sev];
792
+ const n = g.entries.length;
793
+ const cells = open ? `<div class="cells" role="group" aria-label="${esc(g.label)}: ${g.cells} cell${g.cells === 1 ? '' : 's'}" style="height:${Math.min(n * CELL_ROW, CELLS_MAX)}px"><div class="vsp" style="height:${n * CELL_ROW}px"></div></div>` : '';
794
+ return `<div class="item rgrp" role="listitem" data-g="${esc(g.key)}"><div class="grow"><button type="button" class="issue" data-act="group" aria-expanded="${open}"><span class="rg ${sev}" aria-hidden="true">${sv.glyph}</span><span class="bd"><span class="ty"><span class="vh">${sv.name}: </span>${esc(g.label)}</span><span class="m">${g.cells} cell${g.cells === 1 ? '' : 's'} · ${esc(whereOf(g.entries))}</span></span></button><button type="button" class="btn btn-g lnk" data-act="show-group" aria-label="Show ${esc(g.label)} in sheet">Show ▸</button></div>${cells}</div>`;
795
+ }).join('');
796
+ }
797
+ /**
798
+ * The Edits view (W-64): one row per change-map entry, newest first — its place and kind, was → now, the time, and the
799
+ * errors it cleared or raised (each a link to the error in Problems); Revert per row in Edit mode. A page at a time.
800
+ */
801
+ editsHtml(edits, canRevert, types) {
802
+ if (!edits.length)
803
+ return '<p class="empty">No edits yet. Each change to the workbook is listed here — what it was, what it is now — with its own Revert.</p>';
804
+ const s = this._store, snap = s.getSnapshot(), byId = new Map(snap.errors.map((e) => [e.id, e]));
805
+ const shown = new Set(snap.visibleErrors.map((e) => e.id)); // W-51: an error in a hidden layer is named, not linked — Problems would not show it
806
+ const link = (id, how) => {
807
+ const e = byId.get(id), word = how === 'cleared' ? 'Cleared' : how === 'raised' ? 'Raised' : 'Matches the suggestion of';
808
+ const glyph = how === 'cleared' ? '✓' : how === 'raised' ? '✕' : '=';
809
+ const what = e ? `error ${errNum(e)}: ${types.get(e.type) ?? e.type}` : id;
810
+ return e && shown.has(id)
811
+ ? `<button type="button" class="fxl" data-act="goto-error" data-id="${esc(id)}"><span class="g-${how}" aria-hidden="true">${glyph}</span> ${word} ${esc(what)}</button>`
812
+ : `<span class="fxl"><span class="g-${how}" aria-hidden="true">${glyph}</span> ${word} ${esc(what)}</span>`;
813
+ };
814
+ const rows = edits.slice(0, this.editsLimit).map((ed) => {
815
+ const place = ed.ref ? `${ed.sheet}!${ed.ref}` : `${ed.sheet} · whole sheet`, kind = kindName(ed);
816
+ const fx = s.editEffects(ed.key);
817
+ const links = [...fx.cleared.map((id) => link(id, 'cleared')), ...fx.raised.map((id) => link(id, 'raised')), ...fx.matched.map((id) => link(id, 'matched'))];
818
+ const time = Date.parse(ed.at) ? new Date(ed.at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '';
819
+ const summary = [fx.cleared.length ? `${fx.cleared.length} cleared` : '', fx.raised.length ? `${fx.raised.length} raised` : ''].filter(Boolean).join(' · ');
820
+ return `<div class="item edit" role="listitem" data-k="${esc(ed.key)}" data-sp-edit="${esc(ed.key)}" data-sp-ref="${esc(ed.sheet)}!${esc(ed.ref)}"><div class="grow"><button type="button" class="issue" data-act="edit-pick"><span class="ek" aria-hidden="true">✎</span><span class="bd"><span class="ty">${esc(place)} · ${esc(kind)}</span><span class="ba"><span class="vh">was </span><span class="found">${esc(editText(s, ed, ed.was))}</span> → <span class="vh">now </span><span class="want">${esc(editText(s, ed, ed.now))}</span></span><span class="m">${time ? `<time datetime="${esc(ed.at)}">${esc(time)}</time>` : ''}${time && summary ? ' · ' : ''}${esc(summary)}</span></span></button>${canRevert ? `<button type="button" class="btn btn-g lnk" data-act="revert-edit" data-sp="revert-edit" aria-label="Revert ${esc(place)}${ed.kind === 'value' ? '' : ` ${esc(kind.toLowerCase())}`}">Revert</button>` : ''}</div>${links.length ? `<div class="fx">${links.join('')}</div>` : ''}</div>`;
821
+ }).join('');
822
+ const rest = edits.length - Math.min(edits.length, this.editsLimit);
823
+ return rows + (rest ? `<div role="listitem"><button type="button" class="btn more" data-act="edits-more">Show ${Math.min(EDITS_PAGE, rest)} more of ${rest}</button></div>` : '');
824
+ }
825
+ /** Draws the cell rows an opened group's box shows, and a few either side — never all of them (W-50, virtualised). */
826
+ fillCells(box) {
827
+ const entries = this.groupEntries.get(box.closest('[data-g]')?.dataset.g ?? '') ?? [];
828
+ const top = box.scrollTop, h = box.clientHeight || CELLS_MAX;
829
+ const a = Math.max(0, Math.floor(top / CELL_ROW) - 4), b = Math.min(entries.length, Math.ceil((top + h) / CELL_ROW) + 4);
830
+ let html = '';
831
+ for (let i = a; i < b; i++) {
832
+ const e = entries[i], sv = SEVERITY[e.severity];
833
+ html += `<button type="button" class="cellrow" data-act="reveal" data-id="${esc(e.id)}" data-sp-error="${esc(e.id)}" data-sp-ref="${esc(e.sheet)}!${esc(e.range)}" style="top:${i * CELL_ROW}px" title="Show in sheet"><span class="rg ${e.severity}" aria-hidden="true">${sv.glyph}</span><span class="vh">${sv.name}: </span>${esc(e.range)} <span class="m">${esc(e.sheet)}${e.found ? ` · ${esc(e.found)}` : ''}</span></button>`;
834
+ }
835
+ const sp = box.firstElementChild;
836
+ if (sp.dataset.html !== html) {
837
+ sp.innerHTML = html;
838
+ sp.dataset.html = html;
408
839
  }
409
840
  }
410
841
  formHtml() {
@@ -429,6 +860,8 @@ export class SheetpaneErrors extends ElementBase {
429
860
  <div class="fft"><span class="xs faint">⌘ ↵</span><span class="spacer" style="flex:1"></span><button type="button" class="btn" data-act="cancel">Cancel</button><button type="button" class="btn btn-p" data-act="add" data-sp="add-error">Add error</button></div></div>`;
430
861
  }
431
862
  }
863
+ /** The reason form in an open row (W-69): a reason is required — Accept stays disabled until one is typed. */
864
+ const ACCEPT_FORM = `<div class="accept" role="group" aria-label="Accept this deviation"><label class="field"><span>Why is this accepted?</span><textarea class="input" rows="2" data-field="reason" required aria-required="true" aria-describedby="sp-accept-help"></textarea></label><span class="xs faint" id="sp-accept-help">Required. Saved with your name and the time; the error stays in the review and the download, and no longer counts as open.</span><div class="acts"><button type="button" class="btn" data-act="accept-cancel">Cancel</button><button type="button" class="btn btn-p" data-act="accept-ok" data-sp="accept" disabled>Accept</button></div></div>`;
432
865
  /** The column's header text: the nearest row above with two or more text cells. */
433
866
  export function headerFor(text, row, col) {
434
867
  for (let r = 0; r < Math.min(row, 10); r++) {
@@ -439,6 +872,33 @@ export function headerFor(text, row, col) {
439
872
  return '';
440
873
  }
441
874
  export { sevOrder };
875
+ /** The bounding range of entries on one sheet, or undefined when none parses. */
876
+ function boxOf(entries) {
877
+ let box;
878
+ for (const e of entries) {
879
+ const r = parseRange(e.range);
880
+ if (!r)
881
+ continue;
882
+ box = box ? { top: Math.min(box.top, r.top), left: Math.min(box.left, r.left), bottom: Math.max(box.bottom, r.bottom), right: Math.max(box.right, r.right) } : { ...r };
883
+ }
884
+ return box;
885
+ }
886
+ /** Where a result group sits (W-50): one range when its cells fill it ("H3:H214"), else the first three ("H6, H31, H44 …"). */
887
+ function whereOf(entries) {
888
+ const sheets = new Set(entries.map((e) => e.sheet));
889
+ if (sheets.size > 1)
890
+ return `${sheets.size} sheets`;
891
+ const box = boxOf(entries);
892
+ let cells = 0;
893
+ for (const e of entries) {
894
+ const r = parseRange(e.range);
895
+ if (r)
896
+ cells += (r.bottom - r.top + 1) * (r.right - r.left + 1);
897
+ }
898
+ if (box && cells === (box.bottom - box.top + 1) * (box.right - box.left + 1))
899
+ return rangeRef(box);
900
+ return entries.slice(0, 3).map((e) => e.range).join(', ') + (entries.length > 3 ? ' …' : '');
901
+ }
442
902
  /** Where an error sits out of sight: a hidden sheet, row or column (W-49). Selecting it shows it in the sheet. */
443
903
  function hiddenTags(s, e) {
444
904
  const h = s.hidden(e.sheet), r = parseRange(e.range);
@@ -453,4 +913,66 @@ function hiddenTags(s, e) {
453
913
  out.push('hidden column');
454
914
  return out;
455
915
  }
916
+ /** An edit's kind in words (W-64); a kind this list does not know is named as it is. */
917
+ function kindName(e) {
918
+ switch (e.kind) {
919
+ case 'value': return 'Value';
920
+ case 'formula': return 'Formula';
921
+ case 'style': return 'Style';
922
+ case 'numFmt': return 'Number format';
923
+ case 'merge': return 'Merge';
924
+ case 'freeze': return 'Freeze panes';
925
+ case 'hidden': return e.key.endsWith('!#hiddenCols') ? 'Hidden columns' : 'Hidden rows';
926
+ case 'sheetState': return 'Sheet visibility';
927
+ case 'outline': return e.key.endsWith('!#outlineCols') ? 'Column groups' : 'Row groups';
928
+ case 'structure': return 'Rows and columns'; // W-52: every insert and delete on the sheet, one row
929
+ default: {
930
+ const k = String(e.kind);
931
+ return k ? k[0].toUpperCase() + k.slice(1) : 'Change';
932
+ }
933
+ }
934
+ }
935
+ /** One side of an edit as text (W-64): a cell value as the cell shows it, `(blank)` for none; other kinds in a few words. */
936
+ function editText(s, e, v) {
937
+ if (v === undefined || v === null || v === '')
938
+ return e.kind === 'value' || e.kind === 'formula' ? '(blank)' : '—';
939
+ if (e.kind === 'structure' && Array.isArray(v))
940
+ return v.length ? clip(v.map(opText).join(', ')) : 'as read';
941
+ const r = parseRange(e.ref);
942
+ if ((e.kind === 'value' || e.kind === 'formula') && r && (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string'))
943
+ return s.format(e.sheet, cellRef(r.top, r.left), v);
944
+ if (e.kind === 'merge')
945
+ return v ? 'merged' : 'not merged';
946
+ if (e.kind === 'freeze' && typeof v === 'object') {
947
+ const f = v;
948
+ return f.rows || f.cols ? `${f.rows ?? 0} row${f.rows === 1 ? '' : 's'}, ${f.cols ?? 0} column${f.cols === 1 ? '' : 's'}` : 'none';
949
+ }
950
+ if (e.kind === 'hidden' && Array.isArray(v)) {
951
+ if (!v.length)
952
+ return 'none';
953
+ const cols = e.key.endsWith('!#hiddenCols'), names = v.slice(0, 6).map((i) => (cols ? colName(i) : String(i + 1)));
954
+ return `${cols ? 'columns' : 'rows'} ${names.join(', ')}${v.length > 6 ? ' …' : ''}`;
955
+ }
956
+ if (e.kind === 'outline' && typeof v === 'object') {
957
+ const n = Object.keys(v).length;
958
+ return n ? `${n} grouped` : 'none';
959
+ }
960
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
961
+ return String(v);
962
+ let json;
963
+ try {
964
+ json = JSON.stringify(v);
965
+ }
966
+ catch {
967
+ json = String(v);
968
+ }
969
+ return clip(json);
970
+ }
971
+ const clip = (t) => (t.length > 60 ? `${t.slice(0, 59)}…` : t);
972
+ /** One insert or delete in a few words (W-52): `2 rows inserted at 3`, `column C deleted`. */
973
+ function opText(o) {
974
+ const rows = o.op.endsWith('Rows'), n = o.count, end = o.at + n - 1;
975
+ const where = rows ? (n === 1 ? `row ${o.at + 1}` : `rows ${o.at + 1}–${end + 1}`) : n === 1 ? `column ${colName(o.at)}` : `columns ${colName(o.at)}–${colName(end)}`;
976
+ return o.op.startsWith('insert') ? `${n} ${rows ? 'row' : 'column'}${n === 1 ? '' : 's'} inserted at ${rows ? o.at + 1 : colName(o.at)}` : `${where} deleted`;
977
+ }
456
978
  //# sourceMappingURL=element.js.map