@xh/hoist 86.2.0 → 86.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.gitattributes +4 -0
  2. package/CHANGELOG.md +75 -0
  3. package/admin/AppModel.ts +38 -11
  4. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +56 -6
  5. package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +9 -26
  6. package/admin/tabs/clients/ClientsModel.ts +4 -1
  7. package/admin/tabs/clients/activity/ClientDetailModel.ts +5 -1
  8. package/admin/tabs/userData/roles/RoleModel.ts +11 -18
  9. package/admin/tabs/userData/roles/RolePanel.ts +2 -9
  10. package/appcontainer/RouterModel.ts +6 -6
  11. package/build/types/admin/AppModel.d.ts +7 -4
  12. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +14 -3
  13. package/build/types/admin/tabs/clients/ClientsModel.d.ts +3 -0
  14. package/build/types/admin/tabs/userData/roles/RoleModel.d.ts +2 -2
  15. package/build/types/appcontainer/RouterModel.d.ts +5 -5
  16. package/build/types/cmp/grid/Grid.d.ts +2 -1
  17. package/build/types/cmp/grid/GridModel.d.ts +2 -2
  18. package/build/types/cmp/grid/Types.d.ts +8 -1
  19. package/build/types/cmp/input/SegmentedControlOption.d.ts +12 -0
  20. package/build/types/core/HoistAppModel.d.ts +2 -3
  21. package/build/types/core/types/Types.d.ts +12 -0
  22. package/build/types/data/Store.d.ts +3 -2
  23. package/build/types/desktop/cmp/filter/FilterChooser.d.ts +5 -0
  24. package/build/types/desktop/cmp/filter/PopoverFilterChooser.d.ts +3 -1
  25. package/build/types/desktop/cmp/grid/impl/colchooser/ColChooserModel.d.ts +2 -2
  26. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.d.ts +9 -1
  27. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.d.ts +8 -2
  28. package/build/types/desktop/cmp/input/Select.d.ts +8 -0
  29. package/build/types/kit/react-dropzone/index.d.ts +1 -0
  30. package/build/types/kit/swiper/index.d.ts +4 -3
  31. package/build/types/mobile/cmp/grid/impl/ColChooserModel.d.ts +2 -2
  32. package/build/types/mobile/cmp/input/Select.d.ts +8 -0
  33. package/build/types/svc/PrefService.d.ts +12 -1
  34. package/build/types/utils/datetime/LocalDate.d.ts +23 -1
  35. package/cmp/grid/Grid.ts +10 -2
  36. package/cmp/grid/GridModel.ts +4 -3
  37. package/cmp/grid/Types.ts +9 -1
  38. package/cmp/grid/impl/MenuSupport.ts +1 -1
  39. package/cmp/input/SegmentedControlOption.ts +14 -0
  40. package/cmp/store/impl/StoreFilterFieldImplModel.ts +5 -6
  41. package/core/HoistAppModel.ts +2 -3
  42. package/core/types/Types.ts +14 -5
  43. package/data/Store.ts +3 -2
  44. package/desktop/cmp/filechooser/FileChooser.ts +6 -3
  45. package/desktop/cmp/filechooser/FileChooserModel.ts +12 -5
  46. package/desktop/cmp/filter/FilterChooser.scss +42 -0
  47. package/desktop/cmp/filter/FilterChooser.ts +147 -7
  48. package/desktop/cmp/filter/PopoverFilterChooser.ts +7 -84
  49. package/desktop/cmp/grid/editors/impl/InlineEditorModel.ts +6 -1
  50. package/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts +3 -5
  51. package/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts +2 -2
  52. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRow.ts +12 -12
  53. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +18 -0
  54. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.scss +5 -0
  55. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.ts +13 -2
  56. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.ts +97 -14
  57. package/desktop/cmp/input/SegmentedControl.ts +7 -3
  58. package/desktop/cmp/input/Select.ts +24 -16
  59. package/docs/routing.md +4 -2
  60. package/docs/upgrade-notes/v86-upgrade-notes.md +73 -8
  61. package/kit/react-dropzone/index.ts +1 -0
  62. package/mcp/README.md +12 -0
  63. package/mcp/data/ts-registry.spec.ts +156 -0
  64. package/mcp/data/ts-registry.ts +13 -6
  65. package/mcp/formatters/typescript.ts +7 -3
  66. package/mcp/util/paths.ts +21 -0
  67. package/mobile/cmp/grid/impl/ColChooserModel.ts +2 -2
  68. package/mobile/cmp/input/SegmentedControl.ts +7 -3
  69. package/mobile/cmp/input/Select.ts +21 -15
  70. package/mobx/README.md +2 -1
  71. package/package.json +5 -5
  72. package/svc/PrefService.ts +80 -20
  73. package/svc/README.md +6 -0
  74. package/utils/datetime/LocalDate.ts +24 -1
  75. package/desktop/cmp/filter/PopoverFilterChooser.scss +0 -42
@@ -5,12 +5,18 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {HoistModel, XH} from '@xh/hoist/core';
8
- import {CompoundFilterOperator, FilterLike} from '@xh/hoist/data';
8
+ import {
9
+ CompoundFilterOperator,
10
+ FieldFilter,
11
+ FieldFilterOperator,
12
+ FieldFilterSpec,
13
+ FilterLike
14
+ } from '@xh/hoist/data';
9
15
  import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx';
10
- import {compact, isEmpty} from 'lodash';
16
+ import {compact, first, flatMap, forEach, groupBy, isArray, isEmpty, uniq} from 'lodash';
11
17
  import {HeaderFilterModel} from '../HeaderFilterModel';
12
18
 
13
- import {CustomRowModel} from './CustomRowModel';
19
+ import {CustomRowModel, usesMultiValueInput} from './CustomRowModel';
14
20
 
15
21
  export class CustomTabModel extends HoistModel {
16
22
  override xhImpl = true;
@@ -23,11 +29,16 @@ export class CustomTabModel extends HoistModel {
23
29
  /** Filter config output by this model. */
24
30
  @computed.struct
25
31
  get filter(): FilterLike {
26
- const {op, rowModels} = this,
27
- filters = compact(rowModels.map(it => it.value));
28
- if (isEmpty(filters)) return null;
29
- if (filters.length > 1) return {filters, op};
30
- return filters;
32
+ const {op, rowModels} = this;
33
+
34
+ // Null rowModels flags an unrepresentable filter - emit it unchanged so commit is a no-op.
35
+ if (!rowModels) return this.columnCompoundFilter ?? this.columnFilters;
36
+
37
+ const specs = compact(rowModels.map(it => it.value));
38
+ if (isEmpty(specs)) return null;
39
+
40
+ const filters = this.collapseToArrayFilters(specs, op);
41
+ return filters.length > 1 ? {filters, op} : first(filters);
31
42
  }
32
43
 
33
44
  get fieldSpec() {
@@ -42,6 +53,10 @@ export class CustomTabModel extends HoistModel {
42
53
  return this.headerFilterModel.columnFilters;
43
54
  }
44
55
 
56
+ get columnCompoundFilter() {
57
+ return this.headerFilterModel.columnCompoundFilter;
58
+ }
59
+
45
60
  constructor(headerFilterModel: HeaderFilterModel) {
46
61
  super();
47
62
  makeObservable(this);
@@ -74,13 +89,27 @@ export class CustomTabModel extends HoistModel {
74
89
  //-------------------
75
90
  @action
76
91
  private doSyncWithFilter() {
77
- const {columnFilters} = this,
78
- rowModels = [];
92
+ const {columnCompoundFilter} = this,
93
+ op = this.deriveOp();
94
+ this.op = op;
95
+
96
+ if (!this.isRepresentable) {
97
+ this.logWarn('Filter cannot be edited in the custom tab; leaving it unchanged');
98
+ this.rowModels = null;
99
+ return;
100
+ }
79
101
 
80
- // Create rows based on filter.
81
- columnFilters.forEach(filter => {
82
- const {op, value} = filter;
83
- rowModels.push(new CustomRowModel(this, op, value));
102
+ // Expand a multi-value clause destined for a single-value input into one row per value
103
+ // (joined under the tab op); the multi-select input holds the array directly as one row.
104
+ const rowModels = [],
105
+ children = (columnCompoundFilter?.filters ?? this.columnFilters) as FieldFilter[];
106
+ children.forEach(filter => {
107
+ const {op: fieldOp, value} = filter;
108
+ if (this.needsExpansion(filter)) {
109
+ value.forEach(v => rowModels.push(new CustomRowModel(this, fieldOp, v)));
110
+ } else {
111
+ rowModels.push(new CustomRowModel(this, fieldOp, value));
112
+ }
84
113
  });
85
114
 
86
115
  // Add an empty pending row
@@ -90,4 +119,58 @@ export class CustomTabModel extends HoistModel {
90
119
 
91
120
  this.rowModels = rowModels;
92
121
  }
122
+
123
+ // Whether a multi-value clause must be expanded to one row per value (vs held by a multi-select).
124
+ private needsExpansion({op, value}: FieldFilter): boolean {
125
+ return isArray(value) && value.length > 1 && !usesMultiValueInput(this.fieldSpec, op);
126
+ }
127
+
128
+ // The op a multi-value clause joins under: `=`-family => OR, negated => AND.
129
+ private mergeOpFor(op: FieldFilterOperator): CompoundFilterOperator {
130
+ return FieldFilter.INCLUDE_LIKE_OPERATORS.includes(op) ? 'OR' : 'AND';
131
+ }
132
+
133
+ // Op joining the tab's rows
134
+ private deriveOp(): CompoundFilterOperator {
135
+ const {columnCompoundFilter, columnFilters} = this;
136
+ if (columnCompoundFilter) return columnCompoundFilter.op;
137
+
138
+ const arrayFilter = columnFilters.find(f => this.needsExpansion(f));
139
+ return arrayFilter ? this.mergeOpFor(arrayFilter.op) : 'AND';
140
+ }
141
+
142
+ // Filter must be representable as a flat set of rows with a single op
143
+ private get isRepresentable(): boolean {
144
+ const {columnCompoundFilter} = this,
145
+ op = this.deriveOp(),
146
+ children = columnCompoundFilter?.filters ?? this.columnFilters;
147
+ return children.every(
148
+ f =>
149
+ FieldFilter.isFieldFilter(f) &&
150
+ (!this.needsExpansion(f) || op === this.mergeOpFor(f.op))
151
+ );
152
+ }
153
+
154
+ // Inverse of the expand in `doSyncWithFilter`: collapse same-field/op rows into one multi-value FieldFilter
155
+ private collapseToArrayFilters(
156
+ specs: FieldFilterSpec[],
157
+ op: CompoundFilterOperator
158
+ ): FieldFilterSpec[] {
159
+ const ret: FieldFilterSpec[] = [];
160
+ forEach(groupBy(specs, 'op'), (groupSpecs, groupOp: FieldFilterOperator) => {
161
+ const canMerge =
162
+ groupSpecs.length > 1 &&
163
+ FieldFilter.ARRAY_OPERATORS.includes(groupOp) &&
164
+ op === this.mergeOpFor(groupOp);
165
+
166
+ if (canMerge) {
167
+ const {field} = groupSpecs[0],
168
+ value = uniq(flatMap(groupSpecs, it => it.value));
169
+ ret.push({field, op: groupOp, value});
170
+ } else {
171
+ ret.push(...groupSpecs);
172
+ }
173
+ });
174
+ return ret;
175
+ }
93
176
  }
@@ -80,6 +80,7 @@ export const [SegmentedControl, segmentedControl] = hoistCmp.withFactory<Segment
80
80
  interface NormalizedOption extends SegmentedControlOption {
81
81
  label: string;
82
82
  intent?: Intent;
83
+ testId?: string;
83
84
  _key: string;
84
85
  }
85
86
 
@@ -92,13 +93,14 @@ class SegmentedControlModel extends HoistInputModel {
92
93
  return options.map((o: any, idx: number) => {
93
94
  const key = String(idx);
94
95
  if (isObject(o)) {
95
- const {label, value, icon, disabled, intent} = o as SegmentedControlOption;
96
+ const {label, value, icon, disabled, intent, testId} = o as SegmentedControlOption;
96
97
  return {
97
98
  value: this.toInternal(value),
98
99
  label: label ?? (icon ? '' : String(value)),
99
100
  icon,
100
101
  disabled,
101
102
  intent,
103
+ testId,
102
104
  _key: key
103
105
  };
104
106
  } else {
@@ -163,13 +165,15 @@ const cmp = hoistCmp.factory<SegmentedControlModel>(({model, className, ...props
163
165
  // applied via a per-button className that our SCSS keys its solid/hint coloring off of.
164
166
  const defaultIntent = intent && intent !== 'none' ? intent : null,
165
167
  bpOptions = model.normalizedOptions.map(opt => {
166
- const optIntent = opt.intent ?? defaultIntent;
168
+ const optIntent = opt.intent ?? defaultIntent,
169
+ optTestId = opt.testId ?? (testId ? `${testId}-${String(opt.value)}` : null);
167
170
  return {
168
171
  value: opt._key,
169
172
  label: opt.label,
170
173
  icon: opt.icon,
171
174
  disabled: opt.disabled,
172
- className: optIntent ? `xh-segmented-control-option--${optIntent}` : null
175
+ className: optIntent ? `xh-segmented-control-option--${optIntent}` : null,
176
+ ...(optTestId ? {[TEST_ID]: optTestId} : null)
173
177
  };
174
178
  });
175
179
 
@@ -31,7 +31,7 @@ import {debouncePromise, wait} from '@xh/hoist/promise';
31
31
  import {elemWithin, getTestId, mergeDeep, TEST_ID, throwIf, withDefault} from '@xh/hoist/utils/js';
32
32
  import {createObservableRef, getLayoutProps} from '@xh/hoist/utils/react';
33
33
  import classNames from 'classnames';
34
- import {castArray, escapeRegExp, isEmpty, isEqual, isNil, isPlainObject, keyBy} from 'lodash';
34
+ import {castArray, escapeRegExp, isEmpty, isEqual, isNil, isPlainObject, unionWith} from 'lodash';
35
35
  import {ReactElement, ReactNode} from 'react';
36
36
  import {components} from 'react-select';
37
37
  import {calcWindowedMenuWidth} from './impl/CalcWindowedMenuWidth';
@@ -191,6 +191,15 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps {
191
191
 
192
192
  /** Field on provided options for sourcing each option's value (default `value`). */
193
193
  valueField?: string;
194
+
195
+ /**
196
+ * Function to generate a `SelectOption` for a (non-null) selected value not present in the
197
+ * current options list. Return null to fall back to the default value-as-label behavior.
198
+ *
199
+ * Useful with queryFn-based selects, readonly forms, or any case where options may not be
200
+ * loaded when a value is set, ensuring the value renders with its proper label.
201
+ */
202
+ generateOptionFn?: (value: any) => SelectOption;
194
203
  }
195
204
 
196
205
  /**
@@ -437,7 +446,8 @@ class SelectInputModel extends HoistInputModel {
437
446
 
438
447
  // Convert external value into option object(s). Options created if missing - this takes the
439
448
  // external value from the model, and we will respect that even if we don't know about it.
440
- // (Exception for a null value, which we will only accept if explicitly present in options.)
449
+ // (Exception for a null value, which is never synthesized - accepted only if provided via
450
+ // options.)
441
451
  override toInternal(external) {
442
452
  if (this.multiMode) {
443
453
  if (external == null || isEqual(external, this.emptyValue)) external = []; // avoid [null]
@@ -457,7 +467,10 @@ class SelectInputModel extends HoistInputModel {
457
467
  }
458
468
  }
459
469
 
460
- return createIfNotFound ? this.valueToOption(value) : null;
470
+ if (!createIfNotFound) return null;
471
+
472
+ // Value not among options - let the app generate an option for it, else synthesize one.
473
+ return this.componentProps.generateOptionFn?.(value) ?? this.valueToOption(value);
461
474
  }
462
475
 
463
476
  override toExternal(internal) {
@@ -481,11 +494,11 @@ class SelectInputModel extends HoistInputModel {
481
494
  // Normalize / clone a single source value into a normalized option object. Supports Strings
482
495
  // and Objects. Objects are validated/defaulted to ensure a label+value or label+options sublist,
483
496
  // with other fields brought along to support Selects emitting value objects with ad hoc properties.
484
- private toOption(src, depth) {
497
+ private toOption(src, depth): SelectOption {
485
498
  return isPlainObject(src) ? this.objectToOption(src, depth) : this.valueToOption(src);
486
499
  }
487
500
 
488
- private objectToOption(src, depth) {
501
+ private objectToOption(src, depth): SelectOption {
489
502
  const {componentProps} = this,
490
503
  labelField = withDefault(componentProps.labelField, 'label'),
491
504
  valueField = withDefault(componentProps.valueField, 'value');
@@ -508,7 +521,7 @@ class SelectInputModel extends HoistInputModel {
508
521
  };
509
522
  }
510
523
 
511
- private valueToOption(src) {
524
+ private valueToOption(src): SelectOption {
512
525
  return {label: src != null ? src.toString() : '-null-', value: src};
513
526
  }
514
527
 
@@ -521,13 +534,9 @@ class SelectInputModel extends HoistInputModel {
521
534
 
522
535
  // Carry forward and add to any existing internalOpts to allow our value
523
536
  // converters to continue all selected values in multiMode.
524
- const matchesByVal = keyBy(matchOpts, 'value'),
525
- newOpts = [...matchOpts];
526
- this.internalOptions.forEach(currOpt => {
527
- const matchOpt = matchesByVal[currOpt.value];
528
- if (!matchOpt) newOpts.push(currOpt); // avoiding dupes
529
- });
530
- this.internalOptions = newOpts;
537
+ this.internalOptions = unionWith(matchOpts, this.internalOptions, (a, b) =>
538
+ isEqual(a.value, b.value)
539
+ );
531
540
 
532
541
  // But only return the matching options back to the combo.
533
542
  return matchOpts;
@@ -544,8 +553,7 @@ class SelectInputModel extends HoistInputModel {
544
553
  // Option Rendering
545
554
  //----------------------
546
555
  formatOptionLabel = (opt, params) => {
547
- // Always display the standard label string in the value container (context == 'value').
548
- // If we need to expose customization here, we could consider a dedicated prop.
556
+ // Display the standard label string in the value container (context == 'value').
549
557
  if (params.context !== 'menu') {
550
558
  return opt.label;
551
559
  }
@@ -561,7 +569,7 @@ class SelectInputModel extends HoistInputModel {
561
569
  return div(opt.label);
562
570
  }
563
571
 
564
- return castArray(this.externalValue).includes(opt.value)
572
+ return castArray(this.externalValue).some(v => isEqual(v, opt.value))
565
573
  ? hbox({
566
574
  items: [
567
575
  div({
package/docs/routing.md CHANGED
@@ -150,10 +150,12 @@ numeric-only parameters). See the
150
150
  ### Conditional Route Exclusion
151
151
 
152
152
  Routes support an `omit` property (a Hoist extension, not part of Router5) that allows
153
- declarative exclusion of routes at registration time. This is useful for role-gated sections:
153
+ declarative exclusion of routes at registration time. This is useful for role-gated sections.
154
+ `getRoutes()` returns `HoistRoute[]` - Hoist's `Route` type extended with the optional `omit` key -
155
+ so the property type-checks without any cast:
154
156
 
155
157
  ```typescript
156
- override getRoutes() {
158
+ override getRoutes(): HoistRoute[] {
157
159
  return [{
158
160
  name: 'default',
159
161
  path: '/app',
@@ -221,7 +221,21 @@ grep -rn "fileChooser\|FileChooser" client-app/src/
221
221
  | `minSize` (prop) | `FileChooserModel` config `minFileSize` |
222
222
  | `enableMulti` / `enableAddMulti` (props) | `FileChooserModel` config `maxFiles` (set `1` for single-file; omit for unlimited) |
223
223
  | `showFileGrid` (prop) | Removed - default display is a grid; customize via the `fileDisplay` content prop |
224
- | `targetText` (prop) | `FileChooserModel` config `emptyDisplayPrompt` (+ `emptyDisplayHint`), or replace via the `emptyDisplay` content prop |
224
+ | `targetText` (prop) | **Preferred:** `FileChooserModel` config `emptyDisplayPrompt` (+ `emptyDisplayHint`). Use the `emptyDisplay` content prop only for a fully custom layout. |
225
+
226
+ > **`emptyDisplay` and `fileDisplay` switch on file *presence*; v85's `targetText` did not.**
227
+ > In v85 the drop target that rendered `targetText` was **always visible** - even with files
228
+ > selected - and the file grid (if enabled) sat alongside it. v86 has no always-visible text slot:
229
+ > `emptyDisplay` renders **only when no files are selected**, and `fileDisplay` (defaulting to the
230
+ > grid/card) renders **only when files are present**. There is therefore no mechanical mapping for a
231
+ > `targetText` - you must decide which presence-state its content belonged to:
232
+ > - A drop prompt ("Drag and drop files here") is empty-state content - set it as
233
+ > `emptyDisplayPrompt` (a plain string, preferred - keeps Hoist's icon, styling, and the
234
+ > auto-generated constraints hint). When files exist, the grid/`fileDisplay` takes over.
235
+ > - A message that only makes sense once a file is chosen (e.g. "Ready to upload") belongs in
236
+ > `fileDisplay`, which replaces the default grid - so set it only if you don't need that grid.
237
+ > - If your `targetText` varied on an **app flag** rather than on file presence, preserve that flag
238
+ > *inside* the slot you choose - do not let the empty/file switch silently stand in for it.
225
239
 
226
240
  Before (v85 - config as component props, model takes no args):
227
241
  ```typescript
@@ -283,17 +297,68 @@ constructor() {
283
297
  }
284
298
  ```
285
299
 
286
- **If your `targetText` was a React element** (not a plain string) - e.g. a `placeholder(...)` or a
287
- custom layout - move it to the `emptyDisplay` content prop rather than `emptyDisplayPrompt`.
288
- `emptyDisplayPrompt` accepts a `ReactNode` and is fine for a styled message, but `emptyDisplay`
289
- replaces the entire empty-state content, which is the closer match for a fully custom `targetText`:
300
+ **Prefer the styled text prompt over rebuilding the UI.** Most `targetText` values were a string or
301
+ a simple `placeholder(Icon.upload(), '...')` - which is exactly what the default empty display
302
+ already renders. Map these to `emptyDisplayPrompt` (a plain string on the model), optionally with
303
+ `emptyDisplayHint`: you keep Hoist's upload icon, styling, and the auto-generated summary of accepted
304
+ types and size/count limits. This holds even when `targetText` was a `placeholder(...)` element -
305
+ collapse it back to the prompt string rather than reaching for a content prop.
290
306
 
291
307
  ```typescript
292
- // Before (v85)
308
+ // Before (v85) - a plain-text or simple-placeholder targetText
309
+ fileChooser({model, targetText: 'Drop loan docs here'})
293
310
  fileChooser({model, targetText: placeholder(Icon.upload(), 'Drop loan docs here')})
294
311
 
295
- // After (v86)
296
- fileChooser({model, emptyDisplay: placeholder(Icon.upload(), 'Drop loan docs here')})
312
+ // After (v86) - map targetText to a styled prompt on the model; icon, styling, and the constraints
313
+ // hint come for free, and the component render needs no change
314
+ new FileChooserModel({emptyDisplayPrompt: 'Drop loan docs here'});
315
+ ```
316
+
317
+ Reserve the `emptyDisplay` / `fileDisplay` content props for layouts the prompt/hint genuinely can't
318
+ express. Note they are **not** interchangeable targets for a `targetText`: `emptyDisplay` renders
319
+ only when empty and `fileDisplay` only when files are present, so content meant for the
320
+ file-populated state must go to `fileDisplay` - never `emptyDisplay`.
321
+
322
+ **If your `targetText` was conditional** - a ternary or computed expression - work out what the
323
+ condition actually tracked before migrating, because v86 already switches content on file presence.
324
+
325
+ In the common case the condition tracked *file presence* (a flag toggled as files are added and
326
+ removed). v86's built-in switch replaces it directly: the no-file prompt becomes `emptyDisplayPrompt`
327
+ and the flag is dropped. The plain drop text needs no content prop at all - only the custom "ready"
328
+ affirmation, meant for the file-populated state, uses `fileDisplay` (which overrides the default
329
+ grid). `enableMulti` moves to `maxFiles` on the model:
330
+
331
+ ```typescript
332
+ // Before (v85) - targetText always visible, toggled by a presence-derived flag
333
+ fileChooser({
334
+ model,
335
+ enableMulti: !singleDoc,
336
+ targetText: hasFile
337
+ ? placeholder(Icon.check({intent: 'success'}), 'Ready to upload')
338
+ : placeholder(Icon.upload(), 'Drag and drop files here')
339
+ })
340
+
341
+ // After (v86) - the presence switch is built in, so the flag is no longer needed
342
+ this.chooserModel = new FileChooserModel({
343
+ maxFiles: singleDoc ? 1 : null,
344
+ emptyDisplayPrompt: 'Drag and drop files here'
345
+ });
346
+ fileChooser({
347
+ model: this.chooserModel,
348
+ fileDisplay: placeholder(Icon.check({intent: 'success'}), 'Ready to upload')
349
+ })
350
+ ```
351
+
352
+ The trap is a condition that is **independent of file presence** (e.g. an "uploads open" flag). Its
353
+ branches are *not* an empty/file pair, so splitting them across `emptyDisplay` / `fileDisplay` would
354
+ drop the condition and silently re-key the content onto presence. Keep such a flag *inside* the slot
355
+ its content belongs to - here, still on the styled prompt:
356
+
357
+ ```typescript
358
+ // independent flag - preserved on the styled prompt (model config), not mapped onto presence
359
+ new FileChooserModel({
360
+ emptyDisplayPrompt: acceptingUploads ? 'Drag and drop files here' : 'Uploads are closed'
361
+ });
297
362
  ```
298
363
 
299
364
  ### 4. Remove Deleted Mobile `DateInput` Props
@@ -8,5 +8,6 @@ import {elementFactory} from '@xh/hoist/core';
8
8
  import Dropzone from 'react-dropzone';
9
9
 
10
10
  export {Dropzone};
11
+ export {ErrorCode} from 'react-dropzone';
11
12
  export type {Accept, FileRejection, FileWithPath} from 'react-dropzone';
12
13
  export const dropzone = elementFactory(Dropzone);
package/mcp/README.md CHANGED
@@ -725,6 +725,18 @@ The `resolveDocPath()` utility in `util/paths.ts` validates that resolved paths
725
725
  repository root. It rejects paths containing `..` segments. Always use this function when resolving
726
726
  file paths from external input.
727
727
 
728
+ ### Path Separators (Cross-Platform)
729
+
730
+ ts-morph's `SourceFile.getFilePath()` always returns **forward-slash** paths on every platform
731
+ (e.g. `D:/hoist-react/cmp/grid/GridModel.ts` on Windows), whereas `resolveRepoRoot()` returns a
732
+ native path from Node's `path` module -- **backslash-separated** on Windows (`D:\hoist-react`).
733
+ Comparing or slicing one against the other (e.g. `filePath.startsWith(repoRoot + '/')`) silently
734
+ fails on Windows, filtering out every source file and yielding an empty symbol index. When
735
+ comparing against or slicing a ts-morph path, use `resolveRepoRootPosix()` (and `toPosixPath()` for
736
+ any incoming file-path argument) from `util/paths.ts` rather than `resolveRepoRoot()`. Filesystem
737
+ access that stays within Node's `path`/`fs` APIs (e.g. the doc registry, the index cache) can keep
738
+ using `resolveRepoRoot()`, since those are separator-consistent on both sides.
739
+
728
740
  ### Registry Sync
729
741
 
730
742
  The doc registry is hardcoded, not filesystem-scanned. When documentation files are added or
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Test harness for the TypeScript symbol registry's cross-platform path handling.
3
+ * Run with:
4
+ * npx tsx mcp/data/ts-registry.spec.ts
5
+ *
6
+ * The hoist-react repo has no general test framework configured, so this script
7
+ * is a self-contained, exit-coded driver. It guards the path normalization the
8
+ * symbol index depends on: ts-morph's `getFilePath()` reports forward-slash paths
9
+ * on every platform, while Node's `path` reports backslashes on Windows. The two
10
+ * must be reconciled (to POSIX form) before comparison - a regression silently
11
+ * filters out every source file and empties the entire symbol index on Windows.
12
+ * See the "Path Separators (Cross-Platform)" pitfall in mcp/README.md.
13
+ *
14
+ * Two layers:
15
+ * - Synthetic path-helper cases (`toPosixPath`, `resolveRepoRootPosix`,
16
+ * `toRelativePath`, and the exact index-guard comparison) - these simulate a
17
+ * Windows path explicitly, so they are meaningful on any host OS, including
18
+ * Linux/macOS CI.
19
+ * - Live index integration - exercises `searchSymbols` / `getSymbolDetail` /
20
+ * `getMembers` against the real hoist-react sources to prove symbols resolve
21
+ * end-to-end (and that repo-relative `--file` disambiguation works).
22
+ *
23
+ * Run after every change to ts-registry.ts, index-cache.ts, or util/paths.ts.
24
+ */
25
+ import {resolveRepoRoot, resolveRepoRootPosix, toPosixPath} from '../util/paths.js';
26
+ import {toRelativePath} from '../formatters/typescript.js';
27
+ import {searchSymbols, getSymbolDetail, getMembers} from './ts-registry.js';
28
+
29
+ // Suppress info logs during test run; we only want test output. (Warns still print.)
30
+ process.env.HOIST_MCP_QUIET = '1';
31
+
32
+ //---------------------------------------------------------------------
33
+ // Minimal assertion helpers (match the exit-coded style of the sibling
34
+ // doc-id-resolver.spec.ts).
35
+ //---------------------------------------------------------------------
36
+
37
+ let passed = 0;
38
+ let failed = 0;
39
+ const failures: string[] = [];
40
+
41
+ function ok(label: string, cond: boolean, detail?: string): void {
42
+ if (cond) {
43
+ passed++;
44
+ console.log(` PASS ${label}`);
45
+ } else {
46
+ failed++;
47
+ failures.push(detail ? `${label} -- ${detail}` : label);
48
+ console.log(` FAIL ${label}`);
49
+ if (detail) console.log(` ${detail}`);
50
+ }
51
+ }
52
+
53
+ function eq(label: string, actual: unknown, expected: unknown): void {
54
+ ok(
55
+ label,
56
+ actual === expected,
57
+ `got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`
58
+ );
59
+ }
60
+
61
+ //---------------------------------------------------------------------
62
+ // Synthetic path-helper cases (host-OS-independent).
63
+ //---------------------------------------------------------------------
64
+
65
+ console.log('toPosixPath cases:');
66
+ eq(
67
+ 'converts backslashes',
68
+ toPosixPath('D:\\hoist-react\\core\\XH.ts'),
69
+ 'D:/hoist-react/core/XH.ts'
70
+ );
71
+ eq('leaves forward slashes untouched', toPosixPath('/repo/core/XH.ts'), '/repo/core/XH.ts');
72
+ eq('normalizes mixed separators', toPosixPath('a\\b/c\\d'), 'a/b/c/d');
73
+ eq('is idempotent', toPosixPath(toPosixPath('D:\\a\\b')), 'D:/a/b');
74
+
75
+ console.log('\nresolveRepoRootPosix cases:');
76
+ const rootPosix = resolveRepoRootPosix();
77
+ ok('has no backslashes', !rootPosix.includes('\\'), `got "${rootPosix}"`);
78
+ eq('equals toPosixPath(resolveRepoRoot())', rootPosix, toPosixPath(resolveRepoRoot()));
79
+
80
+ console.log('\nIndex-guard regression cases (simulated Windows paths):');
81
+ // The exact scenario that broke: on Windows, resolveRepoRoot() yields a
82
+ // backslash path while ts-morph yields forward slashes. Assert the naive guard
83
+ // fails and the POSIX-normalized guard succeeds - regardless of the host OS.
84
+ const winRootNative = 'D:\\hoist-react'; // what resolveRepoRoot() returns on Windows
85
+ const winFile = 'D:/hoist-react/cmp/grid/GridModel.ts'; // what ts-morph getFilePath() returns on Windows
86
+ ok(
87
+ 'naive backslash-root comparison fails (the original bug)',
88
+ !winFile.startsWith(winRootNative + '/')
89
+ );
90
+ ok(
91
+ 'posix-normalized root comparison succeeds (the fix)',
92
+ winFile.startsWith(toPosixPath(winRootNative) + '/')
93
+ );
94
+ eq(
95
+ 'relative slice under posix root is correct',
96
+ winFile.slice(toPosixPath(winRootNative).length + 1),
97
+ 'cmp/grid/GridModel.ts'
98
+ );
99
+
100
+ console.log('\ntoRelativePath cases:');
101
+ const sampleRel = 'cmp/grid/GridModel.ts';
102
+ eq('strips a posix absolute path', toRelativePath(`${rootPosix}/${sampleRel}`), sampleRel);
103
+ // A backslash absolute path (same root) must still strip AND normalize to posix.
104
+ const backslashAbs = `${rootPosix.replace(/\//g, '\\')}\\cmp\\grid\\GridModel.ts`;
105
+ eq('strips + normalizes a backslash absolute path', toRelativePath(backslashAbs), sampleRel);
106
+
107
+ //---------------------------------------------------------------------
108
+ // Live index integration (needs the real hoist-react sources; builds or
109
+ // loads the cached index on first query).
110
+ //---------------------------------------------------------------------
111
+
112
+ console.log('\nLive index cases:');
113
+
114
+ const results = await searchSymbols('GridModel', {limit: 5});
115
+ const gm = results.find(r => r.name === 'GridModel' && r.kind === 'class');
116
+ ok('searchSymbols("GridModel") finds the class', !!gm, `got ${results.length} results`);
117
+ if (gm) {
118
+ eq('GridModel resolves to cmp/grid/GridModel.ts', toRelativePath(gm.filePath), sampleRel);
119
+ eq('GridModel sourcePackage is cmp/grid', gm.sourcePackage, 'cmp/grid');
120
+ }
121
+
122
+ const detail = await getSymbolDetail('GridModel');
123
+ ok(
124
+ 'getSymbolDetail("GridModel") resolves',
125
+ !!detail && detail.kind === 'class',
126
+ `got ${JSON.stringify(detail && {name: detail.name, kind: detail.kind})}`
127
+ );
128
+
129
+ const members = await getMembers('GridModel');
130
+ ok(
131
+ 'getMembers("GridModel") returns members',
132
+ !!members && members.members.length > 0,
133
+ `count=${members?.members.length ?? 0}`
134
+ );
135
+
136
+ // --file disambiguation (the secondary Windows bug): a repo-relative path passed
137
+ // to findIndexEntry must resolve to the matching entry. `View` exists in both
138
+ // cmp/viewmanager and data/cube; the path selects the data/cube one.
139
+ const view = await getSymbolDetail('View', 'data/cube/View.ts');
140
+ ok(
141
+ 'getSymbolDetail("View", "data/cube/View.ts") disambiguates by repo-relative path',
142
+ !!view && toRelativePath(view.filePath) === 'data/cube/View.ts',
143
+ `relPath=${view ? toRelativePath(view.filePath) : 'null'}`
144
+ );
145
+
146
+ //---------------------------------------------------------------------
147
+ // Tally
148
+ //---------------------------------------------------------------------
149
+
150
+ console.log(`\nTotal: ${passed} passed, ${failed} failed.`);
151
+
152
+ if (failed > 0) {
153
+ console.log('\nFailure details:');
154
+ for (const f of failures) console.log(` - ${f}`);
155
+ process.exit(1);
156
+ }
@@ -24,7 +24,7 @@ import type {
24
24
  import {resolve} from 'node:path';
25
25
 
26
26
  import {log} from '../util/logger.js';
27
- import {resolveRepoRoot} from '../util/paths.js';
27
+ import {resolveRepoRoot, resolveRepoRootPosix, toPosixPath} from '../util/paths.js';
28
28
  import {computeFingerprint, loadCache, writeCache} from './index-cache.js';
29
29
 
30
30
  //------------------------------------------------------------------
@@ -295,7 +295,9 @@ function buildSymbolIndex(proj: Project): {
295
295
  } {
296
296
  const index = new Map<string, SymbolEntry[]>();
297
297
  const mIndex = new Map<string, MemberIndexEntry[]>();
298
- const repoRoot = resolveRepoRoot();
298
+ // POSIX form: compared against ts-morph `getFilePath()`, which always uses
299
+ // forward slashes even on Windows.
300
+ const repoRoot = resolveRepoRootPosix();
299
301
 
300
302
  const counts = {total: 0, exported: 0, byKind: {} as Record<string, number>};
301
303
  let memberCount = 0;
@@ -549,7 +551,7 @@ function buildSymbolIndex(proj: Project): {
549
551
  // Index Promise prototype extensions from promise/Promise.ts
550
552
  const promiseFile = proj.getSourceFile(sf => sf.getFilePath().endsWith('/promise/Promise.ts'));
551
553
  if (promiseFile) {
552
- indexPromiseExtensions(promiseFile, index, mIndex, resolveRepoRoot());
554
+ indexPromiseExtensions(promiseFile, index, mIndex, resolveRepoRootPosix());
553
555
  }
554
556
 
555
557
  // Populate `memberNames` on symbol entries for every member-indexed owner
@@ -799,7 +801,8 @@ function ensureProject(): Project {
799
801
  * JSDoc).
800
802
  */
801
803
  function enrichMemberIndexFromImplements(proj: Project): void {
802
- const repoRoot = resolveRepoRoot();
804
+ // POSIX form: compared against ts-morph `getFilePath()` (see buildSymbolIndex).
805
+ const repoRoot = resolveRepoRootPosix();
803
806
 
804
807
  for (const sourceFile of proj.getSourceFiles()) {
805
808
  const filePath = sourceFile.getFilePath();
@@ -1374,8 +1377,12 @@ function findIndexEntry(name: string, filePath?: string): SymbolEntry | null {
1374
1377
 
1375
1378
  if (filePath) {
1376
1379
  // Resolve relative paths against repo root -- search results display repo-relative
1377
- // paths, so callers will typically pass those rather than absolute paths.
1378
- const resolved = filePath.startsWith('/') ? filePath : resolve(resolveRepoRoot(), filePath);
1380
+ // paths, so callers will typically pass those rather than absolute paths. Normalize
1381
+ // to POSIX so the comparison matches entry.filePath (ts-morph forward slashes) on
1382
+ // Windows, and recognize Windows drive-letter absolutes (e.g. `D:/...`) as absolute.
1383
+ const posix = toPosixPath(filePath);
1384
+ const isAbsolute = posix.startsWith('/') || /^[a-zA-Z]:\//.test(posix);
1385
+ const resolved = isAbsolute ? posix : toPosixPath(resolve(resolveRepoRoot(), posix));
1379
1386
  return exact.find(e => e.filePath === resolved) ?? null;
1380
1387
  }
1381
1388