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