@dataverse-kit/grid-kit 0.1.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 (115) hide show
  1. package/dist/adapters/CalloutCell.d.ts +24 -0
  2. package/dist/adapters/CalloutCell.d.ts.map +1 -0
  3. package/dist/adapters/EditCellWrapper.d.ts +13 -0
  4. package/dist/adapters/EditCellWrapper.d.ts.map +1 -0
  5. package/dist/adapters/context.d.ts +45 -0
  6. package/dist/adapters/context.d.ts.map +1 -0
  7. package/dist/adapters/fromGridCustomizerDefinition.d.ts +43 -0
  8. package/dist/adapters/fromGridCustomizerDefinition.d.ts.map +1 -0
  9. package/dist/adapters/index.d.ts +10 -0
  10. package/dist/adapters/index.d.ts.map +1 -0
  11. package/dist/adapters/resolveGridFromDefinition.d.ts +92 -0
  12. package/dist/adapters/resolveGridFromDefinition.d.ts.map +1 -0
  13. package/dist/adapters/toDetailsListColumns.d.ts +5 -0
  14. package/dist/adapters/toDetailsListColumns.d.ts.map +1 -0
  15. package/dist/adapters/toGridCustomizerOverrides.d.ts +46 -0
  16. package/dist/adapters/toGridCustomizerOverrides.d.ts.map +1 -0
  17. package/dist/core/aggregate.d.ts +35 -0
  18. package/dist/core/aggregate.d.ts.map +1 -0
  19. package/dist/core/coercion.d.ts +9 -0
  20. package/dist/core/coercion.d.ts.map +1 -0
  21. package/dist/core/colorResolver.d.ts +15 -0
  22. package/dist/core/colorResolver.d.ts.map +1 -0
  23. package/dist/core/filter.d.ts +40 -0
  24. package/dist/core/filter.d.ts.map +1 -0
  25. package/dist/core/formatters.d.ts +9 -0
  26. package/dist/core/formatters.d.ts.map +1 -0
  27. package/dist/core/index.d.ts +12 -0
  28. package/dist/core/index.d.ts.map +1 -0
  29. package/dist/core/thresholds.d.ts +15 -0
  30. package/dist/core/thresholds.d.ts.map +1 -0
  31. package/dist/dnd/DroppableCell.d.ts +16 -0
  32. package/dist/dnd/DroppableCell.d.ts.map +1 -0
  33. package/dist/dnd/FileDropCell.d.ts +25 -0
  34. package/dist/dnd/FileDropCell.d.ts.map +1 -0
  35. package/dist/dnd/index.d.ts +7 -0
  36. package/dist/dnd/index.d.ts.map +1 -0
  37. package/dist/dnd/useFileDrop.d.ts +36 -0
  38. package/dist/dnd/useFileDrop.d.ts.map +1 -0
  39. package/dist/export/GridExportDialog.d.ts +29 -0
  40. package/dist/export/GridExportDialog.d.ts.map +1 -0
  41. package/dist/export/exportGrid.d.ts +17 -0
  42. package/dist/export/exportGrid.d.ts.map +1 -0
  43. package/dist/export/index.d.ts +4 -0
  44. package/dist/export/index.d.ts.map +1 -0
  45. package/dist/hosts/CardGrid.d.ts +12 -0
  46. package/dist/hosts/CardGrid.d.ts.map +1 -0
  47. package/dist/hosts/DataGrid.d.ts +10 -0
  48. package/dist/hosts/DataGrid.d.ts.map +1 -0
  49. package/dist/hosts/DetailsGrid.d.ts +3 -0
  50. package/dist/hosts/DetailsGrid.d.ts.map +1 -0
  51. package/dist/hosts/FocusedViewGrid.d.ts +10 -0
  52. package/dist/hosts/FocusedViewGrid.d.ts.map +1 -0
  53. package/dist/hosts/GridAggregateFooter.d.ts +36 -0
  54. package/dist/hosts/GridAggregateFooter.d.ts.map +1 -0
  55. package/dist/hosts/GridColumnChooser.d.ts +27 -0
  56. package/dist/hosts/GridColumnChooser.d.ts.map +1 -0
  57. package/dist/hosts/GridFilterBuilder.d.ts +32 -0
  58. package/dist/hosts/GridFilterBuilder.d.ts.map +1 -0
  59. package/dist/hosts/GridPaginationFooter.d.ts +11 -0
  60. package/dist/hosts/GridPaginationFooter.d.ts.map +1 -0
  61. package/dist/hosts/GroupedGrid.d.ts +9 -0
  62. package/dist/hosts/GroupedGrid.d.ts.map +1 -0
  63. package/dist/hosts/ReadOnlyGrid.d.ts +9 -0
  64. package/dist/hosts/ReadOnlyGrid.d.ts.map +1 -0
  65. package/dist/hosts/buildGroups.d.ts +14 -0
  66. package/dist/hosts/buildGroups.d.ts.map +1 -0
  67. package/dist/hosts/cardLayout.d.ts +22 -0
  68. package/dist/hosts/cardLayout.d.ts.map +1 -0
  69. package/dist/hosts/fill.d.ts +10 -0
  70. package/dist/hosts/fill.d.ts.map +1 -0
  71. package/dist/hosts/index.d.ts +19 -0
  72. package/dist/hosts/index.d.ts.map +1 -0
  73. package/dist/hosts/nested/NestedCardParent.d.ts +23 -0
  74. package/dist/hosts/nested/NestedCardParent.d.ts.map +1 -0
  75. package/dist/hosts/nested/NestedGrid.d.ts +17 -0
  76. package/dist/hosts/nested/NestedGrid.d.ts.map +1 -0
  77. package/dist/hosts/nested/NestedInline.d.ts +18 -0
  78. package/dist/hosts/nested/NestedInline.d.ts.map +1 -0
  79. package/dist/hosts/nested/NestedTriggerCell.d.ts +12 -0
  80. package/dist/hosts/nested/NestedTriggerCell.d.ts.map +1 -0
  81. package/dist/hosts/nested/labels.d.ts +10 -0
  82. package/dist/hosts/nested/labels.d.ts.map +1 -0
  83. package/dist/hosts/nested/useChildren.d.ts +15 -0
  84. package/dist/hosts/nested/useChildren.d.ts.map +1 -0
  85. package/dist/hosts/rowContextMenu.d.ts +11 -0
  86. package/dist/hosts/rowContextMenu.d.ts.map +1 -0
  87. package/dist/hosts/toolbar/GridToolbar.d.ts +9 -0
  88. package/dist/hosts/toolbar/GridToolbar.d.ts.map +1 -0
  89. package/dist/hosts/useEditState.d.ts +25 -0
  90. package/dist/hosts/useEditState.d.ts.map +1 -0
  91. package/dist/hosts/useGridContext.d.ts +15 -0
  92. package/dist/hosts/useGridContext.d.ts.map +1 -0
  93. package/dist/hosts/useRowContextMenu.d.ts +18 -0
  94. package/dist/hosts/useRowContextMenu.d.ts.map +1 -0
  95. package/dist/index.d.ts +22 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.esm.js +2783 -0
  98. package/dist/index.esm.js.map +1 -0
  99. package/dist/index.js +2847 -0
  100. package/dist/index.js.map +1 -0
  101. package/dist/navigation/index.d.ts +3 -0
  102. package/dist/navigation/index.d.ts.map +1 -0
  103. package/dist/navigation/navigateTo.d.ts +71 -0
  104. package/dist/navigation/navigateTo.d.ts.map +1 -0
  105. package/dist/registry/columnMapping.d.ts +15 -0
  106. package/dist/registry/columnMapping.d.ts.map +1 -0
  107. package/dist/registry/createCellRegistry.d.ts +8 -0
  108. package/dist/registry/createCellRegistry.d.ts.map +1 -0
  109. package/dist/registry/index.d.ts +4 -0
  110. package/dist/registry/index.d.ts.map +1 -0
  111. package/dist/registry/renderers.d.ts +15 -0
  112. package/dist/registry/renderers.d.ts.map +1 -0
  113. package/dist/types/index.d.ts +488 -0
  114. package/dist/types/index.d.ts.map +1 -0
  115. package/package.json +51 -0
package/dist/index.js ADDED
@@ -0,0 +1,2847 @@
1
+ 'use strict';
2
+
3
+ var dynamicsUtils = require('@khester/dynamics-utils');
4
+ var dynamicsCellRenderers = require('@khester/dynamics-cell-renderers');
5
+ var jsxRuntime = require('react/jsx-runtime');
6
+ var React = require('react');
7
+ var react = require('@fluentui/react');
8
+
9
+ /**
10
+ * Framework-agnostic value coercion. No React / no Fluent imports (enforced by
11
+ * the package ESLint guard on src/core/**).
12
+ */
13
+ function toNumber(value) {
14
+ if (value === null || value === undefined || value === '')
15
+ return null;
16
+ const n = typeof value === 'number' ? value : Number(value);
17
+ return Number.isFinite(n) ? n : null;
18
+ }
19
+ function toBool(value) {
20
+ if (typeof value === 'boolean')
21
+ return value;
22
+ if (typeof value === 'number')
23
+ return value !== 0;
24
+ const s = String(value).toLowerCase();
25
+ return s === 'true' || s === '1' || s === 'yes';
26
+ }
27
+ function toDate(value) {
28
+ if (value instanceof Date)
29
+ return Number.isNaN(value.getTime()) ? null : value;
30
+ if (value === null || value === undefined || value === '')
31
+ return null;
32
+ const d = new Date(value);
33
+ return Number.isNaN(d.getTime()) ? null : d;
34
+ }
35
+ function toDisplayString(value) {
36
+ return value !== null && value !== undefined ? String(value) : '';
37
+ }
38
+
39
+ /**
40
+ * Framework-agnostic threshold resolution (progress / KPI coloring).
41
+ */
42
+ /**
43
+ * Resolve a value to a threshold level.
44
+ * - `direction: 'desc'` (default): lower is worse (e.g. completion %).
45
+ * - `direction: 'asc'`: higher is worse (e.g. error count).
46
+ */
47
+ function resolveThreshold(value, thresholds, direction = 'desc') {
48
+ if (!thresholds)
49
+ return 'ok';
50
+ const { warning, danger } = thresholds;
51
+ if (direction === 'desc') {
52
+ if (danger !== undefined && value <= danger)
53
+ return 'danger';
54
+ if (warning !== undefined && value <= warning)
55
+ return 'warning';
56
+ }
57
+ else {
58
+ if (danger !== undefined && value >= danger)
59
+ return 'danger';
60
+ if (warning !== undefined && value >= warning)
61
+ return 'warning';
62
+ }
63
+ return 'ok';
64
+ }
65
+
66
+ /**
67
+ * Framework-agnostic cell-color resolution. Wraps (does not reimplement)
68
+ * @khester/dynamics-utils contrast + hex helpers.
69
+ */
70
+ /** Resolve the background color for a cell value, or undefined for none. */
71
+ function resolveCellColor(value, res) {
72
+ if (value === null || value === undefined)
73
+ return undefined;
74
+ if (res.getBackgroundColor)
75
+ return res.getBackgroundColor(value);
76
+ if (res.colorMap) {
77
+ const key = typeof value === 'number' ? value : String(value);
78
+ return res.colorMap[key];
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ /**
84
+ * Framework-agnostic display-value formatting. Wraps @khester/dynamics-utils.
85
+ *
86
+ * This produces the FALLBACK `formattedValue` only — adapters prefer a
87
+ * host-supplied value (e.g. Dataverse `@OData…FormattedValue`) when present and
88
+ * never recompute over it.
89
+ */
90
+ /** Compute a fallback display string for a value given its renderer type. */
91
+ function computeFormattedValue(value, rendererType, config = {}) {
92
+ if (value === null || value === undefined || value === '')
93
+ return '';
94
+ const { currencyCode = 'USD', locale = 'en-US', dateOptions } = config;
95
+ switch (rendererType) {
96
+ case 'currency': {
97
+ const n = toNumber(value);
98
+ return n === null ? toDisplayString(value) : dynamicsUtils.formatCurrency(n, currencyCode, locale);
99
+ }
100
+ case 'number': {
101
+ const n = toNumber(value);
102
+ return n === null ? toDisplayString(value) : dynamicsUtils.formatNumber(n, locale);
103
+ }
104
+ case 'date':
105
+ return dynamicsUtils.formatDate(String(value), locale, dateOptions);
106
+ case 'datetime':
107
+ return dynamicsUtils.formatDateTime(String(value), locale);
108
+ default:
109
+ return toDisplayString(value);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Framework-agnostic footer aggregates. No React / no Fluent imports (enforced by
115
+ * the package ESLint guard on src/core/**). Used by the host footer renderers.
116
+ */
117
+ /**
118
+ * Compute one aggregate over a column's values.
119
+ * - `count` counts ALL rows (including null/empty values).
120
+ * - `sum`/`avg`/`min`/`max` consider only numeric-coercible values; over an empty
121
+ * numeric set they return `null` (so a footer renders blank, never NaN/Infinity).
122
+ * Implemented with a single pass (no `Math.min(...nums)` spread, which blows the
123
+ * argument limit on large grids).
124
+ */
125
+ function computeAggregate(items, fieldName, fn) {
126
+ if (fn === 'count')
127
+ return items.length;
128
+ let count = 0;
129
+ let sum = 0;
130
+ let min = Infinity;
131
+ let max = -Infinity;
132
+ for (const item of items) {
133
+ const n = toNumber(item?.[fieldName]);
134
+ if (n === null)
135
+ continue;
136
+ count++;
137
+ sum += n;
138
+ if (n < min)
139
+ min = n;
140
+ if (n > max)
141
+ max = n;
142
+ }
143
+ if (count === 0)
144
+ return null;
145
+ switch (fn) {
146
+ case 'sum':
147
+ return sum;
148
+ case 'avg':
149
+ return sum / count;
150
+ case 'min':
151
+ return min;
152
+ case 'max':
153
+ return max;
154
+ default:
155
+ return null;
156
+ }
157
+ }
158
+ /** Compute aggregates for every column that declared one → `{ fieldName: { fn, value } }`. */
159
+ function computeAggregates(items, columns) {
160
+ const out = {};
161
+ for (const col of columns) {
162
+ if (col.aggregate) {
163
+ out[col.fieldName] = { fn: col.aggregate, value: computeAggregate(items, col.fieldName, col.aggregate) };
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+ /**
169
+ * Per-group aggregates over a contiguous, group-ordered item array. `ranges` are
170
+ * `{ key, startIndex, count }` (the buckets from `buildGroups`); each group's
171
+ * rows are `orderedItems.slice(startIndex, startIndex + count)`. Returns
172
+ * `{ [groupKey]: { [fieldName]: { fn, value } } }`. Pure (no Fluent types) so it
173
+ * stays in framework-agnostic core and is unit-testable.
174
+ */
175
+ function computeGroupAggregates(orderedItems, ranges, columns) {
176
+ const out = {};
177
+ for (const r of ranges) {
178
+ out[r.key] = computeAggregates(orderedItems.slice(r.startIndex, r.startIndex + r.count), columns);
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * Framework-agnostic filter model + predicate engine. No React / no Fluent
185
+ * imports (enforced by the package ESLint guard on src/core/**).
186
+ *
187
+ * The operator vocabulary uses FetchXML names (`eq`/`like`/`begins-with`/…) so a
188
+ * consumer can map a `GridFilterModel` onto a server-side FetchXML / OData query
189
+ * (e.g. `@dataverse-kit/runtime`) 1:1 — grid-kit applies it client-side here.
190
+ */
191
+ const NO_VALUE_OPS = new Set(['null', 'not-null']);
192
+ const MULTI_VALUE_OPS = new Set(['in', 'not-in']);
193
+ /** Whether `operator` needs an operand value (false for `null`/`not-null`). */
194
+ function operatorRequiresValue(operator) {
195
+ return !NO_VALUE_OPS.has(operator);
196
+ }
197
+ /** Whether `operator` takes a list of values (`in`/`not-in`). */
198
+ function operatorIsMultiValue(operator) {
199
+ return MULTI_VALUE_OPS.has(operator);
200
+ }
201
+ /** A condition is applied only when fully specified (gates incomplete rows out). */
202
+ function isConditionComplete(c) {
203
+ if (!c.fieldName || !c.operator)
204
+ return false;
205
+ if (!operatorRequiresValue(c.operator))
206
+ return true;
207
+ if (operatorIsMultiValue(c.operator))
208
+ return Array.isArray(c.values) && c.values.length > 0;
209
+ return c.value !== undefined && c.value !== null && c.value !== '';
210
+ }
211
+ const isEmpty = (v) => v === null || v === undefined || v === '';
212
+ const asText = (v) => toDisplayString(v).toLowerCase();
213
+ /**
214
+ * Compare raw vs operand numerically, else by date. `undefined` ⇒ not comparable.
215
+ * Number-first is deliberate: a bare-year operand like `"2024"` compares as the
216
+ * number 2024 (the date editor always emits a full `YYYY-MM-DD`, never a bare year).
217
+ */
218
+ function compare(raw, operand) {
219
+ const a = toNumber(raw);
220
+ const b = toNumber(operand);
221
+ if (a !== null && b !== null)
222
+ return a === b ? 0 : a < b ? -1 : 1;
223
+ const da = toDate(raw);
224
+ const db = toDate(operand);
225
+ if (da && db) {
226
+ // Compare at UTC calendar-day granularity — the filter UI picks a day, not an
227
+ // instant — so a date-only operand matches a stored datetime on the same day
228
+ // and the result is timezone-stable (no off-by-one across the date line).
229
+ const ta = Date.UTC(da.getUTCFullYear(), da.getUTCMonth(), da.getUTCDate());
230
+ const tb = Date.UTC(db.getUTCFullYear(), db.getUTCMonth(), db.getUTCDate());
231
+ return ta === tb ? 0 : ta < tb ? -1 : 1;
232
+ }
233
+ return undefined;
234
+ }
235
+ /** Loose equality: numeric when both coerce to numbers, else case-insensitive text. */
236
+ function looseEq(raw, operand) {
237
+ const a = toNumber(raw);
238
+ const b = toNumber(operand);
239
+ if (a !== null && b !== null)
240
+ return a === b;
241
+ return asText(raw) === operand.trim().toLowerCase();
242
+ }
243
+ /**
244
+ * Equality used by `eq`/`ne`/`in`/`not-in`. Boolean operands (`'true'`/`'false'`
245
+ * from the Yes/No editor) compare via `toBool`, so a row stored as a JS boolean,
246
+ * `1`/`0`, or `'1'`/`'0'` (a Dataverse bit field) all match. Otherwise: text/numeric
247
+ * loose-equality, then a date/number `compare` so a `Date` (or date-string) still
248
+ * matches by calendar day — without this, `eq` on a date column never matched.
249
+ */
250
+ function valuesEqual(raw, operand) {
251
+ if (operand === 'true' || operand === 'false')
252
+ return toBool(raw) === (operand === 'true');
253
+ if (looseEq(raw, operand))
254
+ return true;
255
+ return compare(raw, operand) === 0;
256
+ }
257
+ /**
258
+ * Evaluate one condition against a raw field value. Equality/relational compares
259
+ * are coercion-based: when both sides parse as numbers they compare numerically
260
+ * (so a numeric-looking text id like `'007'` equals `'7'`), dates compare by day,
261
+ * else case-insensitive text. Assumes a complete condition (see
262
+ * `isConditionComplete`) — `applyFilters` drops incomplete ones first; calling this
263
+ * directly on e.g. a `not-in` with no `values` returns `true` (matches everything).
264
+ */
265
+ function evalCondition(raw, c) {
266
+ const operand = c.value ?? '';
267
+ switch (c.operator) {
268
+ case 'null':
269
+ return isEmpty(raw);
270
+ case 'not-null':
271
+ return !isEmpty(raw);
272
+ case 'eq':
273
+ return valuesEqual(raw, operand);
274
+ case 'ne':
275
+ return !valuesEqual(raw, operand);
276
+ case 'like':
277
+ return asText(raw).includes(operand.toLowerCase());
278
+ case 'not-like':
279
+ return !asText(raw).includes(operand.toLowerCase());
280
+ case 'begins-with':
281
+ return asText(raw).startsWith(operand.toLowerCase());
282
+ case 'ends-with':
283
+ return asText(raw).endsWith(operand.toLowerCase());
284
+ case 'in':
285
+ return (c.values ?? []).some((v) => valuesEqual(raw, v));
286
+ case 'not-in':
287
+ return !(c.values ?? []).some((v) => valuesEqual(raw, v));
288
+ case 'gt': {
289
+ const r = compare(raw, operand);
290
+ return r !== undefined && r > 0;
291
+ }
292
+ case 'ge':
293
+ case 'on-or-after': {
294
+ const r = compare(raw, operand);
295
+ return r !== undefined && r >= 0;
296
+ }
297
+ case 'lt': {
298
+ const r = compare(raw, operand);
299
+ return r !== undefined && r < 0;
300
+ }
301
+ case 'le':
302
+ case 'on-or-before': {
303
+ const r = compare(raw, operand);
304
+ return r !== undefined && r <= 0;
305
+ }
306
+ default:
307
+ return true;
308
+ }
309
+ }
310
+ function defaultGetValue(item, fieldName) {
311
+ return item && typeof item === 'object' ? item[fieldName] : undefined;
312
+ }
313
+ /**
314
+ * Filter `items` by `model` (client-side, in-memory). Incomplete conditions are
315
+ * ignored; with no complete condition the input is returned unchanged. `match`
316
+ * `'all'` ⇒ every condition must pass (AND); `'any'` ⇒ at least one (OR).
317
+ */
318
+ function applyFilters(items, model, getValue = defaultGetValue) {
319
+ if (!model)
320
+ return items;
321
+ const active = model.conditions.filter(isConditionComplete);
322
+ if (active.length === 0)
323
+ return items;
324
+ const matchAll = model.match !== 'any';
325
+ return items.filter((item) => matchAll
326
+ ? active.every((c) => evalCondition(getValue(item, c.fieldName), c))
327
+ : active.some((c) => evalCondition(getValue(item, c.fieldName), c)));
328
+ }
329
+
330
+ /** grid-kit renderer type → canonical library field type. */
331
+ const RENDERER_TO_FIELDTYPE = {
332
+ text: 'text',
333
+ link: 'link',
334
+ lookup: 'lookup',
335
+ optionset: 'optionset',
336
+ currency: 'currency',
337
+ number: 'number',
338
+ date: 'date',
339
+ datetime: 'datetime',
340
+ boolean: 'toggle',
341
+ toggle: 'toggle',
342
+ icon: 'icon',
343
+ progress: 'progressBar',
344
+ coloredCell: 'coloredCell',
345
+ rating: 'rating',
346
+ composite: 'composite',
347
+ // 'fileDrop' is grid-kit-native (FileDropCell) and never goes through the
348
+ // canonical-lib wrapper, so this entry is just to satisfy the exhaustive map.
349
+ fileDrop: 'text',
350
+ };
351
+ function rendererToFieldType(type) {
352
+ return RENDERER_TO_FIELDTYPE[type] ?? 'text';
353
+ }
354
+ /* eslint-disable @typescript-eslint/no-explicit-any */
355
+ /**
356
+ * Map a grid-kit column (renderer type + config bag) onto an IReusableColumn.
357
+ * `rendererConfig` keys accept both a short form (`max`, `color`) and the
358
+ * canonical IReusableColumn name (`progressMax`, `progressColor`).
359
+ */
360
+ function toReusableColumn(column) {
361
+ const c = (column.rendererConfig ?? {});
362
+ const base = {
363
+ key: column.key,
364
+ name: column.name,
365
+ fieldName: column.fieldName,
366
+ minWidth: column.minWidth ?? 100,
367
+ maxWidth: column.maxWidth,
368
+ isResizable: column.isResizable ?? true,
369
+ fieldType: rendererToFieldType(column.rendererType),
370
+ editable: !!column.editorType && column.editorType !== 'none' && !column.isLocked,
371
+ onLinkClick: column.onLinkClick,
372
+ validate: column.validate,
373
+ };
374
+ switch (column.rendererType) {
375
+ case 'currency':
376
+ base.currencyCode = c.currencyCode;
377
+ base.locale = c.locale;
378
+ break;
379
+ case 'number':
380
+ base.locale = c.locale;
381
+ break;
382
+ case 'progress':
383
+ base.progressMax = c.max ?? c.progressMax;
384
+ base.progressColor = c.color ?? c.progressColor;
385
+ base.showProgressLabel = c.showLabel ?? c.showProgressLabel;
386
+ break;
387
+ case 'coloredCell':
388
+ base.colorMap = c.colorMap;
389
+ base.getBackgroundColor = c.getBackgroundColor;
390
+ break;
391
+ case 'optionset':
392
+ base.optionSetOptions = c.options ?? c.optionSetOptions;
393
+ base.colorMap = c.colorMap;
394
+ base.optionSetMultiSelect = c.multiSelect ?? c.optionSetMultiSelect;
395
+ break;
396
+ case 'rating':
397
+ base.ratingMax = c.max ?? c.ratingMax;
398
+ base.ratingColor = c.color ?? c.ratingColor;
399
+ base.ratingAllowHalf = c.allowHalf ?? c.ratingAllowHalf;
400
+ base.ratingStyle = c.style ?? c.ratingStyle;
401
+ base.ratingFaceCount = c.faceCount ?? c.ratingFaceCount;
402
+ base.ratingUseSentimentColor = c.useSentimentColor ?? c.ratingUseSentimentColor;
403
+ break;
404
+ case 'composite':
405
+ base.compositeSlots = c.slots ?? c.compositeSlots;
406
+ base.compositeLayout = c.layout ?? c.compositeLayout;
407
+ base.compositeGap = c.gap ?? c.compositeGap;
408
+ break;
409
+ case 'icon':
410
+ base.iconName = c.iconName;
411
+ base.iconTitle = c.iconTitle;
412
+ base.onIconClick = c.onIconClick ?? column.onLinkClick;
413
+ break;
414
+ case 'toggle':
415
+ case 'boolean':
416
+ base.toggleOnText = c.onText ?? c.toggleOnText;
417
+ base.toggleOffText = c.offText ?? c.toggleOffText;
418
+ base.onToggleChange = c.onToggleChange;
419
+ break;
420
+ case 'lookup':
421
+ base.lookupDisplayField = c.displayField ?? c.lookupDisplayField;
422
+ base.lookupEntitySetName = c.entitySetName ?? c.lookupEntitySetName;
423
+ base.lookupSearchField = c.searchField ?? c.lookupSearchField;
424
+ base.lookupTargetEntity = c.targetEntity ?? c.lookupTargetEntity;
425
+ base.lookupPrimaryIdAttribute = c.primaryIdAttribute ?? c.lookupPrimaryIdAttribute;
426
+ base.lookupFilter = c.filter ?? c.lookupFilter;
427
+ base.lookupSelectFields = c.selectFields ?? c.lookupSelectFields;
428
+ base.lookupSearch = c.searchLookup ?? c.lookupSearch;
429
+ break;
430
+ case 'link':
431
+ base.openInNewTab = c.openInNewTab;
432
+ base.isRecordLink = c.isRecordLink;
433
+ base.recordEntityLogicalName = c.recordEntityLogicalName;
434
+ break;
435
+ }
436
+ if (c.formatValue && !base.formatValue)
437
+ base.formatValue = c.formatValue;
438
+ return base;
439
+ }
440
+ /* eslint-enable @typescript-eslint/no-explicit-any */
441
+
442
+ const noop = () => undefined;
443
+ /** Ensure the row exposes a `.key` (some canonical editable cells require it). */
444
+ function ensureKey(item, key) {
445
+ if (item && typeof item === 'object' && item.key !== undefined)
446
+ return item;
447
+ return { ...item, key };
448
+ }
449
+ /**
450
+ * Bridge the display value into the canonical renderer's `formatValue` path so
451
+ * read cells honor the right label instead of re-deriving from the raw code.
452
+ * Precedence: user-provided formatValue > host-supplied formatted value
453
+ * (Dataverse optionset/lookup label) > optionset value→label map > the
454
+ * computed fallback for numeric/date types (which also fixes numeric-string
455
+ * values that the canonical Currency/Number cells would otherwise render as '-').
456
+ * Mutates the freshly-mapped column (a new object per render — safe).
457
+ */
458
+ function applyDisplayValue(column, props) {
459
+ if (column.formatValue)
460
+ return; // explicit user formatter wins
461
+ if (props.suppliedFormattedValue) {
462
+ const s = props.suppliedFormattedValue;
463
+ column.formatValue = () => s;
464
+ return;
465
+ }
466
+ switch (column.fieldType) {
467
+ case 'optionset': {
468
+ const opts = column.optionSetOptions;
469
+ if (opts && opts.length) {
470
+ column.formatValue = (v) => {
471
+ const o = opts.find((opt) => String(opt.value) === String(v));
472
+ return o ? o.label : v !== null && v !== undefined && v !== '' ? String(v) : '-';
473
+ };
474
+ }
475
+ return;
476
+ }
477
+ case 'currency':
478
+ case 'number':
479
+ case 'date':
480
+ case 'datetime': {
481
+ const fv = props.formattedValue;
482
+ column.formatValue = () => fv || '-';
483
+ return;
484
+ }
485
+ default:
486
+ return;
487
+ }
488
+ }
489
+ /** Read-only wrapper: maps GridCellProps → the canonical CellRendererProps. */
490
+ function makeReadWrapper(Comp, displayName) {
491
+ const Wrapped = (props) => {
492
+ const column = toReusableColumn(props.column);
493
+ applyDisplayValue(column, props);
494
+ // Editable toggles render an interactive ToggleCell (no editable variant
495
+ // exists); wire its change back to the grid's onValueChange so edits persist.
496
+ if (column.editable && !column.onToggleChange && props.onValueChange) {
497
+ const onVC = props.onValueChange;
498
+ const original = props.value;
499
+ column.onToggleChange = (key, fieldName, v) => onVC(key, fieldName, v, original);
500
+ }
501
+ const cellProps = {
502
+ item: props.item,
503
+ column,
504
+ value: props.value,
505
+ itemKey: props.itemKey,
506
+ };
507
+ return jsxRuntime.jsx(Comp, { ...cellProps });
508
+ };
509
+ Wrapped.displayName = displayName;
510
+ return Wrapped;
511
+ }
512
+ /** Editable wrapper: threads edit-state onto the canonical editable cell props. */
513
+ function makeEditWrapper(Comp, displayName) {
514
+ const Wrapped = (props) => {
515
+ const column = toReusableColumn(props.column);
516
+ applyDisplayValue(column, props); // read-mode display of an editable cell
517
+ const item = ensureKey(props.item, props.itemKey);
518
+ const editProps = {
519
+ item,
520
+ column,
521
+ value: props.value,
522
+ itemKey: props.itemKey,
523
+ isEditMode: props.isEditing ?? false,
524
+ isDirty: props.isDirty ?? false,
525
+ editedValue: props.editedValue,
526
+ onValueChange: props.onValueChange ?? noop,
527
+ errorMessage: props.errorMessage,
528
+ onValidationError: props.onValidationError,
529
+ };
530
+ return jsxRuntime.jsx(Comp, { ...editProps });
531
+ };
532
+ Wrapped.displayName = displayName;
533
+ return Wrapped;
534
+ }
535
+
536
+ function normalizeExt(ext) {
537
+ const e = ext.toLowerCase();
538
+ return e.startsWith('.') ? e : `.${e}`;
539
+ }
540
+ /**
541
+ * Whether a file name matches an accept list of extensions (case-insensitive,
542
+ * leading dot optional). Empty/undefined accept = anything. Exported for reuse
543
+ * in custom drop targets.
544
+ */
545
+ function isFileAccepted(name, accept) {
546
+ if (!accept || accept.length === 0)
547
+ return true;
548
+ const lower = name.toLowerCase();
549
+ return accept.some((ext) => lower.endsWith(normalizeExt(ext)));
550
+ }
551
+ /**
552
+ * HTML5 file-drop behavior for a grid cell or row. Tracks drag depth (so the
553
+ * highlight doesn't flicker over child elements), validates the dropped files
554
+ * against `accept`, and exposes the drag-over state + the handler props to spread
555
+ * onto a wrapper element. Pure HTML5 — no drag library.
556
+ */
557
+ function useFileDrop(opts) {
558
+ const { onDrop, accept, multiple = false, disabled, onReject } = opts;
559
+ const [isDragOver, setIsDragOver] = React.useState(false);
560
+ const depth = React.useRef(0);
561
+ const onDragEnter = React.useCallback((e) => {
562
+ if (disabled)
563
+ return;
564
+ e.preventDefault();
565
+ e.stopPropagation();
566
+ depth.current += 1;
567
+ setIsDragOver(true);
568
+ }, [disabled]);
569
+ const onDragOver = React.useCallback((e) => {
570
+ if (disabled)
571
+ return;
572
+ e.preventDefault();
573
+ e.stopPropagation();
574
+ e.dataTransfer.dropEffect = 'copy';
575
+ }, [disabled]);
576
+ const onDragLeave = React.useCallback((e) => {
577
+ if (disabled)
578
+ return;
579
+ e.preventDefault();
580
+ e.stopPropagation();
581
+ depth.current = Math.max(0, depth.current - 1);
582
+ if (depth.current === 0)
583
+ setIsDragOver(false);
584
+ }, [disabled]);
585
+ const onDropHandler = React.useCallback((e) => {
586
+ if (disabled)
587
+ return;
588
+ e.preventDefault();
589
+ e.stopPropagation();
590
+ depth.current = 0;
591
+ setIsDragOver(false);
592
+ const all = Array.from(e.dataTransfer.files ?? []);
593
+ if (all.length === 0)
594
+ return;
595
+ const valid = all.filter((f) => isFileAccepted(f.name, accept));
596
+ if (valid.length === 0) {
597
+ onReject?.(accept && accept.length ? `Only ${accept.join(', ')} files are allowed.` : 'File not allowed.');
598
+ return;
599
+ }
600
+ onDrop(multiple ? valid : [valid[0]]);
601
+ }, [disabled, accept, multiple, onDrop, onReject]);
602
+ return {
603
+ isDragOver,
604
+ dragProps: { onDragEnter, onDragOver, onDragLeave, onDrop: onDropHandler },
605
+ };
606
+ }
607
+
608
+ /**
609
+ * Wraps any content as an HTML5 file-drop target with a drag-over highlight.
610
+ * Use inside a custom cell renderer (or directly) to accept files dropped from
611
+ * the OS onto a cell. The `fileDrop` registry cell type uses this internally.
612
+ */
613
+ const DroppableCell = ({ children, className, style, variant = 'highlight', ...opts }) => {
614
+ const { isDragOver, dragProps } = useFileDrop(opts);
615
+ const active = isDragOver && !opts.disabled;
616
+ return (jsxRuntime.jsx("div", { ...dragProps, className: className, style: {
617
+ display: 'flex',
618
+ alignItems: 'center',
619
+ height: '100%',
620
+ width: '100%',
621
+ borderRadius: 4,
622
+ outline: active && variant === 'highlight' ? '2px solid #0078d4' : '2px solid transparent',
623
+ background: active ? 'rgba(0,120,212,0.08)' : 'transparent',
624
+ boxShadow: active && variant === 'highlight' ? '0 0 6px 1px rgba(0,120,212,0.4)' : 'none',
625
+ transition: 'background 80ms ease-out, box-shadow 80ms ease-out, outline-color 80ms ease-out',
626
+ ...style,
627
+ }, children: children }));
628
+ };
629
+
630
+ /**
631
+ * Grid-kit-native cell renderer for `rendererType: 'fileDrop'`. Renders the cell
632
+ * as a droppable target; on drop it calls `rendererConfig.onDrop(files, item,
633
+ * fieldName)`. Unlike the other built-ins it does NOT wrap a canonical renderer.
634
+ */
635
+ const FileDropCell = (props) => {
636
+ const cfg = (props.column.rendererConfig ?? {});
637
+ const field = props.column.fieldName;
638
+ // Explicit label wins; else show the cell's value; else the drop hint.
639
+ // (formattedValue is '' — not undefined — when the value is empty, so use `||`.)
640
+ const text = cfg.label ?? (props.formattedValue || 'Drop file');
641
+ return (jsxRuntime.jsx(DroppableCell, { accept: cfg.accept, multiple: cfg.multiple, onDrop: (files) => cfg.onDrop?.(files, props.item, field), onReject: cfg.onReject ? (reason) => cfg.onReject(reason, props.item, field) : undefined, style: { justifyContent: 'center', cursor: 'copy', fontSize: 13, color: '#605e5c' }, children: jsxRuntime.jsx("span", { children: text }) }));
642
+ };
643
+
644
+ /**
645
+ * The cell-renderer registry — the single source of truth that both grid hosts
646
+ * (DetailsList adapter + GridCustomizer overrides) consume. Maps each
647
+ * `CellRendererType` to a read (and optional edit) wrapper over the canonical
648
+ * @khester/dynamics-cell-renderers components.
649
+ */
650
+ function buildDefaults() {
651
+ const m = new Map();
652
+ m.set('text', {
653
+ read: makeReadWrapper(dynamicsCellRenderers.TextCell, 'GridKit(text)'),
654
+ edit: makeEditWrapper(dynamicsCellRenderers.EditableTextCell, 'GridKit(edit:text)'),
655
+ });
656
+ m.set('link', { read: makeReadWrapper(dynamicsCellRenderers.LinkCell, 'GridKit(link)') });
657
+ // Editable lookup is fetch-free: the consumer supplies `searchLookup(term)` on
658
+ // the column's rendererConfig (mapped to `column.lookupSearch`), so the cell
659
+ // never needs an apiService. A column opts in with editorType:'lookup'.
660
+ m.set('lookup', {
661
+ read: makeReadWrapper(dynamicsCellRenderers.LookupCell, 'GridKit(lookup)'),
662
+ edit: makeEditWrapper(dynamicsCellRenderers.EditableLookupCell, 'GridKit(edit:lookup)'),
663
+ });
664
+ m.set('optionset', {
665
+ read: makeReadWrapper(dynamicsCellRenderers.OptionSetCell, 'GridKit(optionset)'),
666
+ edit: makeEditWrapper(dynamicsCellRenderers.EditableOptionSetCell, 'GridKit(edit:optionset)'),
667
+ });
668
+ m.set('currency', {
669
+ read: makeReadWrapper(dynamicsCellRenderers.CurrencyCell, 'GridKit(currency)'),
670
+ edit: makeEditWrapper(dynamicsCellRenderers.EditableNumberCell, 'GridKit(edit:currency)'),
671
+ });
672
+ m.set('number', {
673
+ read: makeReadWrapper(dynamicsCellRenderers.NumberCell, 'GridKit(number)'),
674
+ edit: makeEditWrapper(dynamicsCellRenderers.EditableNumberCell, 'GridKit(edit:number)'),
675
+ });
676
+ m.set('date', {
677
+ read: makeReadWrapper(dynamicsCellRenderers.DateCell, 'GridKit(date)'),
678
+ edit: makeEditWrapper(dynamicsCellRenderers.EditableDateCell, 'GridKit(edit:date)'),
679
+ });
680
+ m.set('datetime', {
681
+ read: makeReadWrapper(dynamicsCellRenderers.DateCell, 'GridKit(datetime)'),
682
+ edit: makeEditWrapper(dynamicsCellRenderers.EditableDateCell, 'GridKit(edit:datetime)'),
683
+ });
684
+ m.set('boolean', { read: makeReadWrapper(dynamicsCellRenderers.ToggleCell, 'GridKit(boolean)') });
685
+ m.set('toggle', { read: makeReadWrapper(dynamicsCellRenderers.ToggleCell, 'GridKit(toggle)') });
686
+ m.set('icon', { read: makeReadWrapper(dynamicsCellRenderers.IconCell, 'GridKit(icon)') });
687
+ m.set('progress', { read: makeReadWrapper(dynamicsCellRenderers.ProgressBarCell, 'GridKit(progress)') });
688
+ m.set('coloredCell', { read: makeReadWrapper(dynamicsCellRenderers.ColoredCell, 'GridKit(coloredCell)') });
689
+ // Rating is click-to-set editable (stars or sentiment emoji). Opt in with
690
+ // editorType:'number'; rendererConfig.style:'emoji' switches to faces.
691
+ m.set('rating', {
692
+ read: makeReadWrapper(dynamicsCellRenderers.RatingCell, 'GridKit(rating)'),
693
+ edit: makeEditWrapper(dynamicsCellRenderers.EditableRatingCell, 'GridKit(edit:rating)'),
694
+ });
695
+ m.set('composite', { read: makeReadWrapper(dynamicsCellRenderers.CompositeCell, 'GridKit(composite)') });
696
+ // grid-kit-native (not a canonical-lib wrapper): a per-cell file-drop target.
697
+ m.set('fileDrop', { read: FileDropCell });
698
+ return m;
699
+ }
700
+ /**
701
+ * Create a cell-renderer registry with all built-ins, optionally overridden.
702
+ * Consumers can also call `registry.register(type, { read, edit })` later to
703
+ * override a built-in or add a brand-new cell type.
704
+ */
705
+ function createCellRegistry(overrides) {
706
+ const map = buildDefaults();
707
+ if (overrides) {
708
+ for (const [key, reg] of Object.entries(overrides))
709
+ map.set(key, reg);
710
+ }
711
+ const registry = {
712
+ get: (type) => map.get(type),
713
+ register: (type, reg) => {
714
+ map.set(type, reg);
715
+ },
716
+ resolve: (column, editable) => {
717
+ const reg = map.get(column.rendererType);
718
+ if (!reg) {
719
+ // Unknown renderer type → safe text fallback (no silent blank cells).
720
+ // Read 'text' live so a consumer's register('text', …) override is honored.
721
+ // eslint-disable-next-line no-console
722
+ if (typeof console !== 'undefined') {
723
+ console.warn(`[grid-kit] No renderer registered for "${column.rendererType}"; using text.`);
724
+ }
725
+ return map.get('text').read;
726
+ }
727
+ const canEdit = editable &&
728
+ !!column.editorType &&
729
+ column.editorType !== 'none' &&
730
+ !column.isLocked &&
731
+ !!reg.edit;
732
+ return (canEdit ? reg.edit : reg.read);
733
+ },
734
+ };
735
+ return registry;
736
+ }
737
+
738
+ /**
739
+ * `Xrm.Navigation.navigateTo` wrapper — open a record form, web resource, or
740
+ * custom page from a grid. Loose-typed (NO `@types/xrm` dependency). On localhost
741
+ * or when Xrm is unavailable (Storybook / tests / non-model-driven host) it logs a
742
+ * no-op and resolves, so consumers can wire navigation unconditionally.
743
+ *
744
+ * Lives OUTSIDE src/core/ on purpose: it touches `window`/`Xrm` (a side-effectful
745
+ * host concern), whereas core/ is v9-portable pure logic.
746
+ */
747
+ function getXrm() {
748
+ if (typeof window === 'undefined')
749
+ return undefined;
750
+ return window.Xrm;
751
+ }
752
+ /** Mirror the ServiceFactory localhost check used across the kit. */
753
+ function isLocalhost() {
754
+ if (typeof window === 'undefined' || !window.location)
755
+ return false;
756
+ const h = window.location.hostname;
757
+ return h === 'localhost' || h === '127.0.0.1';
758
+ }
759
+ /** Map a `NavTarget` → an Xrm `pageInput` object. */
760
+ function toPageInput(target) {
761
+ switch (target.pageType) {
762
+ case 'entityrecord':
763
+ return { pageType: 'entityrecord', entityName: target.entityName, entityId: target.entityId };
764
+ case 'webresource':
765
+ return { pageType: 'webresource', webresourceName: target.webresourceName, data: target.data };
766
+ case 'custom':
767
+ return { pageType: 'custom', name: target.name, entityName: target.entityName, recordId: target.entityId };
768
+ default:
769
+ return {};
770
+ }
771
+ }
772
+ /**
773
+ * Navigate to `target` via `Xrm.Navigation.navigateTo`. No-ops (console.log) on
774
+ * localhost or when Xrm is unavailable. Always resolves (never throws) so a
775
+ * cell-click handler can call it without a try/catch.
776
+ *
777
+ * The options the platform receives are the explicit `navigationOptions` arg if
778
+ * given, else `target.navigationOptions` — so callers that only have a
779
+ * `NavTarget` (cell-click synthesis, the "Open" command) still get dialog/size
780
+ * control by attaching options to the target.
781
+ */
782
+ async function navigateToTarget(target, navigationOptions) {
783
+ const xrm = getXrm();
784
+ const options = navigationOptions ?? target.navigationOptions;
785
+ if (isLocalhost() || !xrm?.Navigation?.navigateTo) {
786
+ // eslint-disable-next-line no-console
787
+ if (typeof console !== 'undefined') {
788
+ console.log('[grid-kit] navigateTo (no-op — localhost or no Xrm):', target, options);
789
+ }
790
+ return;
791
+ }
792
+ try {
793
+ await xrm.Navigation.navigateTo(toPageInput(target), options);
794
+ }
795
+ catch (err) {
796
+ // eslint-disable-next-line no-console
797
+ if (typeof console !== 'undefined')
798
+ console.error('[grid-kit] navigateTo failed:', err);
799
+ }
800
+ }
801
+
802
+ /* eslint-disable @typescript-eslint/no-explicit-any */
803
+ /** Read a row field value. */
804
+ function getFieldValue(item, fieldName) {
805
+ return item && typeof item === 'object' ? item[fieldName] : undefined;
806
+ }
807
+ /**
808
+ * Host-supplied formatted value (Dataverse `@OData…FormattedValue` sibling key),
809
+ * if present. Adapters prefer this over recomputing.
810
+ */
811
+ function getSuppliedFormattedValue(item, fieldName) {
812
+ if (!item || typeof item !== 'object')
813
+ return undefined;
814
+ const fv = item[`${fieldName}@OData.Community.Display.V1.FormattedValue`];
815
+ return fv !== undefined && fv !== null ? String(fv) : undefined;
816
+ }
817
+ // Stable synthetic keys for rows that lack a `key` — keeps edit-state per-row
818
+ // instead of collapsing every keyless row to '' (which caused cross-row edit
819
+ // bleed). Same object identity ⇒ same key across renders.
820
+ const SYNTH_KEYS = new WeakMap();
821
+ let synthCounter = 0;
822
+ let warnedMissingKey = false;
823
+ function defaultGetKey(item) {
824
+ const k = item?.key;
825
+ if (k !== undefined && k !== null && k !== '')
826
+ return String(k);
827
+ if (item && typeof item === 'object') {
828
+ let s = SYNTH_KEYS.get(item);
829
+ if (!s) {
830
+ s = '__gridkit_row_' + ++synthCounter;
831
+ SYNTH_KEYS.set(item, s);
832
+ if (!warnedMissingKey && typeof console !== 'undefined') {
833
+ warnedMissingKey = true;
834
+ console.warn('[grid-kit] Rows are missing a `key`; using synthetic keys. Pass `getKey` or give each row a stable `key` for reliable selection/editing.');
835
+ }
836
+ }
837
+ return s;
838
+ }
839
+ return String(k ?? '');
840
+ }
841
+ function rendererFormatConfig(column) {
842
+ const c = (column.rendererConfig ?? {});
843
+ return { currencyCode: c.currencyCode, locale: c.locale };
844
+ }
845
+ /** Build the normalized cell contract for one (column, row) pair. */
846
+ function buildCellProps(column, item, ctx) {
847
+ const getKey = ctx.getKey ?? defaultGetKey;
848
+ const itemKey = getKey(item);
849
+ const value = getFieldValue(item, column.fieldName);
850
+ // Navigation: for link/lookup cells with no explicit onLinkClick, synthesize one
851
+ // from the grid-level `navigateTo` resolver. Done here (the seam both host
852
+ // adapters share) so DetailsList + GridCustomizer get nav parity for free.
853
+ let effectiveColumn = column;
854
+ if (ctx.navigateTo &&
855
+ !column.onLinkClick &&
856
+ (column.rendererType === 'link' || column.rendererType === 'lookup')) {
857
+ const resolve = ctx.navigateTo;
858
+ effectiveColumn = {
859
+ ...column,
860
+ onLinkClick: (it) => {
861
+ const target = resolve(it);
862
+ if (target)
863
+ void navigateToTarget(target);
864
+ },
865
+ };
866
+ }
867
+ const supplied = ctx.formattedValueAccessor
868
+ ? ctx.formattedValueAccessor(item, column)
869
+ : getSuppliedFormattedValue(item, column.fieldName);
870
+ const formattedValue = supplied ?? computeFormattedValue(value, column.rendererType, rendererFormatConfig(column));
871
+ return {
872
+ value,
873
+ formattedValue,
874
+ suppliedFormattedValue: supplied,
875
+ item,
876
+ itemKey,
877
+ column: effectiveColumn,
878
+ isEditing: ctx.isEditing?.(itemKey, column.fieldName) ?? false,
879
+ isDirty: ctx.isDirty?.(itemKey, column.fieldName) ?? false,
880
+ editedValue: ctx.getEditedValue?.(itemKey, column.fieldName),
881
+ errorMessage: ctx.getError?.(itemKey, column.fieldName),
882
+ onValueChange: ctx.onValueChange,
883
+ onValidationError: ctx.onValidationError,
884
+ onEditStart: ctx.onEditStart ? () => ctx.onEditStart(itemKey, column.fieldName) : undefined,
885
+ onEditEnd: ctx.onEditEnd ? () => ctx.onEditEnd(itemKey, column.fieldName) : undefined,
886
+ };
887
+ }
888
+ /* eslint-enable @typescript-eslint/no-explicit-any */
889
+
890
+ const FOCUSABLE = 'input, textarea, select, button, [tabindex]:not([tabindex="-1"])';
891
+ /** True when focus moved into a Fluent v8 portal (Dropdown/Calendar/Lookup
892
+ * option lists render in a Layer/Callout OUTSIDE the cell's DOM subtree). */
893
+ function isInFluentPortal(node) {
894
+ const el = node;
895
+ return !!el?.closest?.('.ms-Layer, .ms-Callout');
896
+ }
897
+ /**
898
+ * Wraps an editable cell that is currently in edit mode. On mount (i.e. when the
899
+ * cell enters edit mode) it focuses the first focusable control, and it exits
900
+ * edit mode on Enter/Escape or on blur to outside — but NOT when focus moves into
901
+ * a Fluent portal (otherwise picking a Dropdown/Calendar/Lookup option would
902
+ * close the editor before the value commits).
903
+ */
904
+ const EditCellWrapper = ({ onExit, children }) => {
905
+ const ref = React.useRef(null);
906
+ React.useEffect(() => {
907
+ ref.current?.querySelector(FOCUSABLE)?.focus();
908
+ }, []);
909
+ return (jsxRuntime.jsx("div", { ref: ref, style: { height: '100%' }, onKeyDown: (e) => {
910
+ if (e.key === 'Enter' || e.key === 'Escape')
911
+ onExit();
912
+ }, onBlur: (e) => {
913
+ const next = e.relatedTarget;
914
+ if (isInFluentPortal(next))
915
+ return; // selecting a portal option, not exiting
916
+ if (!e.currentTarget.contains(next))
917
+ onExit();
918
+ }, children: children }));
919
+ };
920
+
921
+ /**
922
+ * Wraps a cell in a hover-triggered Fluent v8 `Callout` showing preview content
923
+ * (e.g. a lookup's related-record fields). Consumed by `toDetailsListColumns` for
924
+ * any `ColumnDef` that declares `calloutContent` — the host wiring that the
925
+ * `ColumnDef.calloutContent`/`dismissMode`/`timeoutDuration` contract was always
926
+ * meant to drive (it was previously declared-but-unconsumed).
927
+ */
928
+ function CalloutCell({ children, renderContent, dismissMode = 'mouseleave', timeoutDuration = 200, }) {
929
+ const [open, setOpen] = React.useState(false);
930
+ const anchorRef = React.useRef(null);
931
+ const timer = React.useRef(null);
932
+ const clearTimer = React.useCallback(() => {
933
+ if (timer.current) {
934
+ clearTimeout(timer.current);
935
+ timer.current = null;
936
+ }
937
+ }, []);
938
+ // Clear any pending dismiss timer on unmount (cells unmount on scroll in a
939
+ // virtualized DetailsList) so a stale callback can't setState after unmount.
940
+ React.useEffect(() => clearTimer, [clearTimer]);
941
+ const onEnter = React.useCallback(() => {
942
+ clearTimer();
943
+ setOpen(true);
944
+ }, [clearTimer]);
945
+ const onLeave = React.useCallback(() => {
946
+ if (dismissMode === 'timeout') {
947
+ clearTimer();
948
+ timer.current = setTimeout(() => setOpen(false), timeoutDuration);
949
+ }
950
+ else {
951
+ setOpen(false);
952
+ }
953
+ }, [clearTimer, dismissMode, timeoutDuration]);
954
+ // Keep the callout open while the pointer is over its content (timeout mode only).
955
+ const calloutHover = dismissMode === 'timeout' ? { onMouseEnter: clearTimer, onMouseLeave: onLeave } : {};
956
+ return (jsxRuntime.jsxs("span", { ref: anchorRef, onMouseEnter: onEnter, onMouseLeave: onLeave, style: { display: 'inline-block', width: '100%' }, children: [children, open && anchorRef.current && (jsxRuntime.jsx(react.Callout, { target: anchorRef.current, directionalHint: react.DirectionalHint.bottomLeftEdge, isBeakVisible: false, gapSpace: 0, onDismiss: () => setOpen(false), ...calloutHover, children: jsxRuntime.jsx("div", { style: { padding: 12, maxWidth: 320 }, children: renderContent() }) }))] }));
957
+ }
958
+
959
+ /** Wrap a rendered cell in a hover-preview callout when the column declares `calloutContent`. */
960
+ function withCallout(column, item, rendered) {
961
+ if (!column.calloutContent)
962
+ return rendered;
963
+ return (jsxRuntime.jsx(CalloutCell, { renderContent: () => column.calloutContent(item), dismissMode: column.dismissMode, timeoutDuration: column.timeoutDuration, children: rendered }));
964
+ }
965
+ function toDetailsListColumns(columns, ctx) {
966
+ return columns.map((column) => {
967
+ const sortable = column.isSortable && !!ctx.onSortChange;
968
+ return {
969
+ key: column.key,
970
+ name: column.name,
971
+ fieldName: column.fieldName,
972
+ minWidth: column.minWidth ?? 100,
973
+ maxWidth: column.maxWidth,
974
+ isResizable: column.isResizable ?? true,
975
+ isSorted: ctx.sort?.fieldName === column.fieldName,
976
+ isSortedDescending: ctx.sort?.fieldName === column.fieldName && ctx.sort?.direction === 'desc',
977
+ columnActionsMode: sortable ? undefined : 0, // 0 = disabled when not sortable
978
+ onColumnClick: sortable
979
+ ? () => {
980
+ const nextDir = ctx.sort?.fieldName === column.fieldName && ctx.sort?.direction === 'asc' ? 'desc' : 'asc';
981
+ ctx.onSortChange(column.fieldName, nextDir);
982
+ }
983
+ : undefined,
984
+ onRender: (item) => {
985
+ if (item === undefined || item === null)
986
+ return null;
987
+ // Escape hatch: fully custom render bypasses the registry.
988
+ if (column.onRender)
989
+ return column.onRender(item);
990
+ const props = buildCellProps(column, item, ctx);
991
+ const Renderer = ctx.registry.resolve(column, !!ctx.editable);
992
+ const cell = jsxRuntime.jsx(Renderer, { ...props });
993
+ // Per-cell click-to-edit affordance: editable columns enter edit mode on
994
+ // click (one cell at a time) and exit on Enter/Escape/blur. Only cell
995
+ // types that actually have an edit renderer get the affordance — toggle/
996
+ // boolean are already interactive in read mode and ignore edit state.
997
+ // With editTrigger 'always' (or non-editable columns) the cell renders plainly.
998
+ const isEditableCell = !!ctx.editable &&
999
+ !!column.editorType &&
1000
+ column.editorType !== 'none' &&
1001
+ !column.isLocked &&
1002
+ !!ctx.registry.get(column.rendererType)?.edit &&
1003
+ (ctx.editTrigger ?? 'click') === 'click';
1004
+ // Resolve the cell to its rendered form (plain / editing / click-to-edit),
1005
+ // then apply the hover-preview callout once so it composes with any path.
1006
+ let rendered;
1007
+ if (!isEditableCell) {
1008
+ rendered = cell;
1009
+ }
1010
+ else if (props.isEditing) {
1011
+ rendered = jsxRuntime.jsx(EditCellWrapper, { onExit: () => props.onEditEnd?.(), children: cell });
1012
+ }
1013
+ else {
1014
+ rendered = (jsxRuntime.jsx("div", { role: "button", tabIndex: 0, title: "Click to edit", style: { height: '100%', cursor: 'text', display: 'flex', alignItems: 'center' }, onClick: () => props.onEditStart?.(), onKeyDown: (e) => {
1015
+ if (e.key === 'Enter' || e.key === 'F2')
1016
+ props.onEditStart?.();
1017
+ }, children: cell }));
1018
+ }
1019
+ return withCallout(column, item, rendered);
1020
+ },
1021
+ };
1022
+ });
1023
+ }
1024
+
1025
+ function toGridCustomizerOverrides(columns, registry, options = {}) {
1026
+ const overrides = {};
1027
+ for (const column of columns) {
1028
+ const Override = (gc) => {
1029
+ const value = gc.value;
1030
+ // Trust the platform's formatted value; only fall back when absent.
1031
+ const formattedValue = gc.formattedValue ??
1032
+ computeFormattedValue(value, column.rendererType, {
1033
+ currencyCode: column.rendererConfig?.currencyCode,
1034
+ locale: column.rendererConfig?.locale,
1035
+ });
1036
+ const props = {
1037
+ value,
1038
+ formattedValue,
1039
+ suppliedFormattedValue: gc.formattedValue,
1040
+ item: gc.rowData,
1041
+ itemKey: gc.entityId,
1042
+ column,
1043
+ isEditing: gc.isEditing,
1044
+ // The platform GC host owns dirty/validation state, so these are not
1045
+ // available on this path (unlike the DetailsList host's useEditState).
1046
+ isDirty: undefined,
1047
+ editedValue: undefined,
1048
+ errorMessage: undefined,
1049
+ onValidationError: undefined,
1050
+ // The GC host's onChange takes only the new value, so fieldName +
1051
+ // originalValue from grid-kit's 4-arg signature are intentionally dropped.
1052
+ onValueChange: gc.onChange ? (_rk, _fn, v) => gc.onChange(v) : undefined,
1053
+ onEditStart: gc.onEditStart,
1054
+ onEditEnd: gc.onEditEnd,
1055
+ };
1056
+ const Renderer = registry.resolve(column, !!gc.isEditing);
1057
+ return jsxRuntime.jsx(Renderer, { ...props });
1058
+ };
1059
+ Override.displayName = `GcOverride(${column.fieldName})`;
1060
+ overrides[column.fieldName] = Override;
1061
+ if (options.keyByDataType) {
1062
+ overrides[column.rendererType] = overrides[column.rendererType] ?? Override;
1063
+ }
1064
+ }
1065
+ return overrides;
1066
+ }
1067
+
1068
+ /**
1069
+ * Map form-runtime's narrow `rendererType` + separate `dataType` onto grid-kit's
1070
+ * 15-literal `CellRendererType`. form-runtime keeps the two separate (a column
1071
+ * can be `rendererType:'text'` + `dataType:'numeric'`), so a naive 1:1 map would
1072
+ * render numbers / links / toggles as plain text.
1073
+ */
1074
+ function mapFormRuntimeRendererType(rendererType, dataType) {
1075
+ switch (rendererType) {
1076
+ case 'lookup':
1077
+ return 'lookup';
1078
+ case 'optionset':
1079
+ return 'optionset';
1080
+ case 'currency':
1081
+ return 'currency';
1082
+ case 'progress':
1083
+ return 'progress';
1084
+ case 'date':
1085
+ return dataType === 'datetime' ? 'datetime' : 'date';
1086
+ case 'datetime':
1087
+ return 'datetime';
1088
+ case 'boolean':
1089
+ return 'boolean';
1090
+ case 'rating':
1091
+ return 'rating';
1092
+ case 'composite':
1093
+ return 'composite';
1094
+ case 'text':
1095
+ default:
1096
+ switch (dataType) {
1097
+ case 'numeric':
1098
+ return 'number';
1099
+ case 'currency':
1100
+ return 'currency';
1101
+ case 'date':
1102
+ return 'date';
1103
+ case 'datetime':
1104
+ return 'datetime';
1105
+ case 'boolean':
1106
+ return 'boolean';
1107
+ case 'url':
1108
+ case 'email':
1109
+ return 'link';
1110
+ // No standalone image renderer — show the URL as text (use a composite
1111
+ // 'image' slot for an actual thumbnail).
1112
+ case 'image':
1113
+ return 'text';
1114
+ default:
1115
+ return 'text';
1116
+ }
1117
+ }
1118
+ }
1119
+ function mapEditorType(editorType) {
1120
+ switch (editorType) {
1121
+ case 'text':
1122
+ return 'text';
1123
+ case 'number':
1124
+ return 'number';
1125
+ case 'date':
1126
+ return 'date';
1127
+ case 'dropdown':
1128
+ return 'dropdown';
1129
+ case 'toggle':
1130
+ return 'toggle';
1131
+ case 'lookup':
1132
+ return 'lookup';
1133
+ case 'none':
1134
+ return 'none';
1135
+ default:
1136
+ return undefined;
1137
+ }
1138
+ }
1139
+ function fromGridCustomizerColumn(col) {
1140
+ return {
1141
+ key: col.id ?? col.fieldName,
1142
+ fieldName: col.fieldName,
1143
+ name: col.displayName,
1144
+ rendererType: mapFormRuntimeRendererType(col.rendererType, col.dataType),
1145
+ rendererConfig: col.rendererConfig,
1146
+ editorType: mapEditorType(col.editorType),
1147
+ isLocked: col.isLocked,
1148
+ width: col.width,
1149
+ minWidth: col.minWidth,
1150
+ maxWidth: col.maxWidth,
1151
+ isResizable: col.isResizable,
1152
+ isSortable: col.isSortable,
1153
+ isFilterable: col.isFilterable,
1154
+ aggregate: col.aggregateFunction,
1155
+ };
1156
+ }
1157
+ function fromGridCustomizerDefinition(def) {
1158
+ return (def.columns ?? []).map((c) => fromGridCustomizerColumn(c));
1159
+ }
1160
+
1161
+ /** Default nested display mode by parent grid type (mirrors form-runtime's v11→v12 migration). */
1162
+ function defaultNestedMode(gridType) {
1163
+ if (gridType === 'focused-view')
1164
+ return 'detail-pane';
1165
+ if (gridType === 'card-list')
1166
+ return 'side-panel';
1167
+ return 'inline';
1168
+ }
1169
+ /** Build a FocusedViewConfig from a definition's focused-view settings (config or legacy fields). */
1170
+ function toFocusedViewConfig(fv) {
1171
+ if (fv?.config)
1172
+ return fv.config;
1173
+ const rows = [
1174
+ fv?.primaryNameField
1175
+ ? { id: 'primary', primaryField: { fieldName: fv.primaryNameField, label: 'Name' } }
1176
+ : undefined,
1177
+ ...(fv?.summaryFields ?? []).map((s, i) => ({
1178
+ id: `s${i}`,
1179
+ primaryField: { fieldName: s.fieldName, label: s.label },
1180
+ })),
1181
+ ].filter(Boolean);
1182
+ return { rows: rows.length ? rows : [{ id: 'primary', primaryField: { fieldName: 'name', label: 'Name' } }] };
1183
+ }
1184
+ /**
1185
+ * Resolve a grid definition into the host + props grid-kit should render.
1186
+ * `nestedGridId` (with `opts.resolveNested`) wraps the result in a NestedGrid;
1187
+ * otherwise the `gridType` selects card / focused-view / readonly / data.
1188
+ */
1189
+ function resolveGridFromDefinition(def, items, opts = {}) {
1190
+ const columns = fromGridCustomizerDefinition(def);
1191
+ const base = {
1192
+ items,
1193
+ columns,
1194
+ editable: def.gridType === 'editable' || (!!def.isEditable && def.gridType !== 'readonly'),
1195
+ selectionMode: def.gridType === 'readonly' ? 'none' : def.selectionMode ?? 'none',
1196
+ alternateRowColors: def.alternateRowColors,
1197
+ };
1198
+ if (def.nestedGridId && opts.resolveNested) {
1199
+ const { childColumns, getChildren } = opts.resolveNested(def);
1200
+ const nested = {
1201
+ mode: def.nestedDisplay?.mode ?? defaultNestedMode(def.gridType),
1202
+ childColumns,
1203
+ getChildren,
1204
+ panelSize: def.nestedDisplay?.panelSize,
1205
+ hoverDelay: def.nestedDisplay?.hoverDelay,
1206
+ calloutMaxRows: def.nestedDisplay?.calloutMaxRows,
1207
+ triggerLabel: def.nestedDisplay?.triggerLabel,
1208
+ triggerIcon: def.nestedDisplay?.triggerIcon,
1209
+ childSelectionMode: def.nestedDisplay?.childSelectionMode,
1210
+ childSelectionGating: def.nestedSelectionMode,
1211
+ childEditable: def.nestedDisplay?.childEditable,
1212
+ childEditTrigger: def.nestedDisplay?.childEditTrigger,
1213
+ };
1214
+ // A focused-view parent with a detail-pane child is a master-detail layout.
1215
+ if (def.gridType === 'focused-view') {
1216
+ return { host: 'focused-view', props: { ...base, focusedView: toFocusedViewConfig(def.focusedView), nested } };
1217
+ }
1218
+ return { host: 'nested', props: { ...base, nested } };
1219
+ }
1220
+ switch (def.gridType) {
1221
+ case 'card-list':
1222
+ return {
1223
+ host: 'card',
1224
+ props: {
1225
+ ...base,
1226
+ card: {
1227
+ cardsPerRow: def.cardView?.cardsPerRow,
1228
+ cardHeight: def.cardView?.cardHeight,
1229
+ titleField: def.cardView?.titleField,
1230
+ subtitleField: def.cardView?.subtitleField,
1231
+ imageField: def.cardView?.imageField,
1232
+ },
1233
+ },
1234
+ };
1235
+ case 'focused-view':
1236
+ return { host: 'focused-view', props: { ...base, focusedView: toFocusedViewConfig(def.focusedView) } };
1237
+ case 'readonly':
1238
+ return { host: 'readonly', props: base };
1239
+ case 'editable':
1240
+ case 'standard':
1241
+ default:
1242
+ return { host: 'data', props: base };
1243
+ }
1244
+ }
1245
+
1246
+ /**
1247
+ * Grid export helper — a thin wrapper over `@khester/dynamics-utils` export
1248
+ * utilities that maps grid-kit `ColumnDef`s onto the export `ExportColumn` shape.
1249
+ *
1250
+ * Kept OUT of `src/core/` (which is for v9-portable pure logic): this triggers a
1251
+ * browser download (`document` / `URL.createObjectURL`), a side-effectful host
1252
+ * concern.
1253
+ */
1254
+ /**
1255
+ * Map grid-kit columns → dynamics-utils `ExportColumn[]`.
1256
+ * `ExportColumn.key` is the DATA field (so it reads `item[fieldName]`), and the
1257
+ * export formatter expects a canonical field type — reuse `rendererToFieldType`
1258
+ * rather than the grid-kit `rendererType` vocabulary. The grid-kit-native
1259
+ * `fileDrop` column has no exportable value and is skipped.
1260
+ */
1261
+ function toExportColumns(columns) {
1262
+ return columns
1263
+ .filter((c) => c.rendererType !== 'fileDrop')
1264
+ .map((c) => ({
1265
+ key: c.fieldName,
1266
+ name: c.name,
1267
+ fieldType: rendererToFieldType(c.rendererType),
1268
+ }));
1269
+ }
1270
+ /**
1271
+ * Export grid rows to a CSV or JSON file (CSV = formatted strings, JSON = raw
1272
+ * values — see dynamics-utils). The consumer passes exactly the rows to export
1273
+ * (current page, all items, or the selection).
1274
+ */
1275
+ function exportGrid(items, columns, format = 'csv', filename) {
1276
+ dynamicsUtils.exportToFile(items, toExportColumns(columns), format, filename ?? dynamicsUtils.generateDefaultFilename('grid-export'));
1277
+ }
1278
+
1279
+ const FORMAT_OPTIONS = {
1280
+ csv: { key: 'csv', text: 'CSV', iconProps: { iconName: 'ExcelDocument' } },
1281
+ json: { key: 'json', text: 'JSON', iconProps: { iconName: 'Code' } },
1282
+ };
1283
+ /** Strip characters illegal in filenames. */
1284
+ function sanitizeFilename(name) {
1285
+ return name.replace(/[<>:"/\\|?*]/g, '');
1286
+ }
1287
+ /**
1288
+ * The export subset for a column selection: drops `fileDrop` columns (no
1289
+ * exportable value) and keeps only columns whose `key` is in `selectedKeys`.
1290
+ * Pure — the dialog's column-picker decision, extracted for testability.
1291
+ */
1292
+ function resolveExportColumns(columns, selectedKeys) {
1293
+ return columns.filter((c) => c.rendererType !== 'fileDrop' && selectedKeys.has(c.key));
1294
+ }
1295
+ /**
1296
+ * Export dialog with format choice, a column picker (Select-all / indeterminate),
1297
+ * and a filename field. Exports the scoped `items` through `exportGrid`. The
1298
+ * turnkey `GridProps.exportConfig` split-button opens this when `dialog: true`.
1299
+ */
1300
+ function GridExportDialog({ isOpen, onClose, items, columns, defaultFilename = 'grid-export', formats = ['csv', 'json'], }) {
1301
+ const exportable = React.useMemo(() => columns.filter((c) => c.rendererType !== 'fileDrop'), [columns]);
1302
+ const [format, setFormat] = React.useState(formats[0] ?? 'csv');
1303
+ const [filename, setFilename] = React.useState(() => dynamicsUtils.generateDefaultFilename(defaultFilename));
1304
+ const [selected, setSelected] = React.useState(() => new Set(exportable.map((c) => c.key)));
1305
+ // Reset to defaults each time the dialog opens. Prop changes (formats /
1306
+ // defaultFilename / columns) while it stays OPEN are intentionally ignored.
1307
+ React.useEffect(() => {
1308
+ if (!isOpen)
1309
+ return;
1310
+ setFormat(formats[0] ?? 'csv');
1311
+ setFilename(dynamicsUtils.generateDefaultFilename(defaultFilename));
1312
+ setSelected(new Set(exportable.map((c) => c.key)));
1313
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on the open transition
1314
+ }, [isOpen]);
1315
+ const allSelected = exportable.length > 0 && selected.size === exportable.length;
1316
+ const someSelected = selected.size > 0 && selected.size < exportable.length;
1317
+ const selectedColumns = React.useMemo(() => resolveExportColumns(columns, selected), [columns, selected]);
1318
+ const toggleAll = React.useCallback((_, checked) => {
1319
+ setSelected(checked ? new Set(exportable.map((c) => c.key)) : new Set());
1320
+ }, [exportable]);
1321
+ const toggleColumn = React.useCallback((key, checked) => {
1322
+ setSelected((prev) => {
1323
+ const next = new Set(prev);
1324
+ if (checked)
1325
+ next.add(key);
1326
+ else
1327
+ next.delete(key);
1328
+ return next;
1329
+ });
1330
+ }, []);
1331
+ const handleExport = React.useCallback(() => {
1332
+ if (selectedColumns.length === 0 || items.length === 0 || !filename.trim())
1333
+ return;
1334
+ exportGrid(items, selectedColumns, format, filename.trim());
1335
+ onClose();
1336
+ }, [items, selectedColumns, format, filename, onClose]);
1337
+ const formatChoices = formats.map((f) => FORMAT_OPTIONS[f]).filter(Boolean);
1338
+ return (jsxRuntime.jsxs(react.Dialog, { hidden: !isOpen, onDismiss: onClose, dialogContentProps: { type: react.DialogType.normal, title: 'Export', closeButtonAriaLabel: 'Close' }, modalProps: { isBlocking: false, styles: { main: { maxWidth: 480, minWidth: 380 } } }, children: [jsxRuntime.jsx("div", { style: { textAlign: 'center', padding: 10, background: '#f3f2f1', borderRadius: 4 }, children: jsxRuntime.jsxs(react.Text, { variant: "mediumPlus", children: ["Exporting ", jsxRuntime.jsx("strong", { children: items.length }), " ", items.length === 1 ? 'row' : 'rows'] }) }), jsxRuntime.jsx(react.Separator, {}), formatChoices.length > 1 && (jsxRuntime.jsxs("div", { style: { marginBottom: 12 }, children: [jsxRuntime.jsx(react.Text, { block: true, styles: { root: { fontWeight: 600, marginBottom: 6 } }, children: "Format" }), jsxRuntime.jsx(react.ChoiceGroup, { selectedKey: format, options: formatChoices, onChange: (_, o) => o && setFormat(o.key) })] })), jsxRuntime.jsxs("div", { style: { marginBottom: 12 }, children: [jsxRuntime.jsx(react.Text, { block: true, styles: { root: { fontWeight: 600, marginBottom: 6 } }, children: "Columns" }), jsxRuntime.jsxs("div", { style: { maxHeight: 200, overflowY: 'auto', border: '1px solid #edebe9', borderRadius: 4, background: '#faf9f8' }, children: [jsxRuntime.jsx("div", { style: { padding: '8px 12px', borderBottom: '1px solid #edebe9' }, children: jsxRuntime.jsx(react.Checkbox, { label: "Select all", checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }), exportable.map((c) => (jsxRuntime.jsx("div", { style: { padding: '4px 12px' }, children: jsxRuntime.jsx(react.Checkbox, { label: c.name, checked: selected.has(c.key), onChange: (_, ck) => toggleColumn(c.key, !!ck) }) }, c.key)))] }), jsxRuntime.jsxs(react.Text, { variant: "small", styles: { root: { color: '#605e5c', paddingTop: 4, display: 'block' } }, children: [selected.size, " of ", exportable.length, " columns"] })] }), jsxRuntime.jsx(react.TextField, { label: "Filename", value: filename, onChange: (_, v) => setFilename(sanitizeFilename(v ?? '')), suffix: `.${format}`, description: "Invalid characters are removed." }), jsxRuntime.jsxs(react.DialogFooter, { children: [jsxRuntime.jsx(react.PrimaryButton, { text: "Export", iconProps: { iconName: 'Download' }, onClick: handleExport, disabled: selectedColumns.length === 0 || items.length === 0 || !filename.trim() }), jsxRuntime.jsx(react.DefaultButton, { text: "Cancel", onClick: onClose })] })] }));
1339
+ }
1340
+
1341
+ const cellKey = (rowKey, fieldName) => `${rowKey}::${fieldName}`;
1342
+ function useEditState() {
1343
+ const [edits, setEdits] = React.useState({});
1344
+ const [errors, setErrors] = React.useState({});
1345
+ const [active, setActive] = React.useState(null);
1346
+ const onValueChange = React.useCallback((rowKey, fieldName, value, originalValue) => {
1347
+ setEdits((prev) => ({ ...prev, [cellKey(rowKey, fieldName)]: { value, original: originalValue } }));
1348
+ }, []);
1349
+ const onValidationError = React.useCallback((rowKey, fieldName, message) => {
1350
+ setErrors((prev) => {
1351
+ const k = cellKey(rowKey, fieldName);
1352
+ if (!message) {
1353
+ if (!(k in prev))
1354
+ return prev;
1355
+ const next = { ...prev };
1356
+ delete next[k];
1357
+ return next;
1358
+ }
1359
+ return { ...prev, [k]: message };
1360
+ });
1361
+ }, []);
1362
+ const isDirty = React.useCallback((rowKey, fieldName) => cellKey(rowKey, fieldName) in edits, [edits]);
1363
+ const getEditedValue = React.useCallback((rowKey, fieldName) => edits[cellKey(rowKey, fieldName)]?.value, [edits]);
1364
+ const getError = React.useCallback((rowKey, fieldName) => errors[cellKey(rowKey, fieldName)], [errors]);
1365
+ const isActiveCell = React.useCallback((rowKey, fieldName) => active === cellKey(rowKey, fieldName), [active]);
1366
+ const setActiveCell = React.useCallback((rowKey, fieldName) => setActive(cellKey(rowKey, fieldName)), []);
1367
+ const clearActiveCell = React.useCallback(() => setActive(null), []);
1368
+ const getChanges = React.useCallback(() => {
1369
+ const out = {};
1370
+ for (const k of Object.keys(edits)) {
1371
+ const sep = k.indexOf('::');
1372
+ const rowKey = k.slice(0, sep);
1373
+ const fieldName = k.slice(sep + 2);
1374
+ (out[rowKey] = out[rowKey] ?? {})[fieldName] = edits[k].value;
1375
+ }
1376
+ return out;
1377
+ }, [edits]);
1378
+ const reset = React.useCallback(() => {
1379
+ setEdits({});
1380
+ setErrors({});
1381
+ setActive(null);
1382
+ }, []);
1383
+ const hasErrors = Object.keys(errors).length > 0;
1384
+ // Stable object identity: only changes when edits/errors/active change (the
1385
+ // derived callbacks change identity then), so the grid's ctx/columns recompute
1386
+ // on real edit-state changes, not on every render.
1387
+ return React.useMemo(() => ({
1388
+ onValueChange,
1389
+ onValidationError,
1390
+ isDirty,
1391
+ getEditedValue,
1392
+ getError,
1393
+ isActiveCell,
1394
+ setActiveCell,
1395
+ clearActiveCell,
1396
+ getChanges,
1397
+ hasErrors,
1398
+ reset,
1399
+ }), [
1400
+ onValueChange,
1401
+ onValidationError,
1402
+ isDirty,
1403
+ getEditedValue,
1404
+ getError,
1405
+ isActiveCell,
1406
+ setActiveCell,
1407
+ clearActiveCell,
1408
+ getChanges,
1409
+ hasErrors,
1410
+ reset,
1411
+ ]);
1412
+ }
1413
+
1414
+ /**
1415
+ * Builds the per-render `GridRenderContext` shared by every grid host
1416
+ * (DataGrid, CardGrid, GroupedGrid, …). Resolves the registry + key accessor,
1417
+ * threads edit-state, and wires per-cell click-to-edit so all hosts behave
1418
+ * identically. Extracted from DataGrid so the hosts don't each re-implement it.
1419
+ */
1420
+ function useGridContext(props, edit) {
1421
+ const { registry: providedRegistry, editable, editTrigger, sort, onSortChange, getKey, onValueChange, navigateTo } = props;
1422
+ const registry = React.useMemo(() => providedRegistry ?? createCellRegistry(), [providedRegistry]);
1423
+ const getKeyFn = React.useMemo(() => getKey ?? defaultGetKey, [getKey]);
1424
+ const trigger = editTrigger ?? 'click';
1425
+ const ctx = React.useMemo(() => ({
1426
+ registry,
1427
+ editable,
1428
+ editTrigger: trigger,
1429
+ getKey: getKeyFn,
1430
+ sort,
1431
+ onSortChange,
1432
+ navigateTo,
1433
+ // Per-cell: only the active cell is in edit mode. 'always': every editable cell.
1434
+ isEditing: (rk, fn) => !!editable && (trigger === 'always' || edit.isActiveCell(rk, fn)),
1435
+ isDirty: edit.isDirty,
1436
+ getEditedValue: edit.getEditedValue,
1437
+ getError: edit.getError,
1438
+ onValueChange: (rk, fn, v, ov) => {
1439
+ edit.onValueChange(rk, fn, v, ov);
1440
+ onValueChange?.(rk, fn, v, ov);
1441
+ },
1442
+ onValidationError: edit.onValidationError,
1443
+ onEditStart: (rk, fn) => edit.setActiveCell(rk, fn),
1444
+ onEditEnd: () => edit.clearActiveCell(),
1445
+ }), [registry, editable, trigger, getKeyFn, sort, onSortChange, edit, onValueChange, navigateTo]);
1446
+ return { registry, getKeyFn, ctx };
1447
+ }
1448
+
1449
+ /**
1450
+ * Pure projection of a row's `RowCommand[]` → Fluent `ContextualMenu` items for a
1451
+ * given row. Commands whose `visible(item)` returns `false` are dropped; `disabled`
1452
+ * is resolved per row; `onClick` is bound to the row and chained with `onAfter`
1453
+ * (used by the host to dismiss the menu). No React / no rendering — unit-testable in
1454
+ * grid-kit's node test env (the type-only Fluent import has no runtime cost).
1455
+ */
1456
+ function buildRowMenuItems(commands, item, onAfter) {
1457
+ return commands
1458
+ .filter((c) => c.visible?.(item) !== false)
1459
+ .map((c) => ({
1460
+ key: c.key,
1461
+ text: c.text,
1462
+ iconProps: c.iconName ? { iconName: c.iconName } : undefined,
1463
+ disabled: c.disabled?.(item) ?? false,
1464
+ onClick: () => {
1465
+ c.onClick(item);
1466
+ onAfter();
1467
+ },
1468
+ }));
1469
+ }
1470
+
1471
+ /**
1472
+ * Right-click row context menu for the DetailsList-backed hosts (`DataGrid`,
1473
+ * `NestedInline`). Wire the returned `onItemContextMenu` onto `ShimmeredDetailsList`
1474
+ * and render `contextMenuElement` alongside it.
1475
+ *
1476
+ * Backward-compat: when `rowCommands` is unset/empty, `onItemContextMenu` is
1477
+ * `undefined`, so the host attaches no handler and the native browser context menu is
1478
+ * left intact for existing consumers. When set, the handler stores the clicked row +
1479
+ * the cursor event and returns `false` to suppress the native menu (Fluent calls
1480
+ * `preventDefault` unless the handler returns a truthy value — do NOT return `true`).
1481
+ */
1482
+ function useRowContextMenu(rowCommands) {
1483
+ const [menu, setMenu] = React.useState(null);
1484
+ const enabled = !!rowCommands && rowCommands.length > 0;
1485
+ const onItemContextMenu = enabled
1486
+ ? (item, _index, ev) => {
1487
+ if (item == null || ev == null)
1488
+ return;
1489
+ setMenu({ item, target: ev });
1490
+ return false; // suppress the native browser menu
1491
+ }
1492
+ : undefined;
1493
+ const contextMenuElement = enabled && menu ? (jsxRuntime.jsx(react.ContextualMenu, { target: menu.target, items: buildRowMenuItems(rowCommands, menu.item, () => setMenu(null)), onDismiss: () => setMenu(null) })) : null;
1494
+ return { onItemContextMenu, contextMenuElement };
1495
+ }
1496
+
1497
+ /**
1498
+ * Grid toolbar: a command bar (left) + optional search box (right).
1499
+ */
1500
+ const GridToolbar = ({ config }) => {
1501
+ const items = (config.commands ?? []).map((c) => ({
1502
+ key: c.key,
1503
+ text: c.text,
1504
+ iconProps: c.iconName ? { iconName: c.iconName } : undefined,
1505
+ onClick: c.onClick,
1506
+ disabled: c.disabled,
1507
+ split: c.split,
1508
+ subMenuProps: c.subMenuProps,
1509
+ }));
1510
+ return (jsxRuntime.jsxs(react.Stack, { horizontal: true, horizontalAlign: "space-between", verticalAlign: "center", tokens: { childrenGap: 8 }, styles: { root: { paddingBottom: 4 } }, children: [jsxRuntime.jsx(react.Stack.Item, { grow: true, children: items.length > 0 ? (jsxRuntime.jsx(react.CommandBar, { items: items, styles: { root: { padding: 0, height: 36 } } })) : (jsxRuntime.jsx("span", {})) }), config.showSearch && (jsxRuntime.jsx(react.SearchBox, { placeholder: config.searchPlaceholder ?? 'Search', onChange: (_, v) => config.onSearch?.(v ?? ''), styles: { root: { width: 240 } } }))] }));
1511
+ };
1512
+
1513
+ /**
1514
+ * Presentational pager shared by all hosts. The consumer pre-slices `items` to
1515
+ * the page; this only renders the page indicator + prev/next controls.
1516
+ */
1517
+ const GridPaginationFooter = ({ pagination, onPageChange }) => {
1518
+ const { currentPage, pageSize, totalItems } = pagination;
1519
+ const totalPages = Math.max(1, Math.ceil(totalItems / Math.max(1, pageSize)));
1520
+ return (jsxRuntime.jsxs(react.Stack, { horizontal: true, verticalAlign: "center", horizontalAlign: "end", tokens: { childrenGap: 8 }, styles: { root: { paddingTop: 8 } }, children: [jsxRuntime.jsx(react.Text, { variant: "small", children: `Page ${currentPage} of ${totalPages} · ${totalItems} item${totalItems === 1 ? '' : 's'}` }), jsxRuntime.jsx(react.DefaultButton, { text: "Previous", iconProps: { iconName: 'ChevronLeft' }, disabled: currentPage <= 1, onClick: () => onPageChange?.(currentPage - 1) }), jsxRuntime.jsx(react.DefaultButton, { text: "Next", iconProps: { iconName: 'ChevronRight' }, disabled: currentPage >= totalPages, onClick: () => onPageChange?.(currentPage + 1) })] }));
1521
+ };
1522
+
1523
+ const AGG_LABEL = {
1524
+ sum: 'Σ',
1525
+ avg: 'avg',
1526
+ count: 'count',
1527
+ min: 'min',
1528
+ max: 'max',
1529
+ };
1530
+ /** Format one aggregate cell: `<label> <value>` (currency sum → currency, etc.). */
1531
+ function aggregateCellText(col, agg) {
1532
+ if (!agg)
1533
+ return '';
1534
+ let text = '';
1535
+ if (col.aggregateFormat) {
1536
+ text = col.aggregateFormat(agg.value, agg.fn);
1537
+ }
1538
+ else if (agg.value === null) {
1539
+ text = '';
1540
+ }
1541
+ else if (agg.fn === 'count') {
1542
+ text = String(agg.value);
1543
+ }
1544
+ else {
1545
+ const cfg = (col.rendererConfig ?? {});
1546
+ text = computeFormattedValue(agg.value, col.rendererType, {
1547
+ currencyCode: cfg.currencyCode,
1548
+ locale: cfg.locale,
1549
+ });
1550
+ }
1551
+ return `${AGG_LABEL[agg.fn]} ${text}`.trim();
1552
+ }
1553
+ /**
1554
+ * Presentational aggregate row: one cell per column (text only for columns that
1555
+ * declared an `aggregate`), constrained to each column's min/max width and offset
1556
+ * by `leadingSpacer`. Shared by the grid footer and per-group subtotal footers.
1557
+ *
1558
+ * Column alignment vs the DetailsList's justified layout is approximate (a known
1559
+ * visual limitation); `leadingSpacer` offsets leading control columns (selection
1560
+ * checkbox, group-expand chevron).
1561
+ */
1562
+ function AggregateRow(props) {
1563
+ const { columns, aggregates, leadingSpacer, background } = props;
1564
+ return (jsxRuntime.jsxs(react.Stack, { horizontal: true, verticalAlign: "center", role: "row", styles: { root: { borderTop: '1px solid #edebe9', background: background ?? '#faf9f8', minHeight: 32 } }, children: [leadingSpacer ? jsxRuntime.jsx("div", { role: "presentation", style: { width: leadingSpacer, flexShrink: 0 } }) : null, columns.map((col) => {
1565
+ const label = aggregateCellText(col, aggregates[col.fieldName]);
1566
+ return (jsxRuntime.jsx("div", { role: "gridcell", title: label || undefined, style: {
1567
+ minWidth: col.minWidth ?? 100,
1568
+ maxWidth: col.maxWidth,
1569
+ width: col.maxWidth ?? col.minWidth ?? 100,
1570
+ padding: '4px 8px',
1571
+ fontSize: 12,
1572
+ fontWeight: 600,
1573
+ color: '#323130',
1574
+ overflow: 'hidden',
1575
+ textOverflow: 'ellipsis',
1576
+ whiteSpace: 'nowrap',
1577
+ }, children: label }, col.key));
1578
+ })] }));
1579
+ }
1580
+ /**
1581
+ * Footer row of per-column aggregates, rendered as a sibling Stack BELOW the
1582
+ * DetailsList (Fluent v8 has no first-class footer-row API; mirrors how
1583
+ * GridPaginationFooter mounts).
1584
+ *
1585
+ * Aggregates over exactly the `items` passed — the displayed (filtered) page by
1586
+ * default, or the full dataset when the host is given `aggregateItems` (cross-page
1587
+ * grand totals). `leadingSpacer` offsets a leading selection-check column.
1588
+ */
1589
+ function GridAggregateFooter(props) {
1590
+ const { items, columns, leadingSpacer } = props;
1591
+ const aggregates = React.useMemo(() => computeAggregates(items, columns), [items, columns]);
1592
+ return jsxRuntime.jsx(AggregateRow, { columns: columns, aggregates: aggregates, leadingSpacer: leadingSpacer });
1593
+ }
1594
+
1595
+ /**
1596
+ * Project a column list to its visible, ordered subset per `visibleOrder` (keys
1597
+ * not in `columns` are skipped; columns not in `visibleOrder` are hidden). Pure —
1598
+ * the chooser's effect on the rendered grid, extracted for testability.
1599
+ */
1600
+ function orderVisibleColumns(columns, visibleOrder) {
1601
+ const byKey = new Map(columns.map((c) => [c.key, c]));
1602
+ return visibleOrder.map((k) => byKey.get(k)).filter((c) => !!c);
1603
+ }
1604
+ const rowStyle = {
1605
+ display: 'flex',
1606
+ alignItems: 'center',
1607
+ padding: '4px 0',
1608
+ borderBottom: '1px solid #f3f2f1',
1609
+ };
1610
+ const sectionTitle = { root: { fontWeight: 600, color: '#605e5c', padding: '8px 0 4px' } };
1611
+ /**
1612
+ * Column chooser panel: show/hide grid columns and reorder the shown ones.
1613
+ * Edits a working copy and commits on Apply (reset on each open). At least one
1614
+ * column must stay shown.
1615
+ */
1616
+ function GridColumnChooser({ isOpen, onClose, columns, visibleOrder, onApply, }) {
1617
+ const [working, setWorking] = React.useState(visibleOrder);
1618
+ // Reset the working copy each time the panel opens.
1619
+ React.useEffect(() => {
1620
+ if (isOpen)
1621
+ setWorking(visibleOrder);
1622
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on the open transition
1623
+ }, [isOpen]);
1624
+ const byKey = React.useMemo(() => new Map(columns.map((c) => [c.key, c])), [columns]);
1625
+ const shown = working.map((k) => byKey.get(k)).filter((c) => !!c);
1626
+ const hidden = columns.filter((c) => !working.includes(c.key));
1627
+ const show = React.useCallback((key) => {
1628
+ setWorking((prev) => (prev.includes(key) ? prev : [...prev, key]));
1629
+ }, []);
1630
+ const hide = React.useCallback((key) => {
1631
+ setWorking((prev) => (prev.length <= 1 ? prev : prev.filter((k) => k !== key)));
1632
+ }, []);
1633
+ const move = React.useCallback((key, dir) => {
1634
+ setWorking((prev) => {
1635
+ const i = prev.indexOf(key);
1636
+ const j = i + dir;
1637
+ if (i < 0 || j < 0 || j >= prev.length)
1638
+ return prev;
1639
+ const next = [...prev];
1640
+ [next[i], next[j]] = [next[j], next[i]];
1641
+ return next;
1642
+ });
1643
+ }, []);
1644
+ const showAll = React.useCallback(() => setWorking(columns.map((c) => c.key)), [columns]);
1645
+ const reset = React.useCallback(() => setWorking(visibleOrder), [visibleOrder]);
1646
+ const apply = React.useCallback(() => {
1647
+ if (working.length === 0)
1648
+ return;
1649
+ onApply(working);
1650
+ onClose();
1651
+ }, [working, onApply, onClose]);
1652
+ return (jsxRuntime.jsxs(react.Panel, { isOpen: isOpen, onDismiss: onClose, type: react.PanelType.medium, headerText: "Columns", isFooterAtBottom: true, onRenderFooterContent: () => (jsxRuntime.jsxs(react.Stack, { horizontal: true, horizontalAlign: "end", tokens: { childrenGap: 8 }, styles: { root: { padding: '12px 24px', borderTop: '1px solid #edebe9' } }, children: [jsxRuntime.jsx(react.DefaultButton, { text: "Cancel", onClick: onClose }), jsxRuntime.jsx(react.PrimaryButton, { text: "Apply", onClick: apply, disabled: working.length === 0 })] })), children: [jsxRuntime.jsxs(react.Stack, { horizontal: true, tokens: { childrenGap: 8 }, styles: { root: { padding: '8px 0' } }, children: [jsxRuntime.jsx(react.DefaultButton, { text: "Show all", onClick: showAll, disabled: hidden.length === 0 }), jsxRuntime.jsx(react.DefaultButton, { text: "Reset", onClick: reset })] }), jsxRuntime.jsxs(react.Text, { block: true, styles: sectionTitle, children: ["Shown (", shown.length, ")"] }), shown.map((c, i) => (jsxRuntime.jsxs("div", { style: rowStyle, children: [jsxRuntime.jsx(react.IconButton, { iconProps: { iconName: 'ChevronUp' }, ariaLabel: `Move ${c.name} up`, onClick: () => move(c.key, -1), disabled: i === 0, styles: { root: { height: 28, width: 28 } } }), jsxRuntime.jsx(react.IconButton, { iconProps: { iconName: 'ChevronDown' }, ariaLabel: `Move ${c.name} down`, onClick: () => move(c.key, 1), disabled: i === shown.length - 1, styles: { root: { height: 28, width: 28 } } }), jsxRuntime.jsx(react.Checkbox, { checked: true, ariaLabel: `Hide ${c.name}`, onChange: () => hide(c.key), disabled: shown.length === 1, styles: { root: { marginLeft: 4 } } }), jsxRuntime.jsx("span", { style: { flex: 1, marginLeft: 8 }, children: c.name })] }, c.key))), jsxRuntime.jsx(react.Separator, {}), jsxRuntime.jsxs(react.Text, { block: true, styles: sectionTitle, children: ["Hidden (", hidden.length, ")"] }), hidden.length === 0 ? (jsxRuntime.jsx(react.Text, { variant: "small", styles: { root: { color: '#a19f9d' } }, children: "All columns are shown." })) : (hidden.map((c) => (jsxRuntime.jsxs("div", { style: rowStyle, children: [jsxRuntime.jsx(react.Checkbox, { checked: false, ariaLabel: `Show ${c.name}`, onChange: () => show(c.key) }), jsxRuntime.jsx("span", { style: { flex: 1, marginLeft: 8 }, children: c.name })] }, c.key))))] }));
1653
+ }
1654
+
1655
+ /** Map a column's renderer type onto a filter field-type bucket. */
1656
+ function fieldTypeForRenderer(rendererType) {
1657
+ switch (rendererToFieldType(rendererType)) {
1658
+ case 'number':
1659
+ case 'progressBar':
1660
+ case 'rating':
1661
+ return 'number';
1662
+ case 'currency':
1663
+ return 'currency';
1664
+ case 'date':
1665
+ case 'datetime':
1666
+ return 'date';
1667
+ case 'toggle':
1668
+ return 'boolean';
1669
+ case 'optionset':
1670
+ return 'optionset';
1671
+ case 'lookup':
1672
+ return 'lookup';
1673
+ default:
1674
+ return 'text';
1675
+ }
1676
+ }
1677
+ const OPS_BY_TYPE = {
1678
+ text: ['eq', 'ne', 'like', 'not-like', 'begins-with', 'ends-with', 'null', 'not-null'],
1679
+ number: ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'null', 'not-null'],
1680
+ currency: ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'null', 'not-null'],
1681
+ date: ['eq', 'ne', 'on-or-after', 'on-or-before', 'gt', 'lt', 'null', 'not-null'],
1682
+ boolean: ['eq', 'ne'],
1683
+ optionset: ['eq', 'ne', 'in', 'not-in', 'null', 'not-null'],
1684
+ lookup: ['eq', 'ne', 'like', 'null', 'not-null'],
1685
+ };
1686
+ const OP_LABELS = {
1687
+ eq: 'Equals',
1688
+ ne: 'Does Not Equal',
1689
+ like: 'Contains',
1690
+ 'not-like': 'Does Not Contain',
1691
+ 'begins-with': 'Begins With',
1692
+ 'ends-with': 'Ends With',
1693
+ gt: 'Greater Than',
1694
+ ge: 'Greater Or Equal',
1695
+ lt: 'Less Than',
1696
+ le: 'Less Or Equal',
1697
+ 'on-or-after': 'On Or After',
1698
+ 'on-or-before': 'On Or Before',
1699
+ in: 'Is One Of',
1700
+ 'not-in': 'Is Not One Of',
1701
+ null: 'Is Empty',
1702
+ 'not-null': 'Is Not Empty',
1703
+ };
1704
+ /** Operators for a field type, with display labels (date relabels gt/lt to After/Before). */
1705
+ function getOperatorsForFieldType(t) {
1706
+ return OPS_BY_TYPE[t].map((op) => {
1707
+ const label = t === 'date' && op === 'gt' ? 'After' : t === 'date' && op === 'lt' ? 'Before' : OP_LABELS[op];
1708
+ return { op, label };
1709
+ });
1710
+ }
1711
+ /** Read optionset choices off a column's renderer config (both key shapes). */
1712
+ function normalizeOptions(col) {
1713
+ const cfg = (col.rendererConfig ?? {});
1714
+ const raw = cfg.optionSetOptions ?? cfg.options;
1715
+ if (!Array.isArray(raw))
1716
+ return undefined;
1717
+ return raw
1718
+ .map((o) => {
1719
+ const opt = o;
1720
+ const key = opt.key ?? opt.value;
1721
+ if (key === undefined || key === null)
1722
+ return undefined;
1723
+ const text = opt.text ?? opt.label ?? key;
1724
+ return { key: String(key), text: String(text) };
1725
+ })
1726
+ .filter((o) => !!o);
1727
+ }
1728
+ function toFilterField(col) {
1729
+ let fieldType = fieldTypeForRenderer(col.rendererType);
1730
+ const options = fieldType === 'optionset' ? normalizeOptions(col) : undefined;
1731
+ // An optionset column with no declared choices behaves as text — otherwise its
1732
+ // `in`/`not-in` operators would render a text box that writes `value`, never the
1733
+ // `values` they require, leaving the condition permanently incomplete.
1734
+ if (fieldType === 'optionset' && (!options || options.length === 0))
1735
+ fieldType = 'text';
1736
+ return { key: col.fieldName, text: col.name, fieldType, options };
1737
+ }
1738
+ /** Format a Date as a local calendar day (YYYY-MM-DD) — avoids the toISOString UTC shift. */
1739
+ function toDateOnly(d) {
1740
+ const pad = (n) => String(n).padStart(2, '0');
1741
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
1742
+ }
1743
+ /** Parse a YYYY-MM-DD (or ISO) value into a LOCAL Date for the picker (round-trips the day). */
1744
+ function parseDateValue(value) {
1745
+ if (!value)
1746
+ return undefined;
1747
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
1748
+ const d = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : new Date(value);
1749
+ return Number.isNaN(d.getTime()) ? undefined : d;
1750
+ }
1751
+ /** Per-condition value editor — switches on the column's field type + operator. */
1752
+ function ValueEditor({ field, condition, onChange, }) {
1753
+ const fieldType = field?.fieldType ?? 'text';
1754
+ if (fieldType === 'boolean') {
1755
+ return (jsxRuntime.jsx(react.Dropdown, { ariaLabel: "Value", selectedKey: condition.value ?? null, options: [
1756
+ { key: 'true', text: 'Yes' },
1757
+ { key: 'false', text: 'No' },
1758
+ ], onChange: (_, o) => o && onChange({ value: String(o.key) }), styles: { root: { width: 120 } } }));
1759
+ }
1760
+ if (fieldType === 'optionset' && field?.options) {
1761
+ if (operatorIsMultiValue(condition.operator)) {
1762
+ return (jsxRuntime.jsx(react.Dropdown, { multiSelect: true, ariaLabel: "Values", selectedKeys: condition.values ?? [], options: field.options, onChange: (_, o) => {
1763
+ if (!o)
1764
+ return;
1765
+ const prev = condition.values ?? [];
1766
+ const key = String(o.key);
1767
+ onChange({ values: o.selected ? [...prev, key] : prev.filter((k) => k !== key) });
1768
+ }, styles: { root: { width: 190 } } }));
1769
+ }
1770
+ return (jsxRuntime.jsx(react.Dropdown, { ariaLabel: "Value", selectedKey: condition.value ?? null, options: field.options, onChange: (_, o) => o && onChange({ value: String(o.key) }), styles: { root: { width: 190 } } }));
1771
+ }
1772
+ if (fieldType === 'date') {
1773
+ return (jsxRuntime.jsx(react.DatePicker, { ariaLabel: "Value", value: parseDateValue(condition.value), onSelectDate: (d) => onChange({ value: d ? toDateOnly(d) : undefined }), styles: { root: { width: 170 } } }));
1774
+ }
1775
+ const isNumeric = fieldType === 'number' || fieldType === 'currency';
1776
+ return (jsxRuntime.jsx(react.TextField, { ariaLabel: "Value", type: isNumeric ? 'number' : 'text', value: condition.value ?? '', placeholder: fieldType === 'lookup' ? 'Text or GUID' : undefined, onChange: (_, v) => onChange({ value: v ?? '' }), styles: { root: { width: 170 } } }));
1777
+ }
1778
+ /** One filter row: column → operator → value editor → remove. */
1779
+ function ConditionRow({ condition, fields, onChange, onRemove, }) {
1780
+ const field = fields.find((f) => f.key === condition.fieldName);
1781
+ const fieldType = field?.fieldType ?? 'text';
1782
+ const operators = getOperatorsForFieldType(fieldType);
1783
+ const onFieldChange = (key) => {
1784
+ const next = fields.find((f) => f.key === key);
1785
+ const firstOp = next ? getOperatorsForFieldType(next.fieldType)[0].op : 'eq';
1786
+ onChange({ fieldName: key, operator: firstOp, value: undefined, values: undefined });
1787
+ };
1788
+ return (jsxRuntime.jsxs(react.Stack, { horizontal: true, verticalAlign: "start", tokens: { childrenGap: 8 }, styles: { root: { padding: '2px 0' } }, children: [jsxRuntime.jsx(react.Dropdown, { ariaLabel: "Column", placeholder: "Select a column", selectedKey: condition.fieldName || null, options: fields.map((f) => ({ key: f.key, text: f.text })), onChange: (_, o) => o && onFieldChange(String(o.key)), styles: { root: { width: 170 } } }), jsxRuntime.jsx(react.Dropdown, { ariaLabel: "Operator", selectedKey: condition.operator, disabled: !condition.fieldName, options: operators.map((o) => ({ key: o.op, text: o.label })), onChange: (_, o) => o && onChange({ operator: o.key, value: undefined, values: undefined }), styles: { root: { width: 150 } } }), condition.fieldName && operatorRequiresValue(condition.operator) && (jsxRuntime.jsx(ValueEditor, { field: field, condition: condition, onChange: onChange })), jsxRuntime.jsx(react.IconButton, { iconProps: { iconName: 'Delete' }, ariaLabel: "Remove condition", title: "Remove condition", onClick: onRemove })] }));
1789
+ }
1790
+ let rowSeq = 0;
1791
+ const makeRow = (cond) => ({ id: ++rowSeq, cond });
1792
+ /**
1793
+ * Filter-builder panel: build field/operator/value conditions combined by All/Any
1794
+ * (AND/OR). Edits a working copy and commits on Apply (reset on each open).
1795
+ * Incomplete rows are dropped on Apply. The actual row filtering is done by the
1796
+ * pure `applyFilters` (src/core/filter.ts); this panel only produces the model.
1797
+ */
1798
+ function GridFilterBuilder({ isOpen, onClose, columns, model, onApply, }) {
1799
+ const fields = React.useMemo(() => {
1800
+ const seen = new Set();
1801
+ const out = [];
1802
+ for (const col of columns) {
1803
+ if (col.isFilterable === false || col.rendererType === 'fileDrop')
1804
+ continue;
1805
+ if (seen.has(col.fieldName))
1806
+ continue;
1807
+ seen.add(col.fieldName);
1808
+ out.push(toFilterField(col));
1809
+ }
1810
+ return out;
1811
+ }, [columns]);
1812
+ const [match, setMatch] = React.useState(model.match);
1813
+ const [rows, setRows] = React.useState(() => model.conditions.map(makeRow));
1814
+ // Reset the working copy each time the panel opens (mirrors GridColumnChooser).
1815
+ React.useEffect(() => {
1816
+ if (isOpen) {
1817
+ setMatch(model.match);
1818
+ setRows(model.conditions.map(makeRow));
1819
+ }
1820
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on the open transition
1821
+ }, [isOpen]);
1822
+ const addCondition = () => setRows((rs) => [...rs, makeRow({ fieldName: '', operator: 'eq' })]);
1823
+ const updateCondition = (id, patch) => setRows((rs) => rs.map((r) => (r.id === id ? { ...r, cond: { ...r.cond, ...patch } } : r)));
1824
+ const removeCondition = (id) => setRows((rs) => rs.filter((r) => r.id !== id));
1825
+ const clearAll = () => setRows([]);
1826
+ const apply = () => {
1827
+ onApply({ match, conditions: rows.map((r) => r.cond).filter(isConditionComplete) });
1828
+ onClose();
1829
+ };
1830
+ return (jsxRuntime.jsxs(react.Panel, { isOpen: isOpen, onDismiss: onClose, type: react.PanelType.medium, headerText: "Edit filters", isFooterAtBottom: true, onRenderFooterContent: () => (jsxRuntime.jsxs(react.Stack, { horizontal: true, horizontalAlign: "end", tokens: { childrenGap: 8 }, styles: { root: { padding: '12px 24px', borderTop: '1px solid #edebe9' } }, children: [jsxRuntime.jsx(react.DefaultButton, { text: "Cancel", onClick: onClose }), jsxRuntime.jsx(react.PrimaryButton, { text: "Apply", onClick: apply })] })), children: [rows.length > 1 && (jsxRuntime.jsx(react.Dropdown, { label: "Match", selectedKey: match, options: [
1831
+ { key: 'all', text: 'All conditions (AND)' },
1832
+ { key: 'any', text: 'Any condition (OR)' },
1833
+ ], onChange: (_, o) => o && setMatch(o.key), styles: { root: { width: 220, padding: '8px 0' } } })), rows.length === 0 ? (jsxRuntime.jsx(react.Text, { variant: "small", styles: { root: { color: '#a19f9d', display: 'block', padding: '8px 0' } }, children: "No filters. Add a condition to narrow the rows." })) : (jsxRuntime.jsx(react.Stack, { tokens: { childrenGap: 4 }, styles: { root: { padding: '8px 0' } }, children: rows.map((r) => (jsxRuntime.jsx(ConditionRow, { condition: r.cond, fields: fields, onChange: (patch) => updateCondition(r.id, patch), onRemove: () => removeCondition(r.id) }, r.id))) })), jsxRuntime.jsxs(react.Stack, { horizontal: true, tokens: { childrenGap: 8 }, styles: { root: { padding: '8px 0' } }, children: [jsxRuntime.jsx(react.DefaultButton, { iconProps: { iconName: 'Add' }, text: "Add condition", onClick: addCondition, disabled: fields.length === 0 }), rows.length > 0 && jsxRuntime.jsx(react.DefaultButton, { text: "Clear all", onClick: clearAll })] })] }));
1834
+ }
1835
+
1836
+ // Shared helpers for the additive, default-off `fill`/`height` grid props (v1b).
1837
+ // `fill` makes a host fill its container (flex column at `height`, default 100%) and
1838
+ // scroll its list/cards region internally — so a grid dropped into a surface-kit
1839
+ // Section / Dialog / Panel scrolls inside the surface while the toolbar + footers
1840
+ // (and the surface's own footer) stay fixed. All default-off → unchanged when unset.
1841
+ //
1842
+ // Consumer contract: the container MUST have a resolved height (a flex/grid parent or
1843
+ // an explicit height) — `height: '100%'` against an auto-height parent renders nothing.
1844
+ // v1 limitation: the DetailsList column header scrolls with the body (not pinned via
1845
+ // ScrollablePane/Sticky); the surface-integration goal (grid scrolls so the surface
1846
+ // footer stays put) is still met.
1847
+ /** Stack root styles for a host that should fill its container. `undefined` when not filling. */
1848
+ function fillStackStyles(fill, height) {
1849
+ return fill ? { root: { height: height ?? '100%', display: 'flex', flexDirection: 'column', minHeight: 0 } } : undefined;
1850
+ }
1851
+ /** Wraps the scrollable region (list/cards) so it grows + scrolls inside a `fill` host; passthrough otherwise. */
1852
+ const FillRegion = ({ fill, children }) => fill ? jsxRuntime.jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'auto' }, children: children }) : jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
1853
+
1854
+ /** Wraps a rendered DetailsList row as a file-drop target. */
1855
+ function RowFileDropTarget({ item, config, children, }) {
1856
+ const { isDragOver, dragProps } = useFileDrop({
1857
+ onDrop: (files) => config.onDrop(item, files),
1858
+ accept: config.accept,
1859
+ multiple: config.multiple,
1860
+ onReject: config.onReject ? (reason) => config.onReject(item, reason) : undefined,
1861
+ });
1862
+ return (
1863
+ // role="presentation" so the wrapper doesn't add a node to the grid's ARIA
1864
+ // row structure — the DetailsRow inside keeps role="row".
1865
+ jsxRuntime.jsx("div", { ...dragProps, role: "presentation", style: {
1866
+ outline: isDragOver ? '2px solid #0078d4' : '2px solid transparent',
1867
+ background: isDragOver ? 'rgba(0,120,212,0.06)' : undefined,
1868
+ transition: 'background 80ms ease-out, outline-color 80ms ease-out',
1869
+ }, children: children }));
1870
+ }
1871
+ // One-time dev warning: client-side filtering over a consumer-pre-sliced page is
1872
+ // inconsistent with pagination.totalItems. Fired once per process.
1873
+ let warnedFilterPaging = false;
1874
+ function mapSelectionMode$2(mode) {
1875
+ switch (mode) {
1876
+ case 'single':
1877
+ return react.SelectionMode.single;
1878
+ case 'multiple':
1879
+ return react.SelectionMode.multiple;
1880
+ case 'none':
1881
+ default:
1882
+ return react.SelectionMode.none;
1883
+ }
1884
+ }
1885
+ /**
1886
+ * Flat / editable grid host built on Fluent v8 `ShimmeredDetailsList`. Columns
1887
+ * come from the registry via `toDetailsListColumns`; inline edit-state from
1888
+ * `useEditState` (per-cell click-to-edit by default). Card / grouped / nested
1889
+ * layouts are separate hosts that reuse the same registry + render context.
1890
+ */
1891
+ function DataGrid(props) {
1892
+ const { items, columns, selectionMode = 'none', onSelectionChanged, isLoading, pagination, onPageChange, aggregateItems, toolbar, onActiveItemChanged, compact, rowFileDrop, exportConfig, alternateRowColors, navigateTo, onRowNavigate, rowCommands, columnChooser, onColumnOrderChange, filterBuilder, onFilterChange, fill, height, } = props;
1893
+ const edit = useEditState();
1894
+ const { getKeyFn, ctx } = useGridContext(props, edit);
1895
+ const { onItemContextMenu, contextMenuElement } = useRowContextMenu(rowCommands);
1896
+ // Null-safe row key: with `enableShimmer`, Fluent calls getKey on UNDEFINED
1897
+ // placeholder rows — never let a consumer's getKey (e.g. `r => r.key`) crash
1898
+ // on those during loading.
1899
+ const safeRowKey = (it, index) => it == null ? `__gridkit_shimmer_${index ?? 0}` : getKeyFn(it);
1900
+ // Fluent Selection so consumers can read selected rows via onSelectionChanged.
1901
+ const onSelectionChangedRef = React.useRef(onSelectionChanged);
1902
+ onSelectionChangedRef.current = onSelectionChanged;
1903
+ const selectionRef = React.useRef();
1904
+ if (!selectionRef.current) {
1905
+ selectionRef.current = new react.Selection({
1906
+ getKey: (it, index) => safeRowKey(it, index),
1907
+ onSelectionChanged: () => onSelectionChangedRef.current?.(selectionRef.current.getSelection()),
1908
+ });
1909
+ }
1910
+ // Column chooser: the visible+ordered column keys (default all). Reconciles when
1911
+ // the `columns` prop changes — new keys appended (visible), removed keys dropped.
1912
+ const [visibleOrder, setVisibleOrder] = React.useState(() => columns.map((c) => c.key));
1913
+ React.useEffect(() => {
1914
+ setVisibleOrder((prev) => {
1915
+ const keys = new Set(columns.map((c) => c.key));
1916
+ const kept = prev.filter((k) => keys.has(k));
1917
+ const added = columns.map((c) => c.key).filter((k) => !prev.includes(k));
1918
+ const next = [...kept, ...added];
1919
+ return next.length === prev.length && next.every((k, i) => k === prev[i]) ? prev : next;
1920
+ });
1921
+ }, [columns]);
1922
+ const [columnChooserOpen, setColumnChooserOpen] = React.useState(false);
1923
+ const effectiveColumns = React.useMemo(() => (columnChooser ? orderVisibleColumns(columns, visibleOrder) : columns), [columnChooser, columns, visibleOrder]);
1924
+ // Filter builder: the applied model + the filtered row set. Filtering changes
1925
+ // the row SET (unlike the chooser's purely-visual column projection), so
1926
+ // effectiveItems feeds the list, aggregate footer, AND export scope together.
1927
+ const [filterModel, setFilterModel] = React.useState({ match: 'all', conditions: [] });
1928
+ const [filterBuilderOpen, setFilterBuilderOpen] = React.useState(false);
1929
+ const effectiveItems = React.useMemo(() => (filterBuilder ? applyFilters(items, filterModel) : items), [filterBuilder, items, filterModel]);
1930
+ const activeFilterCount = React.useMemo(() => filterModel.conditions.filter(isConditionComplete).length, [filterModel]);
1931
+ // Footer aggregate source: the displayed (filtered) page by default, or the full
1932
+ // `aggregateItems` dataset for cross-page grand totals — with the active filter
1933
+ // applied to it too, so the grand total reflects the filter across all pages.
1934
+ const aggregateSource = React.useMemo(() => aggregateItems ? (filterBuilder ? applyFilters(aggregateItems, filterModel) : aggregateItems) : effectiveItems, [aggregateItems, filterBuilder, filterModel, effectiveItems]);
1935
+ React.useEffect(() => {
1936
+ if (filterBuilder && pagination && !warnedFilterPaging && typeof console !== 'undefined') {
1937
+ warnedFilterPaging = true;
1938
+ console.warn('[grid-kit] filterBuilder filters the in-memory `items` (the current page when paginated), ' +
1939
+ 'so the visible/footer/export counts can disagree with pagination.totalItems. For ' +
1940
+ 'server-side filtering, read onFilterChange and re-query instead.');
1941
+ }
1942
+ }, [filterBuilder, pagination]);
1943
+ const dlColumns = React.useMemo(() => toDetailsListColumns(effectiveColumns, ctx), [effectiveColumns, ctx]);
1944
+ // Turnkey export. selectionRef.getSelection() is safe even when selectionMode
1945
+ // is 'none' (returns []). Scope: 'all' = every row; 'selected' = only the
1946
+ // selection (no-op when empty / not selectable); default = selection if any,
1947
+ // else all.
1948
+ // NOTE: export uses the FULL `columns`, independent of the column chooser's
1949
+ // on-screen visibility — you export the whole dataset; the export dialog has
1950
+ // its own column picker for narrowing the output. Rows, however, DO honor the
1951
+ // filter builder (`effectiveItems`) — you export what the filter shows.
1952
+ const exportFormats = exportConfig?.formats ?? ['csv', 'json'];
1953
+ const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
1954
+ const scopedRows = React.useCallback(() => {
1955
+ const selected = (selectionRef.current?.getSelection() ?? []);
1956
+ if (exportConfig?.scope === 'all')
1957
+ return effectiveItems;
1958
+ if (exportConfig?.scope === 'selected')
1959
+ return selected;
1960
+ return selected.length > 0 ? selected : effectiveItems;
1961
+ }, [effectiveItems, exportConfig]);
1962
+ const doExport = React.useCallback((format) => {
1963
+ const rows = scopedRows();
1964
+ if (rows.length === 0)
1965
+ return;
1966
+ exportGrid(rows, columns, format, exportConfig?.filename);
1967
+ }, [columns, exportConfig, scopedRows]);
1968
+ const exportCommand = !exportConfig?.enabled
1969
+ ? undefined
1970
+ : exportConfig.dialog
1971
+ ? {
1972
+ // Dialog mode — a single "Export…" button opening the column/format picker.
1973
+ key: '__gridkit_export__',
1974
+ text: 'Export…',
1975
+ iconName: 'Download',
1976
+ onClick: () => setExportDialogOpen(true),
1977
+ }
1978
+ : {
1979
+ key: '__gridkit_export__',
1980
+ text: 'Export',
1981
+ iconName: 'Download',
1982
+ split: exportFormats.length > 1,
1983
+ onClick: () => doExport(exportFormats[0]),
1984
+ subMenuProps: exportFormats.length > 1
1985
+ ? {
1986
+ items: exportFormats.map((f) => ({
1987
+ key: f,
1988
+ text: f.toUpperCase(),
1989
+ iconProps: { iconName: f === 'csv' ? 'ExcelDocument' : 'Code' },
1990
+ onClick: () => doExport(f),
1991
+ })),
1992
+ }
1993
+ : undefined,
1994
+ };
1995
+ // "Open" navigation command — navigates the selected row (else the active one).
1996
+ const activeItemRef = React.useRef(undefined);
1997
+ const openCommand = navigateTo
1998
+ ? {
1999
+ key: '__gridkit_open__',
2000
+ text: 'Open',
2001
+ iconName: 'OpenInNewWindow',
2002
+ onClick: () => {
2003
+ const selected = (selectionRef.current?.getSelection() ?? []);
2004
+ const target = selected[0] ?? activeItemRef.current;
2005
+ if (!target)
2006
+ return;
2007
+ const nav = navigateTo(target);
2008
+ if (nav)
2009
+ void navigateToTarget(nav);
2010
+ },
2011
+ }
2012
+ : undefined;
2013
+ // "Columns" command — opens the column chooser panel.
2014
+ const columnsCommand = columnChooser
2015
+ ? {
2016
+ key: '__gridkit_columns__',
2017
+ text: 'Columns',
2018
+ iconName: 'ColumnOptions',
2019
+ onClick: () => setColumnChooserOpen(true),
2020
+ }
2021
+ : undefined;
2022
+ // "Filters" command — opens the filter builder panel (shows the active count).
2023
+ const filtersCommand = filterBuilder
2024
+ ? {
2025
+ key: '__gridkit_filters__',
2026
+ text: activeFilterCount ? `Filters (${activeFilterCount})` : 'Filters',
2027
+ iconName: 'Filter',
2028
+ onClick: () => setFilterBuilderOpen(true),
2029
+ }
2030
+ : undefined;
2031
+ const injectedCommands = [
2032
+ exportCommand,
2033
+ openCommand,
2034
+ columnsCommand,
2035
+ filtersCommand,
2036
+ ].filter(Boolean);
2037
+ const effectiveToolbar = toolbar || injectedCommands.length
2038
+ ? { ...toolbar, commands: [...injectedCommands, ...(toolbar?.commands ?? [])] }
2039
+ : undefined;
2040
+ // Compose alternate-row tint + whole-row file-drop into ONE onRenderRow (both
2041
+ // want it). Return undefined when neither is set so Fluent uses its optimized
2042
+ // default-row path. Tint by passing fresh `styles` to defaultRender — never
2043
+ // mutate rowProps.styles (it is optional and may be a style function).
2044
+ const onRenderRow = React.useMemo(() => rowFileDrop || alternateRowColors
2045
+ ? (rowProps, defaultRender) => {
2046
+ if (!rowProps || !defaultRender)
2047
+ return null;
2048
+ const rendered = alternateRowColors
2049
+ ? defaultRender({
2050
+ ...rowProps,
2051
+ styles: {
2052
+ root: {
2053
+ backgroundColor: rowProps.itemIndex % 2 === 0 ? alternateRowColors.even : alternateRowColors.odd,
2054
+ },
2055
+ },
2056
+ })
2057
+ : defaultRender(rowProps);
2058
+ return rowFileDrop ? (jsxRuntime.jsx(RowFileDropTarget, { item: rowProps.item, config: rowFileDrop, children: rendered })) : (rendered);
2059
+ }
2060
+ : undefined, [rowFileDrop, alternateRowColors]);
2061
+ return (jsxRuntime.jsxs(react.Stack, { styles: fillStackStyles(fill, height), children: [effectiveToolbar && jsxRuntime.jsx(GridToolbar, { config: effectiveToolbar }), jsxRuntime.jsx(FillRegion, { fill: fill, children: jsxRuntime.jsx(react.ShimmeredDetailsList, { items: effectiveItems, columns: dlColumns, selectionMode: mapSelectionMode$2(selectionMode), selection: selectionMode !== 'none' ? selectionRef.current : undefined, enableShimmer: isLoading, layoutMode: react.DetailsListLayoutMode.justified, compact: compact, getKey: (it, index) => safeRowKey(it, index), setKey: "grid-kit", onRenderRow: onRenderRow, onActiveItemChanged: (item, index) => {
2062
+ activeItemRef.current = item;
2063
+ onActiveItemChanged?.(item, index);
2064
+ }, onItemInvoked: onRowNavigate ? (item) => onRowNavigate(item) : undefined, onItemContextMenu: onItemContextMenu, styles: dynamicsCellRenderers.getDetailsListStyles(), ariaLabelForShimmer: "Loading data", ariaLabelForGrid: "Grid" }) }), contextMenuElement, effectiveColumns.some((c) => c.aggregate) && (jsxRuntime.jsx(GridAggregateFooter, { items: aggregateSource, columns: effectiveColumns, leadingSpacer: selectionMode !== 'none' ? 48 : 0 })), pagination && jsxRuntime.jsx(GridPaginationFooter, { pagination: pagination, onPageChange: onPageChange }), exportConfig?.dialog && (jsxRuntime.jsx(GridExportDialog, { isOpen: exportDialogOpen, onClose: () => setExportDialogOpen(false), items: exportDialogOpen ? scopedRows() : [], columns: columns, defaultFilename: exportConfig.filename, formats: exportFormats })), columnChooser && (jsxRuntime.jsx(GridColumnChooser, { isOpen: columnChooserOpen, onClose: () => setColumnChooserOpen(false), columns: columns, visibleOrder: visibleOrder, onApply: (order) => {
2065
+ setVisibleOrder(order);
2066
+ onColumnOrderChange?.(order);
2067
+ } })), filterBuilder && (jsxRuntime.jsx(GridFilterBuilder, { isOpen: filterBuilderOpen, onClose: () => setFilterBuilderOpen(false), columns: columns, model: filterModel, onApply: (m) => {
2068
+ setFilterModel(m);
2069
+ onFilterChange?.(m);
2070
+ } }))] }));
2071
+ }
2072
+
2073
+ /**
2074
+ * Read-only grid: `<DataGrid>` with selection off, editing off, and command-bar
2075
+ * commands stripped (search is kept). Mirrors the Grid Customizer's "Read-Only
2076
+ * Grid" type — a presentation preset, no extra config.
2077
+ */
2078
+ function ReadOnlyGrid(props) {
2079
+ const toolbar = props.toolbar
2080
+ ? { showSearch: props.toolbar.showSearch, searchPlaceholder: props.toolbar.searchPlaceholder, onSearch: props.toolbar.onSearch }
2081
+ : undefined;
2082
+ return jsxRuntime.jsx(DataGrid, { ...props, selectionMode: "none", editable: false, toolbar: toolbar });
2083
+ }
2084
+
2085
+ // Shared card-layout primitives for the card hosts (`<CardGrid>` flat cards +
2086
+ // `<NestedCardParent>` expandable parent cards). The derivation is a PURE function
2087
+ // (no hooks) so it's unit-testable without a DOM; the mergeStyles classes are shared
2088
+ // so the two hosts can't drift apart visually.
2089
+ const cardClass = react.mergeStyles({
2090
+ border: '1px solid #edebe9',
2091
+ borderRadius: 4,
2092
+ padding: 12,
2093
+ background: '#fff',
2094
+ display: 'flex',
2095
+ flexDirection: 'column',
2096
+ gap: 6,
2097
+ boxShadow: '0 1px 2px rgba(0,0,0,0.06)',
2098
+ });
2099
+ const cardImageClass = react.mergeStyles({
2100
+ width: '100%',
2101
+ height: 96,
2102
+ objectFit: 'cover',
2103
+ borderRadius: 2,
2104
+ marginBottom: 4,
2105
+ });
2106
+ const bodyRowClass = react.mergeStyles({
2107
+ display: 'flex',
2108
+ justifyContent: 'space-between',
2109
+ alignItems: 'center',
2110
+ gap: 8,
2111
+ fontSize: 13,
2112
+ });
2113
+ /**
2114
+ * PURE derivation of a card layout from columns + config — no React hooks, so it's
2115
+ * directly unit-testable. Hosts wrap the call in their own `useMemo`. titleField
2116
+ * defaults to the first column; bodyColumns defaults to all columns except the
2117
+ * title/subtitle/image fields (explicit `bodyFields` are honored verbatim).
2118
+ */
2119
+ function resolveCardLayout(columns, card) {
2120
+ const cardsPerRow = card?.cardsPerRow ?? 3;
2121
+ const titleField = card?.titleField ?? columns[0]?.fieldName;
2122
+ const { subtitleField, imageField, cardHeight } = card ?? {};
2123
+ const byField = new Map();
2124
+ columns.forEach((c) => byField.set(c.fieldName, c));
2125
+ const explicit = card?.bodyFields;
2126
+ const bodyColumns = explicit
2127
+ ? explicit.map((f) => byField.get(f)).filter(Boolean)
2128
+ : columns.filter((c) => !new Set([titleField, subtitleField, imageField].filter(Boolean)).has(c.fieldName));
2129
+ return { cardsPerRow, titleField, subtitleField, imageField, cardHeight, byField, bodyColumns };
2130
+ }
2131
+
2132
+ /**
2133
+ * Card-view grid host. Renders each row as a card; every field value goes
2134
+ * through the SAME cell registry as `<DataGrid>` (so status badges, currency,
2135
+ * ratings, etc. look identical). Mirrors the Grid Customizer's "Card List" type.
2136
+ *
2137
+ * Selection is checkbox-based (manual Set), independent of Fluent's DetailsList
2138
+ * Selection.
2139
+ */
2140
+ function CardGrid(props) {
2141
+ const { items, columns, card, selectionMode = 'none', onSelectionChanged, toolbar, pagination, onPageChange, onActiveItemChanged, fill, height, } = props;
2142
+ const edit = useEditState();
2143
+ const { getKeyFn, ctx } = useGridContext(props, edit);
2144
+ // Card layout derivation is shared (and unit-tested) via resolveCardLayout —
2145
+ // identical to the prior inline derivation, so rendered output is unchanged.
2146
+ const { cardsPerRow, titleField, subtitleField, imageField, cardHeight, byField, bodyColumns } = React.useMemo(() => resolveCardLayout(columns, card), [columns, card]);
2147
+ const [selected, setSelected] = React.useState(new Set());
2148
+ const renderField = (col, item) => {
2149
+ if (!col)
2150
+ return null;
2151
+ const cellProps = buildCellProps(col, item, ctx);
2152
+ const Renderer = ctx.registry.resolve(col, false); // cards render read-only
2153
+ return jsxRuntime.jsx(Renderer, { ...cellProps });
2154
+ };
2155
+ const rawValue = (item, field) => {
2156
+ if (!field)
2157
+ return '';
2158
+ const v = item[field];
2159
+ return v == null ? '' : String(v);
2160
+ };
2161
+ const toggleSelect = (item, checked) => {
2162
+ const key = getKeyFn(item);
2163
+ setSelected((prev) => {
2164
+ const next = new Set(selectionMode === 'single' ? [] : prev);
2165
+ if (checked)
2166
+ next.add(key);
2167
+ else
2168
+ next.delete(key);
2169
+ onSelectionChanged?.(items.filter((it) => next.has(getKeyFn(it))));
2170
+ return next;
2171
+ });
2172
+ };
2173
+ return (jsxRuntime.jsxs(react.Stack, { styles: fillStackStyles(fill, height), children: [toolbar && jsxRuntime.jsx(GridToolbar, { config: toolbar }), jsxRuntime.jsx(FillRegion, { fill: fill, children: jsxRuntime.jsx("div", { style: {
2174
+ display: 'grid',
2175
+ gridTemplateColumns: `repeat(${cardsPerRow}, minmax(0, 1fr))`,
2176
+ gap: 12,
2177
+ paddingTop: 4,
2178
+ }, children: items.map((item) => {
2179
+ const key = getKeyFn(item);
2180
+ return (jsxRuntime.jsxs("div", { className: cardClass, style: cardHeight ? { height: cardHeight } : undefined, onClick: () => onActiveItemChanged?.(item), children: [jsxRuntime.jsxs(react.Stack, { horizontal: true, horizontalAlign: "space-between", verticalAlign: "start", children: [jsxRuntime.jsx(react.Text, { variant: "mediumPlus", styles: { root: { fontWeight: 600 } }, children: renderField(byField.get(titleField ?? ''), item) ?? rawValue(item, titleField) }), selectionMode !== 'none' && (
2181
+ // Stop the click from bubbling to the card's onActiveItemChanged.
2182
+ jsxRuntime.jsx("span", { onClick: (e) => e.stopPropagation(), children: jsxRuntime.jsx(react.Checkbox, { checked: selected.has(key), onChange: (_, checked) => toggleSelect(item, checked), ariaLabel: "Select card" }) }))] }), imageField && rawValue(item, imageField) && (jsxRuntime.jsx("img", { className: cardImageClass, src: rawValue(item, imageField), alt: "" })), subtitleField && (jsxRuntime.jsx(react.Text, { variant: "small", styles: { root: { color: '#605e5c' } }, children: rawValue(item, subtitleField) })), bodyColumns.map((col) => (jsxRuntime.jsxs("div", { className: bodyRowClass, children: [jsxRuntime.jsx(react.Text, { variant: "small", styles: { root: { color: '#605e5c' } }, children: col.name }), jsxRuntime.jsx("span", { children: renderField(col, item) })] }, col.key)))] }, key));
2183
+ }) }) }), pagination && jsxRuntime.jsx(GridPaginationFooter, { pagination: pagination, onPageChange: onPageChange })] }));
2184
+ }
2185
+
2186
+ /**
2187
+ * Bucket rows by `group.groupBy` into a contiguous, ordered array plus the
2188
+ * Fluent v8 `IGroup[]` describing each bucket's range. Fluent's grouped
2189
+ * DetailsList requires the items array to be ordered so each group's
2190
+ * `startIndex`/`count` is contiguous — this guarantees that. Pure + exported so
2191
+ * it's unit-testable and reusable.
2192
+ */
2193
+ function buildGroups(items, group) {
2194
+ const buckets = new Map();
2195
+ for (const it of items) {
2196
+ const v = it[group.groupBy];
2197
+ const k = v == null ? '(Blank)' : String(v);
2198
+ let arr = buckets.get(k);
2199
+ if (!arr) {
2200
+ arr = [];
2201
+ buckets.set(k, arr);
2202
+ }
2203
+ arr.push(it);
2204
+ }
2205
+ const orderedItems = [];
2206
+ const groups = [];
2207
+ const showCount = group.showCount !== false;
2208
+ let start = 0;
2209
+ for (const [k, rows] of buckets) {
2210
+ const label = group.getGroupLabel ? group.getGroupLabel(rows[0][group.groupBy], rows) : k;
2211
+ groups.push({
2212
+ key: k,
2213
+ name: showCount ? `${label} (${rows.length})` : label,
2214
+ startIndex: start,
2215
+ count: rows.length,
2216
+ level: 0,
2217
+ isCollapsed: group.collapsedByDefault ?? false,
2218
+ });
2219
+ orderedItems.push(...rows);
2220
+ start += rows.length;
2221
+ }
2222
+ return { orderedItems, groups };
2223
+ }
2224
+
2225
+ function mapSelectionMode$1(mode) {
2226
+ return mode === 'multiple' ? react.SelectionMode.multiple : mode === 'single' ? react.SelectionMode.single : react.SelectionMode.none;
2227
+ }
2228
+ // Approx. widths used to left-align a group subtotal under its rows (column
2229
+ // alignment under justified layout is approximate — see GridAggregateFooter).
2230
+ const SELECTION_COLUMN_WIDTH = 48;
2231
+ const GROUP_CHEVRON_WIDTH = 36;
2232
+ /**
2233
+ * Grouped grid host. Buckets rows by `group.groupBy` into a contiguous,
2234
+ * ordered list and renders a Fluent v8 grouped `DetailsList` (collapsible
2235
+ * groups + count badges), reusing the same columns/registry as `<DataGrid>`.
2236
+ */
2237
+ function GroupedGrid(props) {
2238
+ const { items, columns, group, selectionMode = 'none', isLoading, pagination, onPageChange, aggregateItems, toolbar, compact, alternateRowColors, fill, height, rowCommands } = props;
2239
+ const edit = useEditState();
2240
+ const { ctx } = useGridContext(props, edit);
2241
+ const dlColumns = React.useMemo(() => toDetailsListColumns(columns, ctx), [columns, ctx]);
2242
+ // Per-row right-click menu (DetailsList path, same as DataGrid/NestedInline). No-op when
2243
+ // rowCommands is unset (onItemContextMenu is undefined → no handler attached).
2244
+ const { onItemContextMenu, contextMenuElement } = useRowContextMenu(rowCommands);
2245
+ // Bucket by groupBy → ordered items + IGroup[] (DetailsList needs contiguous
2246
+ // group ranges). See buildGroups (pure + unit-tested).
2247
+ const { orderedItems, groups } = React.useMemo(() => buildGroups(items, group), [items, group]);
2248
+ const hasAggregate = columns.some((c) => c.aggregate);
2249
+ // Per-group subtotals: aggregate each group's slice (keyed by group key).
2250
+ const groupAggregates = React.useMemo(() => group.showGroupAggregates && hasAggregate
2251
+ ? computeGroupAggregates(orderedItems, groups.map((g) => ({ key: g.key, startIndex: g.startIndex, count: g.count })), columns)
2252
+ : {}, [group.showGroupAggregates, hasAggregate, orderedItems, groups, columns]);
2253
+ // Overall footer source: all displayed groups by default, or the full
2254
+ // `aggregateItems` dataset for cross-page grand totals. Note: per-group subtotals
2255
+ // always aggregate the displayed (page) rows of each group — only the overall
2256
+ // footer goes cross-page.
2257
+ const footerItems = aggregateItems ?? orderedItems;
2258
+ // Approx. left offset for a group subtotal: selection checkbox + group-expand chevron.
2259
+ const groupLeadingSpacer = (selectionMode !== 'none' ? SELECTION_COLUMN_WIDTH : 0) + GROUP_CHEVRON_WIDTH;
2260
+ // Alt-row tint (file-drop isn't supported on grouped grids). Striping is by the
2261
+ // flattened row index across groups.
2262
+ const onRenderRow = React.useMemo(() => alternateRowColors
2263
+ ? (rowProps, defaultRender) => {
2264
+ if (!rowProps || !defaultRender)
2265
+ return null;
2266
+ return defaultRender({
2267
+ ...rowProps,
2268
+ styles: {
2269
+ root: {
2270
+ backgroundColor: rowProps.itemIndex % 2 === 0 ? alternateRowColors.even : alternateRowColors.odd,
2271
+ },
2272
+ },
2273
+ });
2274
+ }
2275
+ : undefined, [alternateRowColors]);
2276
+ return (jsxRuntime.jsxs(react.Stack, { styles: fillStackStyles(fill, height), children: [toolbar && jsxRuntime.jsx(GridToolbar, { config: toolbar }), jsxRuntime.jsx(FillRegion, { fill: fill, children: jsxRuntime.jsx(react.ShimmeredDetailsList, { items: orderedItems, columns: dlColumns, groups: groups, selectionMode: mapSelectionMode$1(selectionMode), layoutMode: react.DetailsListLayoutMode.justified, compact: compact, setKey: "grid-kit-grouped", onRenderRow: onRenderRow, onItemContextMenu: onItemContextMenu, styles: dynamicsCellRenderers.getDetailsListStyles(), ariaLabelForGrid: "Grouped grid", groupProps: {
2277
+ showEmptyGroups: false,
2278
+ // Render a subtotal under each EXPANDED group. Fluent v8 calls the group
2279
+ // footer renderer even for collapsed groups, so guard on isCollapsed —
2280
+ // otherwise a subtotal floats under a collapsed header whose rows are hidden.
2281
+ onRenderFooter: group.showGroupAggregates && hasAggregate
2282
+ ? (gprops) => gprops?.group && !gprops.group.isCollapsed ? (jsxRuntime.jsx(AggregateRow, { columns: columns, aggregates: groupAggregates[gprops.group.key] ?? {}, leadingSpacer: groupLeadingSpacer, background: "#f3f2f1" })) : null
2283
+ : undefined,
2284
+ }, enableShimmer: isLoading }) }), contextMenuElement, hasAggregate && (jsxRuntime.jsx(GridAggregateFooter, { items: footerItems, columns: columns, leadingSpacer: selectionMode !== 'none' ? 48 : 0 })), pagination && jsxRuntime.jsx(GridPaginationFooter, { pagination: pagination, onPageChange: onPageChange })] }));
2285
+ }
2286
+
2287
+ /**
2288
+ * Lazily resolves a nested grid's children per parent row. `getChildren` may
2289
+ * return a sync array or a promise; results are cached by row key. grid-kit
2290
+ * never fetches — this just normalizes the consumer-supplied resolver and tracks
2291
+ * loading. Call `ensure(key, parent)` on demand (row expand / panel open / hover).
2292
+ */
2293
+ function useChildren(getChildren) {
2294
+ const [cache, setCache] = React.useState({});
2295
+ const cacheRef = React.useRef(cache);
2296
+ cacheRef.current = cache;
2297
+ // Guard against setState after unmount (e.g. host swapped while a child fetch
2298
+ // is in flight) — mirrors NestedTriggerCell / DetailPaneChildren.
2299
+ const mounted = React.useRef(true);
2300
+ React.useEffect(() => {
2301
+ mounted.current = true;
2302
+ return () => {
2303
+ mounted.current = false;
2304
+ };
2305
+ }, []);
2306
+ const ensure = React.useCallback((key, parent) => {
2307
+ if (cacheRef.current[key])
2308
+ return;
2309
+ const result = getChildren(parent);
2310
+ if (Array.isArray(result)) {
2311
+ setCache((p) => ({ ...p, [key]: { loading: false, rows: result } }));
2312
+ }
2313
+ else {
2314
+ setCache((p) => ({ ...p, [key]: { loading: true, rows: [] } }));
2315
+ Promise.resolve(result).then((rows) => {
2316
+ if (mounted.current)
2317
+ setCache((p) => ({ ...p, [key]: { loading: false, rows } }));
2318
+ });
2319
+ }
2320
+ }, [getChildren]);
2321
+ const get = React.useCallback((key) => cache[key], [cache]);
2322
+ return { get, ensure };
2323
+ }
2324
+
2325
+ // Leading control-column widths the parent footer must skip to line up its cells
2326
+ // with the data columns: the selection checkbox (Fluent default ~48px, present
2327
+ // only when the parent grid is selectable) and the always-present expand chevron.
2328
+ const SELECTION_CHECKBOX_WIDTH = 48;
2329
+ const EXPAND_CHEVRON_WIDTH = 32;
2330
+ function mapSelectionMode(mode) {
2331
+ return mode === 'multiple' ? react.SelectionMode.multiple : mode === 'single' ? react.SelectionMode.single : react.SelectionMode.none;
2332
+ }
2333
+ /**
2334
+ * Inline nested grid: a chevron column expands each parent row to reveal its
2335
+ * child grid indented beneath (Fluent `onRenderRow`). Children resolve lazily on
2336
+ * first expand.
2337
+ *
2338
+ * Known limitation: an expanded row's height varies with its child grid, which
2339
+ * defeats Fluent v8 DetailsList row-height virtualization — fine for typical
2340
+ * subgrid sizes; for very large parent lists prefer side-panel / hover-callout.
2341
+ */
2342
+ function NestedInline(props) {
2343
+ const { parentProps, ctx, getKeyFn } = props;
2344
+ const { items, columns, nested, selectionMode, isLoading, compact, alternateRowColors, pagination, onPageChange, aggregateItems, onSelectionChanged, rowCommands, } = parentProps;
2345
+ const { onItemContextMenu, contextMenuElement } = useRowContextMenu(rowCommands);
2346
+ const [expanded, setExpanded] = React.useState(new Set());
2347
+ const { get, ensure } = useChildren(nested.getChildren);
2348
+ // Forward parent-row selection to the consumer (DataGrid does this; inline did
2349
+ // not). A ref keeps the once-created Selection's callback from going stale when
2350
+ // the prop changes (mirrors DataGrid's onSelectionChangedRef).
2351
+ const onSelectionChangedRef = React.useRef(onSelectionChanged);
2352
+ onSelectionChangedRef.current = onSelectionChanged;
2353
+ // 'requires-parent' gating: a parent's child grid is only selectable while the
2354
+ // parent row is selected. Needs a managed parent Selection (the parent grid
2355
+ // must be selectable). Tracks selected parent keys to re-gate children on change.
2356
+ const gating = nested.childSelectionGating ?? 'independent';
2357
+ const parentSelectable = mapSelectionMode(selectionMode) !== react.SelectionMode.none;
2358
+ const [selectedParentKeys, setSelectedParentKeys] = React.useState(new Set());
2359
+ const selectionRef = React.useRef();
2360
+ if (!selectionRef.current) {
2361
+ selectionRef.current = new react.Selection({
2362
+ getKey: (it, index) => it == null ? `__shimmer_${index ?? 0}` : getKeyFn(it),
2363
+ onSelectionChanged: () => {
2364
+ const sel = selectionRef.current.getSelection();
2365
+ setSelectedParentKeys(new Set(sel.map((it) => getKeyFn(it))));
2366
+ onSelectionChangedRef.current?.(sel);
2367
+ },
2368
+ });
2369
+ }
2370
+ // Surface the silent misconfiguration: gating needs a selectable parent grid.
2371
+ React.useEffect(() => {
2372
+ if (gating === 'requires-parent' && !parentSelectable && typeof console !== 'undefined') {
2373
+ console.warn("[grid-kit] childSelectionGating 'requires-parent' has no effect: the parent grid is not selectable. Set the nested grid's selectionMode to 'single' or 'multiple'.");
2374
+ }
2375
+ }, [gating, parentSelectable]);
2376
+ const toggle = React.useCallback((key, item) => {
2377
+ setExpanded((prev) => {
2378
+ const next = new Set(prev);
2379
+ if (next.has(key))
2380
+ next.delete(key);
2381
+ else {
2382
+ next.add(key);
2383
+ ensure(key, item);
2384
+ }
2385
+ return next;
2386
+ });
2387
+ }, [ensure]);
2388
+ const dlColumns = React.useMemo(() => {
2389
+ const chevron = {
2390
+ key: '__expand',
2391
+ name: '',
2392
+ fieldName: '__expand',
2393
+ minWidth: 32,
2394
+ maxWidth: 32,
2395
+ isResizable: false,
2396
+ onRender: (item) => {
2397
+ if (!item)
2398
+ return null;
2399
+ const key = getKeyFn(item);
2400
+ const open = expanded.has(key);
2401
+ return (jsxRuntime.jsx(react.IconButton, { iconProps: { iconName: open ? 'ChevronDown' : 'ChevronRight' }, ariaLabel: open ? 'Collapse' : 'Expand', styles: { root: { height: 28, width: 28 } }, onClick: (e) => {
2402
+ e.stopPropagation();
2403
+ toggle(key, item);
2404
+ } }));
2405
+ },
2406
+ };
2407
+ return [chevron, ...toDetailsListColumns(columns, ctx)];
2408
+ }, [columns, ctx, expanded, getKeyFn, toggle]);
2409
+ const onRenderRow = React.useCallback((rowProps, defaultRender) => {
2410
+ if (!rowProps || !defaultRender)
2411
+ return null;
2412
+ const parent = rowProps.item;
2413
+ const key = getKeyFn(parent);
2414
+ // Tint the parent row only (not the expansion wrapper) — pass fresh styles.
2415
+ const row = alternateRowColors
2416
+ ? defaultRender({
2417
+ ...rowProps,
2418
+ styles: {
2419
+ root: {
2420
+ backgroundColor: rowProps.itemIndex % 2 === 0 ? alternateRowColors.even : alternateRowColors.odd,
2421
+ },
2422
+ },
2423
+ })
2424
+ : defaultRender(rowProps);
2425
+ if (!expanded.has(key))
2426
+ return row;
2427
+ const entry = get(key);
2428
+ // requires-parent: child selectable only while THIS parent row is selected.
2429
+ const childMode = gating === 'requires-parent' && !selectedParentKeys.has(key) ? 'none' : nested.childSelectionMode;
2430
+ return (jsxRuntime.jsxs("div", { children: [row, jsxRuntime.jsx("div", { onContextMenu: (e) => e.stopPropagation(), style: { padding: '8px 8px 8px 48px', background: '#faf9f8', borderBottom: '1px solid #edebe9' }, children: !entry || entry.loading ? (jsxRuntime.jsx(react.Spinner, { label: "Loading related records\u2026" })) : (jsxRuntime.jsx(DataGrid, { items: entry.rows, columns: nested.childColumns, registry: nested.childRegistry, compact: true, selectionMode: childMode, onSelectionChanged: nested.onChildSelectionChanged
2431
+ ? (sel) => nested.onChildSelectionChanged(parent, sel)
2432
+ : undefined, editable: nested.childEditable, editTrigger: nested.childEditTrigger, onValueChange: nested.onChildValueChange
2433
+ ? (rk, fn, v, ov) => nested.onChildValueChange(parent, rk, fn, v, ov)
2434
+ : undefined })) })] }));
2435
+ }, [expanded, get, getKeyFn, nested, alternateRowColors, gating, selectedParentKeys]);
2436
+ // Parent-level footers (mirrors DataGrid): aggregate row when any parent column
2437
+ // declares an `aggregate`, then the pager. Aggregates over `aggregateItems`
2438
+ // (cross-page grand totals) when given, else the displayed parent rows. The
2439
+ // footer cells skip the selection checkbox + expand chevron so they line up
2440
+ // with the data columns.
2441
+ const hasAggregate = columns.some((c) => c.aggregate);
2442
+ const footerLeadingSpacer = (parentSelectable ? SELECTION_CHECKBOX_WIDTH : 0) + EXPAND_CHEVRON_WIDTH;
2443
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(react.ShimmeredDetailsList, { items: items, columns: dlColumns, onRenderRow: onRenderRow, selectionMode: mapSelectionMode(selectionMode), selection: parentSelectable ? selectionRef.current : undefined, enableShimmer: isLoading, layoutMode: react.DetailsListLayoutMode.justified, compact: compact, setKey: "grid-kit-nested-inline", getKey: (it, index) => (it == null ? `__shimmer_${index ?? 0}` : getKeyFn(it)), onItemContextMenu: onItemContextMenu, styles: dynamicsCellRenderers.getDetailsListStyles(), ariaLabelForGrid: "Nested grid" }), contextMenuElement, hasAggregate && (jsxRuntime.jsx(GridAggregateFooter, { items: aggregateItems ?? items, columns: columns, leadingSpacer: footerLeadingSpacer })), pagination && jsxRuntime.jsx(GridPaginationFooter, { pagination: pagination, onPageChange: onPageChange })] }));
2444
+ }
2445
+
2446
+ /**
2447
+ * Card-parent nested grid (`parentLayout: 'cards'`): each parent row renders as a
2448
+ * card that expands IN PLACE to reveal its child grid below the card fields. Reuses
2449
+ * the shared card layout (`resolveCardLayout` + classes) for the card body and the
2450
+ * lazy `useChildren` hook for child resolution (grid-kit never fetches). v8-only.
2451
+ *
2452
+ * `nested.mode` does NOT apply to card parents — `<NestedGrid>` routes here on
2453
+ * `parentLayout === 'cards'` BEFORE the mode discriminator, so the side-panel /
2454
+ * hover-callout trigger machinery is bypassed.
2455
+ *
2456
+ * v1 limitation: `childSelectionGating: 'requires-parent'` is unsupported here — cards
2457
+ * use a manual checkbox `Set`, not a Fluent DetailsList `Selection` to gate against —
2458
+ * so it's treated as `'independent'` with a one-time `console.warn`.
2459
+ */
2460
+ function NestedCardParent(props) {
2461
+ const { parentProps, ctx, getKeyFn } = props;
2462
+ const { items, columns, nested, selectionMode = 'none', onSelectionChanged, toolbar, pagination, onPageChange, aggregateItems, fill, height, rowCommands, } = parentProps;
2463
+ // Per-row right-click menu (parity with DataGrid/NestedInline). For these raw-div
2464
+ // cards we invoke the hook fn from a native onContextMenu and suppress the browser
2465
+ // menu with e.preventDefault() ourselves (the hook's `return false` only matters to
2466
+ // Fluent's DetailsList onItemContextMenu contract).
2467
+ const { onItemContextMenu, contextMenuElement } = useRowContextMenu(rowCommands);
2468
+ const { cardsPerRow, titleField, subtitleField, imageField, cardHeight, byField, bodyColumns } = React.useMemo(() => resolveCardLayout(columns, nested.parentCard), [columns, nested.parentCard]);
2469
+ // Parent footers (parity with NestedInline / CardGrid): aggregate row when any
2470
+ // parent column declares an `aggregate`, then the pager. No DetailsList leading
2471
+ // control columns on a card grid, so the aggregate footer has no leading spacer.
2472
+ const hasAggregate = columns.some((c) => c.aggregate);
2473
+ const [selected, setSelected] = React.useState(new Set());
2474
+ const [expanded, setExpanded] = React.useState(new Set());
2475
+ const { get, ensure } = useChildren(nested.getChildren);
2476
+ // requires-parent gating needs a Fluent Selection over the parent rows; cards have
2477
+ // none, so surface the unsupported config and fall back to independent selection.
2478
+ React.useEffect(() => {
2479
+ if (nested.childSelectionGating === 'requires-parent' && typeof console !== 'undefined') {
2480
+ console.warn("[grid-kit] childSelectionGating 'requires-parent' is unsupported for card parents (cards have no Fluent Selection); treating as 'independent'.");
2481
+ }
2482
+ }, [nested.childSelectionGating]);
2483
+ const renderField = (col, item) => {
2484
+ if (!col)
2485
+ return null;
2486
+ const cellProps = buildCellProps(col, item, ctx);
2487
+ const Renderer = ctx.registry.resolve(col, false); // cards render read-only
2488
+ return jsxRuntime.jsx(Renderer, { ...cellProps });
2489
+ };
2490
+ const rawValue = (item, field) => {
2491
+ if (!field)
2492
+ return '';
2493
+ const v = item[field];
2494
+ return v == null ? '' : String(v);
2495
+ };
2496
+ const toggleSelect = (item, checked) => {
2497
+ const key = getKeyFn(item);
2498
+ setSelected((prev) => {
2499
+ const next = new Set(selectionMode === 'single' ? [] : prev);
2500
+ if (checked)
2501
+ next.add(key);
2502
+ else
2503
+ next.delete(key);
2504
+ onSelectionChanged?.(items.filter((it) => next.has(getKeyFn(it))));
2505
+ return next;
2506
+ });
2507
+ };
2508
+ const toggleExpand = React.useCallback((key, item) => {
2509
+ setExpanded((prev) => {
2510
+ const next = new Set(prev);
2511
+ if (next.has(key))
2512
+ next.delete(key);
2513
+ else {
2514
+ next.add(key);
2515
+ ensure(key, item);
2516
+ }
2517
+ return next;
2518
+ });
2519
+ }, [ensure]);
2520
+ return (jsxRuntime.jsxs(react.Stack, { styles: fillStackStyles(fill, height), children: [toolbar && jsxRuntime.jsx(GridToolbar, { config: toolbar }), jsxRuntime.jsx(FillRegion, { fill: fill, children: jsxRuntime.jsx("div", { style: {
2521
+ display: 'grid',
2522
+ gridTemplateColumns: `repeat(${cardsPerRow}, minmax(0, 1fr))`,
2523
+ gap: 12,
2524
+ paddingTop: 4,
2525
+ }, children: items.map((item) => {
2526
+ const key = getKeyFn(item);
2527
+ const open = expanded.has(key);
2528
+ const entry = open ? get(key) : undefined;
2529
+ return (jsxRuntime.jsxs("div", { className: cardClass, style: cardHeight ? { height: cardHeight } : undefined, onClick: () => toggleExpand(key, item),
2530
+ // Right-click anywhere on the card opens its row menu. The card is the
2531
+ // row-equivalent target, so the title/body cells, chevron, and checkbox
2532
+ // below keep only their onClick stopPropagation — a right-click on them
2533
+ // intentionally bubbles here (preventDefault suppresses the browser/anchor
2534
+ // menu). Do NOT add onContextMenu guards there or the menu dies on cells.
2535
+ // The expanded child region DOES stop it (see below).
2536
+ onContextMenu: onItemContextMenu
2537
+ ? (e) => {
2538
+ e.preventDefault();
2539
+ onItemContextMenu(item, undefined, e.nativeEvent);
2540
+ }
2541
+ : undefined, children: [jsxRuntime.jsxs(react.Stack, { horizontal: true, horizontalAlign: "space-between", verticalAlign: "start", children: [jsxRuntime.jsxs(react.Stack, { horizontal: true, verticalAlign: "center", tokens: { childrenGap: 4 }, children: [jsxRuntime.jsx(react.IconButton, { iconProps: { iconName: open ? 'ChevronDown' : 'ChevronRight' }, ariaLabel: open ? 'Collapse' : 'Expand', styles: { root: { height: 24, width: 24 } }, onClick: (e) => {
2542
+ e.stopPropagation();
2543
+ toggleExpand(key, item);
2544
+ } }), jsxRuntime.jsx(react.Text, { variant: "mediumPlus", styles: { root: { fontWeight: 600 } }, children: jsxRuntime.jsx("span", { onClick: (e) => e.stopPropagation(), children: renderField(byField.get(titleField ?? ''), item) ?? rawValue(item, titleField) }) })] }), selectionMode !== 'none' && (
2545
+ // Stop the click from bubbling to the card's expand toggle.
2546
+ jsxRuntime.jsx("span", { onClick: (e) => e.stopPropagation(), children: jsxRuntime.jsx(react.Checkbox, { checked: selected.has(key), onChange: (_, checked) => toggleSelect(item, checked), ariaLabel: "Select card" }) }))] }), imageField && rawValue(item, imageField) && (jsxRuntime.jsx("img", { className: cardImageClass, src: rawValue(item, imageField), alt: "" })), subtitleField && (jsxRuntime.jsx(react.Text, { variant: "small", styles: { root: { color: '#605e5c' } }, children: rawValue(item, subtitleField) })), bodyColumns.map((col) => (jsxRuntime.jsxs("div", { className: bodyRowClass, children: [jsxRuntime.jsx(react.Text, { variant: "small", styles: { root: { color: '#605e5c' } }, children: col.name }), jsxRuntime.jsx("span", { onClick: (e) => e.stopPropagation(), children: renderField(col, item) })] }, col.key))), open && (jsxRuntime.jsx("div", { style: { paddingTop: 8, marginTop: 4, borderTop: '1px solid #edebe9' }, onClick: (e) => e.stopPropagation(),
2547
+ // The child <DataGrid> renders INSIDE this card's DOM, so a right-click
2548
+ // on a child row would otherwise bubble to the card's onContextMenu and
2549
+ // fire the PARENT card's menu for the wrong record (the #93 / 2a8ff381
2550
+ // lesson; mirrors NestedInline's inline child-region guard). Children
2551
+ // have no own menu in v1 → the native browser menu is left intact here.
2552
+ onContextMenu: (e) => e.stopPropagation(), children: !entry || entry.loading ? (jsxRuntime.jsx(react.Spinner, { label: "Loading related records\u2026" })) : (jsxRuntime.jsx(DataGrid, { items: entry.rows, columns: nested.childColumns, registry: nested.childRegistry, compact: true, selectionMode: nested.childSelectionMode, onSelectionChanged: nested.onChildSelectionChanged
2553
+ ? (sel) => nested.onChildSelectionChanged(item, sel)
2554
+ : undefined, editable: nested.childEditable, editTrigger: nested.childEditTrigger, onValueChange: nested.onChildValueChange
2555
+ ? (rk, fn, v, ov) => nested.onChildValueChange(item, rk, fn, v, ov)
2556
+ : undefined })) }))] }, key));
2557
+ }) }) }), hasAggregate && (jsxRuntime.jsx(GridAggregateFooter, { items: aggregateItems ?? items, columns: columns, leadingSpacer: 0 })), pagination && jsxRuntime.jsx(GridPaginationFooter, { pagination: pagination, onPageChange: onPageChange }), contextMenuElement] }));
2558
+ }
2559
+
2560
+ /**
2561
+ * Resolve a nested trigger label, substituting the `{count}` token. When the
2562
+ * count isn't known yet (children not loaded), the token is dropped and extra
2563
+ * whitespace collapsed (e.g. "View {count} related" → "View related").
2564
+ */
2565
+ function formatTriggerLabel(label, count) {
2566
+ const base = label ?? 'View {count} related';
2567
+ if (count === undefined) {
2568
+ return base.replace(/\{count\}\s*/g, '').replace(/\s+/g, ' ').trim();
2569
+ }
2570
+ return base.replace(/\{count\}/g, String(count));
2571
+ }
2572
+ /** Map a side-panel size token to a Fluent v8 `PanelType`. */
2573
+ function panelTypeFor(size) {
2574
+ return size === 'small' ? react.PanelType.smallFixedFar : size === 'large' ? react.PanelType.large : react.PanelType.medium;
2575
+ }
2576
+
2577
+ /**
2578
+ * Per-parent-row trigger cell for `side-panel` and `hover-callout` nested modes.
2579
+ * Children are resolved **lazily** — on click (panel) or first hover (callout) —
2580
+ * so a 50-row page doesn't fire 50 fetches up front.
2581
+ */
2582
+ function NestedTriggerCell(props) {
2583
+ const { parent, nested } = props;
2584
+ const [entry, setEntry] = React.useState(null);
2585
+ const [open, setOpen] = React.useState(false);
2586
+ const [hovered, setHovered] = React.useState(false);
2587
+ const hostRef = React.useRef(null);
2588
+ const hoverTimer = React.useRef();
2589
+ const mounted = React.useRef(true);
2590
+ React.useEffect(() => {
2591
+ mounted.current = true;
2592
+ return () => {
2593
+ mounted.current = false;
2594
+ if (hoverTimer.current)
2595
+ clearTimeout(hoverTimer.current);
2596
+ };
2597
+ }, []);
2598
+ // Resolve children once, on demand.
2599
+ const load = React.useCallback(() => {
2600
+ setEntry((prev) => {
2601
+ if (prev)
2602
+ return prev;
2603
+ const result = nested.getChildren(parent);
2604
+ if (Array.isArray(result))
2605
+ return { loading: false, rows: result };
2606
+ Promise.resolve(result).then((rows) => mounted.current && setEntry({ loading: false, rows }));
2607
+ return { loading: true, rows: [] };
2608
+ });
2609
+ }, [parent, nested]);
2610
+ const count = entry && !entry.loading ? entry.rows.length : undefined;
2611
+ if (nested.mode === 'hover-callout') {
2612
+ const max = nested.calloutMaxRows ?? 5;
2613
+ const preview = entry ? entry.rows.slice(0, max) : [];
2614
+ return (jsxRuntime.jsxs("div", { ref: hostRef, style: { display: 'inline-block' }, onMouseEnter: () => {
2615
+ load();
2616
+ hoverTimer.current = setTimeout(() => mounted.current && setHovered(true), nested.hoverDelay ?? 300);
2617
+ }, onMouseLeave: () => {
2618
+ if (hoverTimer.current)
2619
+ clearTimeout(hoverTimer.current);
2620
+ setHovered(false);
2621
+ }, children: [jsxRuntime.jsx(react.Text, { variant: "small", styles: { root: { background: '#edebe9', borderRadius: 10, padding: '2px 10px', cursor: 'default' } }, children: entry?.loading ? '…' : count !== undefined ? `${count} related` : 'related' }), hovered && entry && !entry.loading && entry.rows.length > 0 && (jsxRuntime.jsx(react.Callout, { target: hostRef.current, onDismiss: () => setHovered(false), directionalHint: react.DirectionalHint.bottomLeftEdge, isBeakVisible: false, gapSpace: 4, children: jsxRuntime.jsxs("div", { style: { padding: 8, minWidth: 360, maxWidth: 560 }, children: [jsxRuntime.jsx(ReadOnlyGrid, { items: preview, columns: nested.childColumns, registry: nested.childRegistry, compact: true }), entry.rows.length > max && (jsxRuntime.jsxs(react.Text, { variant: "small", styles: { root: { color: '#605e5c', paddingTop: 4 } }, children: ["+", entry.rows.length - max, " more"] }))] }) }))] }));
2622
+ }
2623
+ // side-panel (default for triggered modes) — fetch on open.
2624
+ const label = formatTriggerLabel(nested.triggerLabel, count);
2625
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(react.ActionButton, { iconProps: { iconName: nested.triggerIcon ?? 'OpenPaneMirrored' }, onClick: () => {
2626
+ load();
2627
+ setOpen(true);
2628
+ }, styles: { root: { height: 28 } }, children: label }), jsxRuntime.jsx(react.Panel, { isOpen: open, onDismiss: () => setOpen(false), type: panelTypeFor(nested.panelSize), headerText: label, closeButtonAriaLabel: "Close", children: !entry || entry.loading ? (jsxRuntime.jsx(react.Spinner, { label: "Loading related records\u2026" })) : (jsxRuntime.jsx(DataGrid, { items: entry.rows, columns: nested.childColumns, registry: nested.childRegistry, compact: true, selectionMode: nested.childSelectionMode, onSelectionChanged: nested.onChildSelectionChanged ? (sel) => nested.onChildSelectionChanged(parent, sel) : undefined, editable: nested.childEditable, editTrigger: nested.childEditTrigger, onValueChange: nested.onChildValueChange
2629
+ ? (rk, fn, v, ov) => nested.onChildValueChange(parent, rk, fn, v, ov)
2630
+ : undefined })) })] }));
2631
+ }
2632
+
2633
+ /**
2634
+ * Nested / expandable grid host. Dispatch order:
2635
+ * 1. `parentLayout === 'cards'` — each parent is a card that expands in place
2636
+ * (`<NestedCardParent>`); `mode` is ignored.
2637
+ * Otherwise, parents are rows and child records surface per `nested.mode`:
2638
+ * - `inline` — chevron expands the child grid under the row
2639
+ * - `side-panel` — a trigger opens a Fluent Panel with the child grid
2640
+ * - `hover-callout` — a count badge shows a compact callout of top-N rows
2641
+ * - `detail-pane` — falls back to `side-panel` here (use `<FocusedViewGrid>`
2642
+ * for true master-detail with a detail-pane child)
2643
+ *
2644
+ * grid-kit never fetches — `nested.getChildren(parent)` (sync or async) owns it.
2645
+ */
2646
+ function NestedGrid(props) {
2647
+ const { columns, nested } = props;
2648
+ const edit = useEditState();
2649
+ const { getKeyFn, ctx } = useGridContext(props, edit);
2650
+ // Append a trigger column for the non-inline modes (its cell manages its own
2651
+ // panel/callout). Built before any early return to keep hook order stable.
2652
+ const triggeredColumns = React.useMemo(() => {
2653
+ const trigger = {
2654
+ key: '__nested',
2655
+ fieldName: '__nested',
2656
+ name: 'Related',
2657
+ rendererType: 'text',
2658
+ minWidth: 160,
2659
+ onRender: (item) => jsxRuntime.jsx(NestedTriggerCell, { parent: item, nested: nested }),
2660
+ };
2661
+ return [...columns, trigger];
2662
+ }, [columns, nested]);
2663
+ // Card parents win over `mode`: each parent renders as a card that expands in place.
2664
+ if (nested.parentLayout === 'cards') {
2665
+ return jsxRuntime.jsx(NestedCardParent, { parentProps: props, ctx: ctx, getKeyFn: getKeyFn });
2666
+ }
2667
+ if (nested.mode === 'inline') {
2668
+ return jsxRuntime.jsx(NestedInline, { parentProps: props, ctx: ctx, getKeyFn: getKeyFn });
2669
+ }
2670
+ // side-panel, hover-callout (and detail-pane fallback) → DataGrid + trigger column
2671
+ return jsxRuntime.jsx(DataGrid, { ...props, columns: triggeredColumns });
2672
+ }
2673
+
2674
+ const railClass = react.mergeStyles({ width: 320, minWidth: 260, borderRight: '1px solid #edebe9', overflowY: 'auto' });
2675
+ const railItemBase = {
2676
+ padding: '10px 12px',
2677
+ borderBottom: '1px solid #f3f2f1',
2678
+ cursor: 'pointer',
2679
+ display: 'flex',
2680
+ gap: 10,
2681
+ alignItems: 'flex-start',
2682
+ };
2683
+ const detailRowClass = react.mergeStyles({
2684
+ display: 'flex',
2685
+ justifyContent: 'space-between',
2686
+ gap: 12,
2687
+ padding: '6px 0',
2688
+ borderBottom: '1px solid #f3f2f1',
2689
+ fontSize: 13,
2690
+ });
2691
+ /**
2692
+ * Focused-view / master-detail host. A left rail of summary items (each rendered
2693
+ * from `focusedView.rows`) drives a right detail pane (the active record's
2694
+ * `detailFields`, each value through the registry). When a `nested` config with
2695
+ * `mode: 'detail-pane'` is supplied, the child grid renders inside the pane.
2696
+ */
2697
+ function FocusedViewGrid(props) {
2698
+ const { items, columns, focusedView, nested, onActiveItemChanged, fill, height, rowCommands } = props;
2699
+ // The detail pane renders read-only (registry.resolve(col, false)); useEditState
2700
+ // is threaded only because useGridContext bundles it — no inline editing here.
2701
+ const edit = useEditState();
2702
+ const { getKeyFn, ctx } = useGridContext(props, edit);
2703
+ // Per-row right-click menu on the left rail (parity with DataGrid/NestedInline). The
2704
+ // rail items are raw divs, so we invoke the hook fn from a native onContextMenu and
2705
+ // suppress the browser menu with e.preventDefault() (the hook's `return false` only
2706
+ // matters to Fluent's DetailsList contract). No child stopPropagation is needed: the
2707
+ // detail pane is a SIBLING of the rail (not nested inside a rail item), unlike the
2708
+ // inline child grids in NestedInline / NestedCardParent.
2709
+ const { onItemContextMenu, contextMenuElement } = useRowContextMenu(rowCommands);
2710
+ const [activeKey, setActiveKey] = React.useState(() => (items[0] ? getKeyFn(items[0]) : undefined));
2711
+ const [search, setSearch] = React.useState('');
2712
+ const byField = React.useMemo(() => {
2713
+ const m = new Map();
2714
+ columns.forEach((c) => m.set(c.fieldName, c));
2715
+ return m;
2716
+ }, [columns]);
2717
+ const filtered = React.useMemo(() => {
2718
+ const q = search.trim().toLowerCase();
2719
+ if (!q)
2720
+ return items;
2721
+ const fields = focusedView.rows.flatMap((r) => [r.primaryField.fieldName, r.secondaryField?.fieldName].filter(Boolean));
2722
+ return items.filter((it) => fields.some((f) => String(getFieldValue(it, f) ?? '').toLowerCase().includes(q)));
2723
+ }, [items, search, focusedView.rows]);
2724
+ const active = React.useMemo(() => items.find((it) => getKeyFn(it) === activeKey), [items, activeKey, getKeyFn]);
2725
+ const detailColumns = React.useMemo(() => {
2726
+ if (!focusedView.detailFields)
2727
+ return columns;
2728
+ return focusedView.detailFields.map((f) => byField.get(f)).filter(Boolean);
2729
+ }, [focusedView.detailFields, byField, columns]);
2730
+ const fieldText = (item, field) => {
2731
+ if (!field)
2732
+ return '';
2733
+ const v = getFieldValue(item, field.fieldName);
2734
+ return v == null ? '' : String(v);
2735
+ };
2736
+ const select = (item) => {
2737
+ setActiveKey(getKeyFn(item));
2738
+ onActiveItemChanged?.(item);
2739
+ };
2740
+ const renderDetailValue = (col, item) => {
2741
+ const cellProps = buildCellProps(col, item, ctx);
2742
+ const Renderer = ctx.registry.resolve(col, false);
2743
+ return jsxRuntime.jsx(Renderer, { ...cellProps });
2744
+ };
2745
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs(react.Stack, { horizontal: true, styles: { root: { border: '1px solid #edebe9', borderRadius: 4, height: height ?? (fill ? '100%' : 460) } }, children: [jsxRuntime.jsxs("div", { className: railClass, children: [jsxRuntime.jsx("div", { style: { padding: 8, borderBottom: '1px solid #edebe9' }, children: jsxRuntime.jsx(react.SearchBox, { placeholder: "Search", onChange: (_, v) => setSearch(v ?? ''), underlined: true }) }), filtered.map((item) => {
2746
+ const key = getKeyFn(item);
2747
+ const isActive = key === activeKey;
2748
+ return (jsxRuntime.jsxs("div", { style: { ...railItemBase, background: isActive ? '#e1efff' : undefined, borderLeft: isActive ? '3px solid #0078d4' : '3px solid transparent' }, onClick: () => select(item), onContextMenu: onItemContextMenu
2749
+ ? (e) => {
2750
+ e.preventDefault();
2751
+ onItemContextMenu(item, undefined, e.nativeEvent);
2752
+ }
2753
+ : undefined, children: [focusedView.rows[0]?.iconName && jsxRuntime.jsx(react.Icon, { iconName: focusedView.rows[0].iconName, styles: { root: { fontSize: 18, color: '#0078d4' } } }), jsxRuntime.jsx(react.Stack, { tokens: { childrenGap: 2 }, children: focusedView.rows.map((row) => (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsx(react.Text, { variant: "medium", styles: { root: { fontWeight: 600 } }, children: fieldText(item, row.primaryField) }), row.secondaryField && (jsxRuntime.jsx(react.Text, { variant: "small", styles: { root: { color: '#605e5c', paddingLeft: 6 } }, children: fieldText(item, row.secondaryField) }))] }, row.id))) })] }, key));
2754
+ })] }), jsxRuntime.jsx("div", { style: { flex: 1, padding: 16, overflowY: 'auto' }, children: !active ? (jsxRuntime.jsx(react.Text, { variant: "medium", styles: { root: { color: '#605e5c' } }, children: "Select a record to see its details." })) : focusedView.childOnly ? (
2755
+ // childOnly: the pane IS the related-records grid — no title heading, no
2756
+ // detail-field list, no "Related" subheading. Pure master/detail.
2757
+ nested ? (jsxRuntime.jsx(DetailPaneChildren, { parent: active, nested: nested })) : (jsxRuntime.jsx(react.Text, { variant: "medium", styles: { root: { color: '#605e5c' } }, children: "No related records configured." }))) : (jsxRuntime.jsxs(react.Stack, { tokens: { childrenGap: 4 }, children: [jsxRuntime.jsx(react.Text, { variant: "large", styles: { root: { fontWeight: 600, marginBottom: 8 } }, children: fieldText(active, focusedView.rows[0]?.primaryField) }), detailColumns.map((col) => (jsxRuntime.jsxs("div", { className: detailRowClass, children: [jsxRuntime.jsx(react.Text, { variant: "small", styles: { root: { color: '#605e5c' } }, children: col.name }), jsxRuntime.jsx("span", { children: renderDetailValue(col, active) })] }, col.key))), nested && nested.mode === 'detail-pane' && (jsxRuntime.jsxs("div", { style: { paddingTop: 16 }, children: [jsxRuntime.jsx(react.Text, { variant: "mediumPlus", styles: { root: { fontWeight: 600 } }, children: "Related" }), jsxRuntime.jsx(DetailPaneChildren, { parent: active, nested: nested })] }))] })) })] }), contextMenuElement] }));
2758
+ }
2759
+ /** Resolves + renders the detail-pane child grid for the active record. */
2760
+ function DetailPaneChildren(props) {
2761
+ const { parent, nested } = props;
2762
+ const [rows, setRows] = React.useState(null);
2763
+ React.useEffect(() => {
2764
+ let alive = true;
2765
+ const result = nested.getChildren(parent);
2766
+ if (Array.isArray(result))
2767
+ setRows(result);
2768
+ else {
2769
+ setRows(null);
2770
+ Promise.resolve(result).then((r) => alive && setRows(r));
2771
+ }
2772
+ return () => {
2773
+ alive = false;
2774
+ };
2775
+ }, [parent, nested]);
2776
+ if (rows === null)
2777
+ return jsxRuntime.jsx(react.Text, { variant: "small", children: "Loading\u2026" });
2778
+ return (jsxRuntime.jsx(DataGrid, { items: rows, columns: nested.childColumns, registry: nested.childRegistry, compact: true, selectionMode: nested.childSelectionMode, onSelectionChanged: nested.onChildSelectionChanged ? (sel) => nested.onChildSelectionChanged(parent, sel) : undefined, editable: nested.childEditable, editTrigger: nested.childEditTrigger, onValueChange: nested.onChildValueChange
2779
+ ? (rk, fn, v, ov) => nested.onChildValueChange(parent, rk, fn, v, ov)
2780
+ : undefined }));
2781
+ }
2782
+
2783
+ Object.defineProperty(exports, "getContrastColor", {
2784
+ enumerable: true,
2785
+ get: function () { return dynamicsUtils.getContrastColor; }
2786
+ });
2787
+ Object.defineProperty(exports, "normalizeHexColor", {
2788
+ enumerable: true,
2789
+ get: function () { return dynamicsUtils.normalizeHexColor; }
2790
+ });
2791
+ exports.CardGrid = CardGrid;
2792
+ exports.DataGrid = DataGrid;
2793
+ exports.DetailsGrid = DataGrid;
2794
+ exports.DroppableCell = DroppableCell;
2795
+ exports.FileDropCell = FileDropCell;
2796
+ exports.FocusedViewGrid = FocusedViewGrid;
2797
+ exports.GridColumnChooser = GridColumnChooser;
2798
+ exports.GridExportDialog = GridExportDialog;
2799
+ exports.GridFilterBuilder = GridFilterBuilder;
2800
+ exports.GridToolbar = GridToolbar;
2801
+ exports.GroupedGrid = GroupedGrid;
2802
+ exports.NestedGrid = NestedGrid;
2803
+ exports.ReadOnlyGrid = ReadOnlyGrid;
2804
+ exports.applyFilters = applyFilters;
2805
+ exports.buildCellProps = buildCellProps;
2806
+ exports.buildGroups = buildGroups;
2807
+ exports.computeAggregate = computeAggregate;
2808
+ exports.computeAggregates = computeAggregates;
2809
+ exports.computeFormattedValue = computeFormattedValue;
2810
+ exports.computeGroupAggregates = computeGroupAggregates;
2811
+ exports.createCellRegistry = createCellRegistry;
2812
+ exports.defaultGetKey = defaultGetKey;
2813
+ exports.evalCondition = evalCondition;
2814
+ exports.exportGrid = exportGrid;
2815
+ exports.fieldTypeForRenderer = fieldTypeForRenderer;
2816
+ exports.fromGridCustomizerColumn = fromGridCustomizerColumn;
2817
+ exports.fromGridCustomizerDefinition = fromGridCustomizerDefinition;
2818
+ exports.getFieldValue = getFieldValue;
2819
+ exports.getOperatorsForFieldType = getOperatorsForFieldType;
2820
+ exports.getSuppliedFormattedValue = getSuppliedFormattedValue;
2821
+ exports.isConditionComplete = isConditionComplete;
2822
+ exports.isFileAccepted = isFileAccepted;
2823
+ exports.makeEditWrapper = makeEditWrapper;
2824
+ exports.makeReadWrapper = makeReadWrapper;
2825
+ exports.mapFormRuntimeRendererType = mapFormRuntimeRendererType;
2826
+ exports.navigateToTarget = navigateToTarget;
2827
+ exports.operatorIsMultiValue = operatorIsMultiValue;
2828
+ exports.operatorRequiresValue = operatorRequiresValue;
2829
+ exports.orderVisibleColumns = orderVisibleColumns;
2830
+ exports.rendererToFieldType = rendererToFieldType;
2831
+ exports.resolveCellColor = resolveCellColor;
2832
+ exports.resolveExportColumns = resolveExportColumns;
2833
+ exports.resolveGridFromDefinition = resolveGridFromDefinition;
2834
+ exports.resolveThreshold = resolveThreshold;
2835
+ exports.toBool = toBool;
2836
+ exports.toDate = toDate;
2837
+ exports.toDetailsListColumns = toDetailsListColumns;
2838
+ exports.toDisplayString = toDisplayString;
2839
+ exports.toExportColumns = toExportColumns;
2840
+ exports.toGridCustomizerOverrides = toGridCustomizerOverrides;
2841
+ exports.toNumber = toNumber;
2842
+ exports.toReusableColumn = toReusableColumn;
2843
+ exports.useChildren = useChildren;
2844
+ exports.useEditState = useEditState;
2845
+ exports.useFileDrop = useFileDrop;
2846
+ exports.useGridContext = useGridContext;
2847
+ //# sourceMappingURL=index.js.map