@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
@@ -0,0 +1,814 @@
1
+ import { formatValue } from "../field/values.js";
2
+ import { formatDate } from "../date/dates.js";
3
+ import { absoluteTime } from "../format.js";
4
+ /** The grid's overridable strings. Missing entries keep today's text; `{name}` is a placeholder. */
5
+ export const GRID_LABEL_DEFAULTS = {
6
+ toolbarLabel: "{label} view",
7
+ expandAll: "Expand all",
8
+ collapseAll: "Collapse all",
9
+ sort: "Sort",
10
+ filters: "Filters",
11
+ filtersActive: "Filters ({count})",
12
+ advancedFilter: "Advanced filter",
13
+ columns: "Columns",
14
+ views: "Views",
15
+ groupsFailed: "Couldn’t load groups.",
16
+ retryGroups: "Retry groups",
17
+ treeSearching: "Finding where the matches are…",
18
+ treeTooBroad: "Too many matches to show where they are. Narrow the filter.",
19
+ treeSearchFailed: "Couldn’t find where the matches are. Change the filter to try again.",
20
+ groupedBy: "Grouped by {field}",
21
+ thenBy: "Then by {field}",
22
+ stopGrouping: "Stop grouping",
23
+ removeGrouping: "Remove grouping {field}",
24
+ moveGroupingEarlier: "Move {field} grouping earlier",
25
+ moveGroupingLater: "Move {field} grouping later",
26
+ groupingRefused: "Grouping is refused.",
27
+ removeRefusedGrouping: "{reason} Remove it from the saved view.",
28
+ filterRefused: "This page doesn’t allow filtering.",
29
+ criteriaNeedsRepair: "Saved filter needs repair. No matches will be returned.",
30
+ notLoaded: "Not loaded",
31
+ loading: "Loading…",
32
+ aboutRows: "About {count} rows",
33
+ atLeastRows: "{count}+ rows",
34
+ row: "1 row",
35
+ rows: "{count} rows",
36
+ group: "1 group",
37
+ groups: "{count} groups",
38
+ groupedRows: "{rows} in {groups}",
39
+ pages: "Pages",
40
+ firstPage: "First page",
41
+ previousPage: "Previous page",
42
+ nextPage: "Next page",
43
+ lastPage: "Last page",
44
+ page: "Page {page}",
45
+ pageOf: "Page {page} of {pages}",
46
+ pageRows: " · rows {first}–{last} of {total}",
47
+ sortRows: "Sort rows",
48
+ noSortableColumns: "This grid has no sortable columns.",
49
+ sortAction: "Sort {field}",
50
+ sortActionNow: "Sort {field}, now {direction}",
51
+ sortReverse: "Click to reverse",
52
+ sortAscending: "Click to sort ascending",
53
+ sortPriority: "Sort priority {priority}.",
54
+ clearFieldSorting: "Clear {field} sorting",
55
+ clearSorting: "Clear sorting",
56
+ moveFieldUp: "Move {field} up",
57
+ moveFieldDown: "Move {field} down",
58
+ resetView: "Reset to the page’s view",
59
+ savedViews: "Saved views",
60
+ noSavedViews: "None yet. Shape the grid, then save it here by name.",
61
+ deleteView: "Delete the view {name}",
62
+ viewName: "Name for this view",
63
+ viewNamePlaceholder: "Name this view",
64
+ save: "Save",
65
+ delete: "Delete",
66
+ sortBy: "Sort by {field}",
67
+ addToSort: "; Shift adds it to the sort",
68
+ dragToMove: "Drag to move",
69
+ dragToResize: "Drag its right edge to resize",
70
+ columnOptions: "{field} column options",
71
+ rowColumn: "{field} column",
72
+ rowMenu: "Row",
73
+ notFilterable: "Not filterable",
74
+ filterField: "Filter {field}",
75
+ chooseFilterDate: "Choose a date to filter {field}",
76
+ menuSort: "Sort",
77
+ ascending: "Ascending",
78
+ descending: "Descending",
79
+ directionAscending: "ascending",
80
+ directionDescending: "descending",
81
+ notSorted: "Not sorted",
82
+ addToSortMenu: "Add to the sort",
83
+ groupBy: "Group by {field}",
84
+ freezeUpTo: "Freeze up to {field}",
85
+ menuMove: "Move",
86
+ moveLeft: "Left",
87
+ moveRight: "Right",
88
+ moveToStart: "To the start",
89
+ moveToEnd: "To the end",
90
+ menuWidth: "Width",
91
+ wider: "Wider",
92
+ narrower: "Narrower",
93
+ fitSpace: "Fit the space",
94
+ hideField: "Hide {field}",
95
+ columnsEllipsis: "Columns…",
96
+ firstColumn: "It's the first column.",
97
+ lastColumn: "It's the last column.",
98
+ sortServiceRefused: "{field} can’t be sorted by this service.",
99
+ sortNavigationRefused: "{field} can’t be sorted until its related record’s display field is known.",
100
+ sortPolicyRefused: "Sorting by {field} isn’t allowed on this page.",
101
+ filterServiceRefused: "{field} can’t be filtered by this service.",
102
+ filterPolicyRefused: "Filtering by {field} isn’t allowed on this page.",
103
+ filterOperatorRefused: "{field} can’t be filtered by {operator} on this page.",
104
+ groupSharedRefused: "{field} can’t be grouped in a grid over shared data.",
105
+ groupUnknownRefused: "{field} can’t be grouped.",
106
+ groupLongRefused: "{field} is too long to group.",
107
+ groupPolicyRefused: "Grouping by {field} isn’t allowed on this page.",
108
+ resizePolicyRefused: "Resizing {field} isn’t allowed on this page.",
109
+ movePolicyRefused: "Moving {field} isn’t allowed on this page.",
110
+ hideLastRefused: "The last visible column can’t be hidden.",
111
+ hidePolicyRefused: "Hiding {field} isn’t allowed on this page.",
112
+ freezePolicyRefused: "Freezing {field} isn’t allowed on this page.",
113
+ freezeUnknownRefused: "{field} can’t be frozen.",
114
+ freezeLastRefused: "At least one column must stay unfrozen.",
115
+ loadFailed: "Couldn’t load rows.",
116
+ retry: "Retry",
117
+ groupLoadFailed: "Couldn’t load group.",
118
+ retryGroup: "Retry group {group}",
119
+ summaryLoadFailed: "Couldn’t load summaries.",
120
+ summaryLoading: "Loading summaries…",
121
+ summariesUpdated: "Summaries updated",
122
+ retrySummaries: "Retry summaries",
123
+ summaryRows: "Rows",
124
+ summarySum: "Sum {field}",
125
+ summaryMinimum: "Minimum {field}",
126
+ summaryMaximum: "Maximum {field}",
127
+ summaryAverage: "Average {field}",
128
+ summaryDistinct: "Distinct {field}",
129
+ summaryAria: "{label} summaries",
130
+ empty: "",
131
+ rowLoadFailed: "Could not load that row. Move to another row or retry.",
132
+ hiddenTitle: "You can't see this value",
133
+ hiddenValue: "Hidden",
134
+ advancedApplied: "Advanced filter applied",
135
+ advancedCleared: "Advanced filter cleared",
136
+ sortedBy: "Sorted by {sorts}",
137
+ sortEntry: "{field}, {direction}",
138
+ sortSeparator: ", then ",
139
+ dragPickedUp: "Picked up {field}. Drop it on another row to move it under that row, or on Top level to move it to the top level.",
140
+ dragNotMoved: "{field} not moved.",
141
+ foldersLimit: "Opened {limit} folders. Narrow the tree to open more.",
142
+ foldersOpened: "All folders opened.",
143
+ foldersClosed: "All folders closed.",
144
+ expansionLimit: "Only {limit} {noun} can be open at once. Close one first.",
145
+ driftSort: "Sorted by {field}",
146
+ driftFilter: "Filtered by {field}",
147
+ driftCriteria: "The advanced filter uses {field}",
148
+ driftGroup: "Grouped by {field}",
149
+ driftHidden: "Column {field} is hidden",
150
+ driftFrozen: "Column {field} is frozen",
151
+ driftEntry: "{what}: {reason}",
152
+ driftNote: "The saved view can’t fully apply. {entries} The view keeps these settings and uses them again when the page allows it.",
153
+ driftFallbackReason: "{field} can’t be used by the saved view.",
154
+ dismiss: "Dismiss",
155
+ };
156
+ const NUMBER_FORMATS = new Map();
157
+ function plainObject(value) {
158
+ return !!value && typeof value === "object" && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
159
+ }
160
+ function isValue(value) {
161
+ return value === null || typeof value === "string" || typeof value === "boolean"
162
+ || (typeof value === "number" && Number.isFinite(value));
163
+ }
164
+ const TONES = ["neutral", "positive", "caution", "negative"];
165
+ const ALIGNS = ["start", "center", "end"];
166
+ const MATCH_OPS = ["eq", "ne", "lt", "lte", "gt", "gte", "in", "between", "contains", "empty", "notEmpty"];
167
+ const NUMBER_STYLES = ["decimal", "integer", "percent", "currency"];
168
+ const DATE_STYLES = ["medium", "short", "iso"];
169
+ const NUMERIC = new Set(["Edm.Byte", "Edm.SByte", "Edm.Int16", "Edm.Int32", "Edm.Int64", "Edm.Decimal", "Edm.Single", "Edm.Double"]);
170
+ function fail(path, message) {
171
+ throw new TypeError(`${path} ${message}`);
172
+ }
173
+ function onlyKeys(value, path, allowed) {
174
+ for (const key of Object.keys(value))
175
+ if (!allowed.includes(key))
176
+ fail(`${path}.${key}`, "is not a known option");
177
+ }
178
+ function optionalString(value, path) {
179
+ if (value !== undefined && typeof value !== "string")
180
+ fail(path, "must be a string");
181
+ }
182
+ function optionalBoolean(value, path) {
183
+ if (value !== undefined && typeof value !== "boolean")
184
+ fail(path, "must be a boolean");
185
+ }
186
+ function validateClasses(value, path) {
187
+ if (typeof value === "string")
188
+ return;
189
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string"))
190
+ fail(path, "must be a class name or an array of class names");
191
+ }
192
+ function validateMatch(value, path) {
193
+ if (!plainObject(value))
194
+ fail(path, "must be an object");
195
+ onlyKeys(value, path, ["field", "op", "value"]);
196
+ if (typeof value.field !== "string" || !value.field)
197
+ fail(`${path}.field`, "must be a nonempty field name");
198
+ if (typeof value.op !== "string" || !MATCH_OPS.includes(value.op))
199
+ fail(`${path}.op`, `must be one of ${MATCH_OPS.join(", ")}`);
200
+ const match = value;
201
+ switch (match.op) {
202
+ case "empty":
203
+ case "notEmpty":
204
+ if (match.value !== undefined)
205
+ fail(`${path}.value`, `must be omitted for "${match.op}"`);
206
+ return;
207
+ case "in":
208
+ if (!Array.isArray(match.value) || match.value.some((entry) => !isValue(entry)))
209
+ fail(`${path}.value`, 'must be an array of values for "in"');
210
+ return;
211
+ case "between":
212
+ if (!Array.isArray(match.value) || match.value.length !== 2 || match.value.some((entry) => !isValue(entry)))
213
+ fail(`${path}.value`, 'must be a two-item array for "between"');
214
+ return;
215
+ default:
216
+ if (match.value === undefined || Array.isArray(match.value) || !isValue(match.value))
217
+ fail(`${path}.value`, "must be a single value");
218
+ }
219
+ }
220
+ function validateClassRule(value, path, allowFunctions) {
221
+ if (!plainObject(value))
222
+ fail(path, "must be a plain object");
223
+ onlyKeys(value, path, ["when", "test", "class"]);
224
+ if (value.when !== undefined)
225
+ validateMatch(value.when, `${path}.when`);
226
+ if (value.test !== undefined) {
227
+ if (typeof value.test !== "function")
228
+ fail(`${path}.test`, "must be a function");
229
+ if (!allowFunctions)
230
+ fail(`${path}.test`, "can’t be a function in a composition catalog");
231
+ }
232
+ if (value.class === undefined)
233
+ fail(`${path}.class`, "is required");
234
+ validateClasses(value.class, `${path}.class`);
235
+ }
236
+ function validateClassHook(value, path, allowFunctions) {
237
+ if (typeof value === "string")
238
+ return;
239
+ if (typeof value === "function") {
240
+ if (!allowFunctions)
241
+ fail(path, "can’t be a function in a composition catalog");
242
+ return;
243
+ }
244
+ if (!Array.isArray(value))
245
+ fail(path, "must be a class name, classes, rules or a predicate");
246
+ value.forEach((entry, i) => {
247
+ if (typeof entry === "string")
248
+ return;
249
+ if (typeof entry === "function") {
250
+ if (!allowFunctions)
251
+ fail(`${path}[${i}]`, "can’t be a function in a composition catalog");
252
+ return;
253
+ }
254
+ if (plainObject(entry)) {
255
+ validateClassRule(entry, `${path}[${i}]`, allowFunctions);
256
+ return;
257
+ }
258
+ fail(`${path}[${i}]`, "must be a class name, a rule object or a predicate");
259
+ });
260
+ }
261
+ function validateValueMap(value, path) {
262
+ if (!plainObject(value))
263
+ fail(path, "must be an object");
264
+ for (const [key, entry] of Object.entries(value)) {
265
+ const at = `${path}[${JSON.stringify(key)}]`;
266
+ if (typeof entry === "string")
267
+ continue;
268
+ if (!plainObject(entry))
269
+ fail(at, "must be a string or an object");
270
+ onlyKeys(entry, at, ["label", "tone", "class"]);
271
+ if (typeof entry.label !== "string")
272
+ fail(`${at}.label`, "must be a string");
273
+ if (entry.tone !== undefined && (typeof entry.tone !== "string" || !TONES.includes(entry.tone)))
274
+ fail(`${at}.tone`, `must be one of ${TONES.join(", ")}`);
275
+ if (entry.class !== undefined)
276
+ validateClasses(entry.class, `${at}.class`);
277
+ }
278
+ }
279
+ function validateFormat(value, path) {
280
+ if (!plainObject(value))
281
+ fail(path, "must be an object");
282
+ const kind = value.kind;
283
+ if (kind !== "date" && kind !== "number")
284
+ fail(`${path}.kind`, 'must be "date" or "number"');
285
+ if (kind === "date") {
286
+ onlyKeys(value, path, ["kind", "style"]);
287
+ if (value.style !== undefined && (typeof value.style !== "string" || !DATE_STYLES.includes(value.style)))
288
+ fail(`${path}.style`, `must be one of ${DATE_STYLES.join(", ")}`);
289
+ return;
290
+ }
291
+ onlyKeys(value, path, ["kind", "style", "currency", "minimumFractionDigits", "maximumFractionDigits"]);
292
+ if (value.style !== undefined && (typeof value.style !== "string" || !NUMBER_STYLES.includes(value.style)))
293
+ fail(`${path}.style`, `must be one of ${NUMBER_STYLES.join(", ")}`);
294
+ if (value.currency !== undefined && (typeof value.currency !== "string" || !value.currency))
295
+ fail(`${path}.currency`, "must be a currency code");
296
+ if (value.style === "currency" && typeof value.currency !== "string")
297
+ fail(`${path}.currency`, 'is required when style is "currency"');
298
+ for (const key of ["minimumFractionDigits", "maximumFractionDigits"]) {
299
+ const digits = value[key];
300
+ if (digits !== undefined && (typeof digits !== "number" || !Number.isSafeInteger(digits) || digits < 0))
301
+ fail(`${path}.${key}`, "must be a nonnegative integer");
302
+ }
303
+ const minDigits = value.minimumFractionDigits;
304
+ const maxDigits = value.maximumFractionDigits;
305
+ if (minDigits != null && maxDigits != null && minDigits > maxDigits) {
306
+ fail(`${path}.minimumFractionDigits`, "cannot exceed maximumFractionDigits");
307
+ }
308
+ // The integer style defaults its maximum to zero; a minimum above that is impossible.
309
+ if ((value.style ?? "decimal") === "integer" && typeof minDigits === "number" && minDigits > (typeof maxDigits === "number" ? maxDigits : 0)) {
310
+ fail(`${path}.minimumFractionDigits`, "cannot exceed maximumFractionDigits for the integer style");
311
+ }
312
+ // Reject options the renderer's Intl.NumberFormat cannot construct, naming the offending entry.
313
+ if (value.style === "currency") {
314
+ try {
315
+ new Intl.NumberFormat("en-GB", { style: "currency", currency: value.currency });
316
+ }
317
+ catch {
318
+ fail(`${path}.currency`, "must be a well-formed ISO 4217 currency code");
319
+ }
320
+ }
321
+ for (const key of ["minimumFractionDigits", "maximumFractionDigits"]) {
322
+ const digits = value[key];
323
+ if (typeof digits !== "number")
324
+ continue;
325
+ const probe = {};
326
+ probe[key] = digits;
327
+ try {
328
+ new Intl.NumberFormat("en-GB", probe);
329
+ }
330
+ catch {
331
+ fail(`${path}.${key}`, "is outside the range this platform supports");
332
+ }
333
+ }
334
+ }
335
+ function validateColumn(value, path, allowFunctions) {
336
+ if (!plainObject(value))
337
+ fail(path, "must be an object");
338
+ onlyKeys(value, path, ["title", "align", "valueMap", "format", "cellClass"]);
339
+ optionalString(value.title, `${path}.title`);
340
+ if (value.align !== undefined && (typeof value.align !== "string" || !ALIGNS.includes(value.align)))
341
+ fail(`${path}.align`, `must be one of ${ALIGNS.join(", ")}`);
342
+ if (value.valueMap !== undefined)
343
+ validateValueMap(value.valueMap, `${path}.valueMap`);
344
+ if (value.format !== undefined)
345
+ validateFormat(value.format, `${path}.format`);
346
+ if (value.cellClass !== undefined)
347
+ validateClassHook(value.cellClass, `${path}.cellClass`, allowFunctions);
348
+ }
349
+ function validateHilite(value, path, allowFunctions) {
350
+ if (!plainObject(value))
351
+ fail(path, "must be an object");
352
+ onlyKeys(value, path, ["scope", "field", "when", "test", "tone", "class"]);
353
+ if (value.scope !== undefined && value.scope !== "row" && value.scope !== "cell")
354
+ fail(`${path}.scope`, 'must be "row" or "cell"');
355
+ if (value.field !== undefined && (typeof value.field !== "string" || !value.field))
356
+ fail(`${path}.field`, "must be a nonempty field name");
357
+ if (value.scope === "cell" && value.field === undefined)
358
+ fail(`${path}.field`, 'is required when scope is "cell"');
359
+ if (value.when !== undefined)
360
+ validateMatch(value.when, `${path}.when`);
361
+ if (value.test !== undefined) {
362
+ if (typeof value.test !== "function")
363
+ fail(`${path}.test`, "must be a function");
364
+ if (!allowFunctions)
365
+ fail(`${path}.test`, "can’t be a function in a composition catalog");
366
+ }
367
+ if (value.tone !== undefined && (typeof value.tone !== "string" || !TONES.includes(value.tone)))
368
+ fail(`${path}.tone`, `must be one of ${TONES.join(", ")}`);
369
+ if (value.class !== undefined)
370
+ validateClasses(value.class, `${path}.class`);
371
+ }
372
+ /**
373
+ * Checks a [[type:GridPresentation]] value. With `allowFunctions` true, the
374
+ * JS-only class predicates and hilite tests are accepted; the composition
375
+ * catalog calls it without that, so plain-data shapes are all it can carry.
376
+ */
377
+ export function validateGridPresentation(value, allowFunctions = false) {
378
+ if (!plainObject(value))
379
+ fail("presentation", "must be an object");
380
+ onlyKeys(value, "presentation", ["columns", "rowClass", "cellClass", "hilites"]);
381
+ if (value.columns !== undefined) {
382
+ if (!plainObject(value.columns))
383
+ fail("presentation.columns", "must be an object keyed by field name");
384
+ for (const [field, column] of Object.entries(value.columns)) {
385
+ if (!field)
386
+ fail("presentation.columns", "must not have an empty field name");
387
+ validateColumn(column, `presentation.columns[${JSON.stringify(field)}]`, allowFunctions);
388
+ }
389
+ }
390
+ if (value.rowClass !== undefined)
391
+ validateClassHook(value.rowClass, "presentation.rowClass", allowFunctions);
392
+ if (value.cellClass !== undefined)
393
+ validateClassHook(value.cellClass, "presentation.cellClass", allowFunctions);
394
+ if (value.hilites !== undefined) {
395
+ if (!Array.isArray(value.hilites))
396
+ fail("presentation.hilites", "must be an array");
397
+ value.hilites.forEach((hilite, i) => validateHilite(hilite, `presentation.hilites[${i}]`, allowFunctions));
398
+ }
399
+ }
400
+ /** Checks a [[type:GridLabels]] value: known keys, string values only. */
401
+ export function validateGridLabels(value) {
402
+ if (!plainObject(value))
403
+ fail("labels", "must be an object");
404
+ for (const [key, text] of Object.entries(value)) {
405
+ if (!Object.hasOwn(GRID_LABEL_DEFAULTS, key))
406
+ fail(`labels.${key}`, "is not a known label");
407
+ if (typeof text !== "string")
408
+ fail(`labels.${key}`, "must be a string");
409
+ }
410
+ }
411
+ /** One label, overridden or defaulted, with `{name}` placeholders filled. */
412
+ export function labelText(labels, key, values) {
413
+ const template = labels[key] ?? GRID_LABEL_DEFAULTS[key];
414
+ if (!values)
415
+ return template;
416
+ return template.replace(/\{(\w+)\}/g, (whole, name) => {
417
+ const entry = values[name];
418
+ return entry === undefined ? whole : String(entry);
419
+ });
420
+ }
421
+ /** Whether one record meets a plain-data match. */
422
+ export function matchValue(match, entity) {
423
+ const value = entity[match.field];
424
+ switch (match.op) {
425
+ case "eq":
426
+ return sameValue(value, match.value);
427
+ case "ne":
428
+ return !sameValue(value, match.value);
429
+ case "lt":
430
+ return compare(value, match.value) === -1;
431
+ case "lte": {
432
+ const order = compare(value, match.value);
433
+ return order === -1 || order === 0;
434
+ }
435
+ case "gt":
436
+ return compare(value, match.value) === 1;
437
+ case "gte": {
438
+ const order = compare(value, match.value);
439
+ return order === 1 || order === 0;
440
+ }
441
+ case "in":
442
+ return Array.isArray(match.value) && match.value.some((entry) => sameValue(value, entry));
443
+ case "between": {
444
+ if (!Array.isArray(match.value) || match.value.length !== 2)
445
+ return false;
446
+ const low = compare(value, match.value[0]);
447
+ const high = compare(value, match.value[1]);
448
+ return low !== undefined && high !== undefined && low >= 0 && high <= 0;
449
+ }
450
+ case "contains":
451
+ return value !== null && value !== undefined && String(value).toLowerCase().includes(String(match.value).toLowerCase());
452
+ case "empty":
453
+ return value === null || value === undefined || value === "";
454
+ case "notEmpty":
455
+ return !(value === null || value === undefined || value === "");
456
+ }
457
+ }
458
+ /** Numbers and numeric text compare numerically, so a YAML threshold meets a Decimal string. */
459
+ function numericValue(value) {
460
+ if (typeof value === "number")
461
+ return Number.isFinite(value) ? value : undefined;
462
+ if (typeof value === "string" && value.trim() !== "") {
463
+ const parsed = Number(value);
464
+ return Number.isFinite(parsed) ? parsed : undefined;
465
+ }
466
+ return undefined;
467
+ }
468
+ function numericKey(value) {
469
+ if (typeof value === "number")
470
+ return Number.isFinite(value) ? { kind: "double", value } : undefined;
471
+ if (typeof value !== "string")
472
+ return undefined;
473
+ const text = value.trim();
474
+ if (text === "")
475
+ return undefined;
476
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) {
477
+ const parsed = Number(text);
478
+ return Number.isFinite(parsed) ? { kind: "double", value: parsed } : undefined;
479
+ }
480
+ const body = text.replace(/^[+-]/, "");
481
+ const [integer, fraction = ""] = body.split(".");
482
+ return { kind: "exact", sign: text.startsWith("-") ? "-" : "", integer: integer || "0", fraction };
483
+ }
484
+ /** Exact decimal order: digits compared as integers, never through a double. */
485
+ function exactCompare(a, b) {
486
+ if (a.sign !== b.sign)
487
+ return a.sign === "-" ? -1 : 1;
488
+ const length = Math.max(a.fraction.length, b.fraction.length);
489
+ const scale = 10n ** BigInt(length);
490
+ const left = BigInt(a.integer) * scale + BigInt(a.fraction.padEnd(length, "0") || "0");
491
+ const right = BigInt(b.integer) * scale + BigInt(b.fraction.padEnd(length, "0") || "0");
492
+ if (a.sign === "-")
493
+ return left < right ? 1 : left > right ? -1 : 0;
494
+ return left < right ? -1 : left > right ? 1 : 0;
495
+ }
496
+ function sameValue(left, right) {
497
+ if (left === right)
498
+ return true;
499
+ if (left === null || left === undefined || right === null || right === undefined)
500
+ return false;
501
+ const a = numericKey(left);
502
+ const b = numericKey(right);
503
+ if (a && b) {
504
+ if (a.kind === "exact" && b.kind === "exact")
505
+ return exactCompare(a, b) === 0;
506
+ // A double on either side keeps floating-point semantics, like a Decimal string against a JS number.
507
+ const x = numericValue(left);
508
+ const y = numericValue(right);
509
+ return x !== undefined && y !== undefined && x === y;
510
+ }
511
+ return (typeof left === "string" || typeof left === "number") && (typeof right === "string" || typeof right === "number") && String(left) === String(right);
512
+ }
513
+ function compare(left, right) {
514
+ const a = numericKey(left);
515
+ const b = numericKey(right);
516
+ if (a && b) {
517
+ if (a.kind === "exact" && b.kind === "exact")
518
+ return exactCompare(a, b);
519
+ const x = numericValue(left);
520
+ const y = numericValue(right);
521
+ if (x !== undefined && y !== undefined)
522
+ return x < y ? -1 : x > y ? 1 : 0;
523
+ }
524
+ if (typeFamily(left) !== typeFamily(right) || typeFamily(left) === "other") {
525
+ return sameValue(left, right) ? 0 : undefined;
526
+ }
527
+ const x = typeof left === "number" ? left : String(left);
528
+ const y = typeof right === "number" ? right : String(right);
529
+ return x < y ? -1 : x > y ? 1 : 0;
530
+ }
531
+ function typeFamily(value) {
532
+ if (value === null || value === undefined)
533
+ return "null";
534
+ switch (typeof value) {
535
+ case "number":
536
+ return "number";
537
+ case "string":
538
+ return "string";
539
+ case "boolean":
540
+ return "boolean";
541
+ default:
542
+ return "other";
543
+ }
544
+ }
545
+ /** A rule matches when every present condition matches; no condition matches. */
546
+ function ruleMatches(when, test, entity) {
547
+ return (when === undefined || matchValue(when, entity)) && (test === undefined || test());
548
+ }
549
+ function classList(value, into) {
550
+ if (value === undefined)
551
+ return;
552
+ for (const name of typeof value === "string" ? value.split(/\s+/) : value)
553
+ if (name)
554
+ into.push(name);
555
+ }
556
+ function classEntries(hook, context, entity) {
557
+ const out = [];
558
+ if (hook === undefined)
559
+ return out;
560
+ if (typeof hook === "function") {
561
+ classList(hook(context), out);
562
+ return out;
563
+ }
564
+ if (typeof hook === "string") {
565
+ classList(hook, out);
566
+ return out;
567
+ }
568
+ for (const entry of hook) {
569
+ if (typeof entry === "string") {
570
+ classList(entry, out);
571
+ continue;
572
+ }
573
+ if (typeof entry === "function") {
574
+ classList(entry(context), out);
575
+ continue;
576
+ }
577
+ const rule = entry;
578
+ const test = rule.test ? () => rule.test(context) : undefined;
579
+ if (ruleMatches(rule.when, test, entity))
580
+ classList(rule.class, out);
581
+ }
582
+ return out;
583
+ }
584
+ function hiliteMatches(hilite, entity) {
585
+ const field = hilite.when?.field ?? hilite.field;
586
+ const value = field === undefined ? undefined : entity[field];
587
+ const test = hilite.test ? () => hilite.test(entity, value) : undefined;
588
+ return ruleMatches(hilite.when, test, entity);
589
+ }
590
+ /**
591
+ * Classes and tone for one record row: the row hook first, then row-scoped
592
+ * hilites in declared order. The last hilite with a tone wins.
593
+ */
594
+ export function styleRow(presentation, context) {
595
+ const classes = classEntries(presentation.rowClass, context, context.entity);
596
+ let tone;
597
+ for (const hilite of presentation.hilites ?? []) {
598
+ if ((hilite.scope ?? "row") !== "row")
599
+ continue;
600
+ if (!hiliteMatches(hilite, context.entity))
601
+ continue;
602
+ classList(hilite.class, classes);
603
+ if (hilite.tone !== undefined)
604
+ tone = hilite.tone;
605
+ }
606
+ return { classes: dedupe(classes), ...(tone ? { tone } : {}) };
607
+ }
608
+ /**
609
+ * Classes and tone for one record cell: the grid cell hook, then the column
610
+ * hook, then a value-map entry, then cell-scoped hilites in declared order.
611
+ * The last tone wins.
612
+ */
613
+ export function styleCell(presentation, column, context, mapped) {
614
+ const classes = [
615
+ ...classEntries(presentation.cellClass, context, context.entity),
616
+ ...classEntries(column?.cellClass, context, context.entity),
617
+ ];
618
+ let tone;
619
+ if (mapped?.class !== undefined)
620
+ classList(mapped.class, classes);
621
+ if (mapped?.tone !== undefined)
622
+ tone = mapped.tone;
623
+ for (const hilite of presentation.hilites ?? []) {
624
+ if (hilite.scope !== "cell" || hilite.field !== context.field)
625
+ continue;
626
+ if (!hiliteMatches(hilite, context.entity))
627
+ continue;
628
+ classList(hilite.class, classes);
629
+ if (hilite.tone !== undefined)
630
+ tone = hilite.tone;
631
+ }
632
+ return { classes: dedupe(classes), ...(tone ? { tone } : {}) };
633
+ }
634
+ function dedupe(classes) {
635
+ return [...new Set(classes)];
636
+ }
637
+ /**
638
+ * A value-map entry for a raw value, keyed by `String(value)`. `undefined`
639
+ * means the map has no entry: metadata allowed values and default formatting
640
+ * still apply.
641
+ */
642
+ export function mappedValue(column, value) {
643
+ if (!column?.valueMap || value === undefined)
644
+ return undefined;
645
+ const key = String(value);
646
+ // Only own entries map; names like "constructor" must not read the prototype.
647
+ if (!Object.hasOwn(column.valueMap, key))
648
+ return undefined;
649
+ const entry = column.valueMap[key];
650
+ if (entry === undefined)
651
+ return undefined;
652
+ return typeof entry === "string"
653
+ ? { label: entry }
654
+ : { label: entry.label, ...(entry.tone !== undefined ? { tone: entry.tone } : {}), ...(entry.class !== undefined ? { class: entry.class } : {}) };
655
+ }
656
+ /**
657
+ * The text a value shows when no renderer, formatter or value-map entry
658
+ * applies. A date or number format overrides the matching type's default;
659
+ * everything else is [[staticMethod:formatValue]]'s text.
660
+ */
661
+ export function cellText(field, value, format) {
662
+ if (value === null || value === undefined)
663
+ return "";
664
+ if (format?.kind === "date" && field?.type === "Edm.Date" && typeof value === "string")
665
+ return formatDate(value, format.style);
666
+ if (format?.kind === "number" && NUMERIC.has(field?.type ?? "") && (typeof value === "number" || (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))))) {
667
+ // Int64 and Decimal text formats exactly; a double would drop digits.
668
+ if ((field?.type === "Edm.Int64" || field?.type === "Edm.Decimal") && typeof value === "string")
669
+ return exactNumberText(value, format);
670
+ return numberText(Number(value), format);
671
+ }
672
+ if (field?.type === "Edm.Boolean")
673
+ return value ? "Yes" : "";
674
+ if (field?.type === "Edm.Date" && typeof value === "string")
675
+ return formatDate(value);
676
+ if (field?.type === "Edm.DateTimeOffset" && typeof value === "string")
677
+ return absoluteTime(value);
678
+ return formatValue(field, value);
679
+ }
680
+ function numberText(value, format) {
681
+ const key = JSON.stringify(format);
682
+ let writer = NUMBER_FORMATS.get(key);
683
+ if (!writer) {
684
+ const style = format.style ?? "decimal";
685
+ const options = {};
686
+ if (style === "percent")
687
+ options.style = "percent";
688
+ else if (style === "currency") {
689
+ options.style = "currency";
690
+ options.currency = format.currency ?? "GBP";
691
+ }
692
+ else if (style === "integer")
693
+ options.maximumFractionDigits = 0;
694
+ if (format.minimumFractionDigits !== undefined)
695
+ options.minimumFractionDigits = format.minimumFractionDigits;
696
+ if (format.maximumFractionDigits !== undefined)
697
+ options.maximumFractionDigits = format.maximumFractionDigits;
698
+ writer = new Intl.NumberFormat("en-GB", options);
699
+ NUMBER_FORMATS.set(key, writer);
700
+ }
701
+ return writer.format(value);
702
+ }
703
+ const EXACT_GROUPER = new Intl.NumberFormat("en-GB", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
704
+ const EXACT_MINUS = new Intl.NumberFormat("en-GB").formatToParts(-1).find((p) => p.type === "minusSign")?.value ?? "-";
705
+ const EXACT_POINT = new Intl.NumberFormat("en-GB").formatToParts(1.1).find((p) => p.type === "decimal")?.value ?? ".";
706
+ const EXACT_LAYOUTS = new Map();
707
+ /** The fraction bounds Intl resolves for the same options the renderer builds. */
708
+ function exactFractionBounds(format) {
709
+ const style = format.style ?? "decimal";
710
+ const options = {};
711
+ if (style === "percent")
712
+ options.style = "percent";
713
+ else if (style === "currency") {
714
+ options.style = "currency";
715
+ options.currency = format.currency ?? "GBP";
716
+ }
717
+ else if (style === "integer")
718
+ options.maximumFractionDigits = 0;
719
+ if (format.minimumFractionDigits !== undefined)
720
+ options.minimumFractionDigits = format.minimumFractionDigits;
721
+ if (format.maximumFractionDigits !== undefined)
722
+ options.maximumFractionDigits = format.maximumFractionDigits;
723
+ const resolved = new Intl.NumberFormat("en-GB", options).resolvedOptions();
724
+ return { min: resolved.minimumFractionDigits ?? 0, max: resolved.maximumFractionDigits ?? 0 };
725
+ }
726
+ /** Rounds magnitude digits half away from zero, as Intl does, without a double in between. */
727
+ function roundMagnitude(integer, fraction, max) {
728
+ if (fraction.length > max) {
729
+ const kept = fraction.slice(0, max);
730
+ const carried = fraction[max] >= "5" ? incrementDigits(integer + kept) : integer + kept;
731
+ if (carried.length > integer.length + kept.length) {
732
+ // A carry that grows the digits moves the point left: the new leading
733
+ // digit belongs to the integer and every kept fraction digit became zero.
734
+ return { integer: carried.slice(0, carried.length - max), fraction: "0".repeat(max) };
735
+ }
736
+ // Otherwise the point sits after the original integer part.
737
+ return { integer: carried.slice(0, integer.length), fraction: carried.slice(integer.length) };
738
+ }
739
+ return { integer, fraction };
740
+ }
741
+ function incrementDigits(digits) {
742
+ const out = digits.split("");
743
+ for (let i = out.length - 1; i >= 0; i--) {
744
+ if (out[i] !== "9") {
745
+ out[i] = String(Number(out[i]) + 1);
746
+ return out.join("");
747
+ }
748
+ out[i] = "0";
749
+ }
750
+ return `1${out.join("")}`;
751
+ }
752
+ /** Percent style scales by 100, like Intl's percent does. */
753
+ function scalePercent(body) {
754
+ const [integer, fraction = ""] = body.split(".");
755
+ const shifted = fraction.padEnd(2, "0");
756
+ const whole = `${integer}${shifted.slice(0, 2)}`.replace(/^0+(?=\d)/, "");
757
+ const rest = shifted.slice(2);
758
+ return rest ? `${whole}.${rest}` : whole;
759
+ }
760
+ /** Formats plain decimal text exactly: Int64 and Decimal strings keep every digit. */
761
+ function exactNumberText(value, format) {
762
+ const text = value.trim();
763
+ const negative = text.startsWith("-");
764
+ const body = text.replace(/^[+-]/, "");
765
+ // Exponent or odd text keeps the double path's behaviour.
766
+ if (!/^\d+(\.\d*)?$|^\.\d+$/.test(body))
767
+ return numberText(Number(text), format);
768
+ const { min, max } = exactFractionBounds(format);
769
+ const scaled = format.style === "percent" ? scalePercent(body) : body;
770
+ const parts = scaled.split(".");
771
+ // A leading "." is the same magnitude as a leading "0.".
772
+ const integerRaw = parts[0] || "0";
773
+ const fractionRaw = parts[1] ?? "";
774
+ const rounded = roundMagnitude(integerRaw, fractionRaw, max);
775
+ const integer = rounded.integer || "0";
776
+ let fraction = rounded.fraction.padEnd(min, "0");
777
+ while (fraction.length > min && fraction.endsWith("0"))
778
+ fraction = fraction.slice(0, -1);
779
+ const grouped = EXACT_GROUPER.format(BigInt(integer));
780
+ const layout = makeExactLayout(format, min, max);
781
+ let out = "";
782
+ for (const part of layout) {
783
+ if (part.type === "integer") {
784
+ out += grouped;
785
+ if (fraction && !layout.some((p) => p.type === "fraction"))
786
+ out += EXACT_POINT + fraction;
787
+ continue;
788
+ }
789
+ if (part.type === "fraction") {
790
+ out += fraction;
791
+ continue;
792
+ }
793
+ out += part.value;
794
+ }
795
+ return negative ? EXACT_MINUS + out : out;
796
+ }
797
+ /** The skeleton Intl builds for a format; exact digits replace its number parts. */
798
+ function makeExactLayout(format, min, max) {
799
+ const key = JSON.stringify(format);
800
+ const cached = EXACT_LAYOUTS.get(key);
801
+ if (cached)
802
+ return cached;
803
+ const style = format.style ?? "decimal";
804
+ const options = { minimumFractionDigits: min, maximumFractionDigits: max };
805
+ if (style === "percent")
806
+ options.style = "percent";
807
+ else if (style === "currency") {
808
+ options.style = "currency";
809
+ options.currency = format.currency ?? "GBP";
810
+ }
811
+ const parts = new Intl.NumberFormat("en-GB", options).formatToParts(0);
812
+ EXACT_LAYOUTS.set(key, parts);
813
+ return parts;
814
+ }