@xh/hoist 86.1.0 → 86.2.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 (47) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/admin/AdminJsonDisplay.ts +31 -0
  3. package/admin/App.scss +3 -11
  4. package/admin/jsonsearch/impl/JsonSearchImplModel.ts +4 -4
  5. package/admin/tabs/cluster/instances/connpool/ConnPoolMonitorPanel.ts +3 -11
  6. package/admin/tabs/cluster/instances/services/DetailsPanel.ts +2 -13
  7. package/admin/tabs/cluster/objects/DetailPanel.ts +3 -12
  8. package/build/types/admin/AdminJsonDisplay.d.ts +7 -0
  9. package/build/types/admin/jsonsearch/impl/JsonSearchImplModel.d.ts +2 -2
  10. package/build/types/cmp/grid/Grid.d.ts +6 -3
  11. package/build/types/cmp/grid/GridModel.d.ts +11 -0
  12. package/build/types/cmp/grid/filter/GridFilterFieldSpec.d.ts +24 -6
  13. package/build/types/desktop/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
  14. package/build/types/desktop/cmp/input/CodeInput.d.ts +7 -0
  15. package/build/types/desktop/cmp/input/NumberInput.d.ts +1 -1
  16. package/build/types/desktop/cmp/input/impl/CalcWindowedMenuWidth.d.ts +20 -0
  17. package/build/types/desktop/cmp/rest/impl/RestFormModel.d.ts +1 -1
  18. package/build/types/format/FormatNumber.d.ts +11 -3
  19. package/build/types/icon/Icon.d.ts +4 -3
  20. package/build/types/mobile/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
  21. package/build/types/mobile/cmp/input/NumberInput.d.ts +1 -1
  22. package/cmp/grid/Grid.ts +17 -0
  23. package/cmp/grid/GridModel.ts +17 -5
  24. package/cmp/grid/filter/GridFilterFieldSpec.ts +28 -5
  25. package/cmp/grid/impl/MenuSupport.ts +2 -4
  26. package/desktop/cmp/button/grid/ExpandToLevelButton.ts +4 -5
  27. package/desktop/cmp/grid/editors/NumberEditor.ts +16 -17
  28. package/desktop/cmp/grid/editors/SelectEditor.ts +15 -10
  29. package/desktop/cmp/grid/impl/filter/GridFilterDialog.ts +2 -2
  30. package/desktop/cmp/grid/impl/filter/headerfilter/values/ValuesTabModel.ts +46 -7
  31. package/desktop/cmp/input/CheckboxButton.ts +21 -1
  32. package/desktop/cmp/input/CodeInput.ts +20 -3
  33. package/desktop/cmp/input/NumberInput.ts +1 -1
  34. package/desktop/cmp/input/Select.ts +13 -2
  35. package/desktop/cmp/input/impl/CalcWindowedMenuWidth.ts +106 -0
  36. package/desktop/cmp/rest/impl/RestFormField.ts +1 -1
  37. package/desktop/cmp/tab/Tabs.scss +72 -25
  38. package/format/FormatNumber.ts +69 -32
  39. package/icon/Icon.scss +13 -0
  40. package/icon/Icon.ts +4 -3
  41. package/icon/impl/IconHtml.ts +1 -1
  42. package/mcp/data/ts-registry.ts +1 -1
  43. package/mobile/cmp/button/grid/ExpandToLevelButton.ts +4 -5
  44. package/mobile/cmp/input/CheckboxButton.ts +18 -1
  45. package/mobile/cmp/input/NumberInput.ts +1 -1
  46. package/package.json +6 -6
  47. package/styles/vars.scss +15 -0
@@ -305,6 +305,10 @@ export interface GridConfig {
305
305
  * expand/collapse options in the default context menu will be enhanced to allow users to
306
306
  * expand/collapse to a specific level. See {@link GroupingChooserModel.valueDisplayNames}
307
307
  * for a convenient getter that will satisfy this API when a GroupingChooser is in play.
308
+ *
309
+ * Labels are matched to levels top-down and need not cover the full depth of the grid - provide
310
+ * a partial array to label only the top levels (e.g. when deeper levels should not be
311
+ * expand-to targets). Deeper, unlabelled levels are omitted from the menu.
308
312
  */
309
313
  levelLabels?: Thunkable<string[]>;
310
314
 
@@ -1174,15 +1178,23 @@ export class GridModel extends HoistModel {
1174
1178
 
1175
1179
  /**
1176
1180
  * Get the resolved level labels for the current state of the grid.
1181
+ * An over-long array is truncated to the current `maxDepth`.
1177
1182
  */
1178
1183
  get resolvedLevelLabels(): string[] {
1179
1184
  const {maxDepth, levelLabels} = this,
1180
1185
  ret = executeIfFunction(levelLabels);
1181
- if (ret && ret.length < maxDepth + 1) {
1182
- this.logDebug('Value produced by `GridModel.levelLabels` has insufficient length.');
1183
- return null;
1184
- }
1185
- return ret ? take(ret, maxDepth + 1) : null;
1186
+ return !isEmpty(ret) ? take(ret, maxDepth + 1) : null;
1187
+ }
1188
+
1189
+ /**
1190
+ * True if the given `resolvedLevelLabels` index is the grid's current expand level - used to
1191
+ * mark the active item in the "Expand to..." menu. The deepest labelled level counts as current
1192
+ * whenever the grid is expanded to or beyond it.
1193
+ */
1194
+ isCurrentExpandLevel(idx: number): boolean {
1195
+ const {expandLevel, resolvedLevelLabels} = this,
1196
+ lastIdx = resolvedLevelLabels?.length - 1;
1197
+ return expandLevel === idx || (idx === lastIdx && expandLevel > lastIdx);
1186
1198
  }
1187
1199
 
1188
1200
  /**
@@ -4,7 +4,6 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {ColumnRenderer} from '@xh/hoist/cmp/grid';
8
7
  import {HoistInputProps} from '@xh/hoist/cmp/input';
9
8
  import {PlainObject} from '@xh/hoist/core';
10
9
  import {
@@ -19,17 +18,38 @@ import {
19
18
  BaseFilterFieldSpecConfig
20
19
  } from '@xh/hoist/data/filter/BaseFilterFieldSpec';
21
20
  import {castArray, compact, flatMap, isDate, isEmpty, uniqBy} from 'lodash';
21
+ import {ReactNode} from 'react';
22
22
  import {GridFilterModel} from './GridFilterModel';
23
23
 
24
+ /**
25
+ * Produces the display content for an entry in the Values tab of a column filter. Must be a pure
26
+ * transform of the value - the values list carries no source record, and there is no column or
27
+ * grid context to reach for (unlike a Column's `renderer`, which runs against the source grid).
28
+ */
29
+ export type GridFilterRenderer = (value: any) => ReactNode;
30
+
31
+ /**
32
+ * Produces the value to sort by for an entry in the Values tab of a column filter. Must be a pure
33
+ * transform of the value - the values list carries no source record, and there is no column or
34
+ * grid context to reach for (unlike a Column's `sortValue`, which runs against the source grid).
35
+ */
36
+ export type GridFilterSortValueFn = (value: any) => any;
37
+
24
38
  export interface GridFilterFieldSpecConfig extends BaseFilterFieldSpecConfig {
25
39
  /** GridFilterModel instance owning this fieldSpec. */
26
40
  filterModel?: GridFilterModel;
27
41
 
28
42
  /**
29
- * Function returning a formatted string for each value in this values filter display.
30
- * If not provided, the Column's renderer will be used.
43
+ * Pure function producing the display content for each entry in the values filter display. If
44
+ * not provided, the Column's renderer is used where it can be applied to a bare value.
45
+ */
46
+ renderer?: GridFilterRenderer;
47
+
48
+ /**
49
+ * Pure function producing the value to sort each entry of the values filter display by. If not
50
+ * provided, the Column's sortValue is used where it can be applied to a bare value.
31
51
  */
32
- renderer?: ColumnRenderer;
52
+ sortValue?: GridFilterSortValueFn;
33
53
 
34
54
  /**
35
55
  * Props to pass through to the HoistInput components used on the custom filter tab.
@@ -47,7 +67,8 @@ export interface GridFilterFieldSpecConfig extends BaseFilterFieldSpecConfig {
47
67
  */
48
68
  export class GridFilterFieldSpec extends BaseFilterFieldSpec {
49
69
  filterModel: GridFilterModel;
50
- renderer: ColumnRenderer;
70
+ renderer: GridFilterRenderer;
71
+ sortValue: GridFilterSortValueFn;
51
72
  inputProps: PlainObject;
52
73
  defaultOp: FieldFilterOperator;
53
74
 
@@ -57,6 +78,7 @@ export class GridFilterFieldSpec extends BaseFilterFieldSpec {
57
78
  constructor({
58
79
  filterModel,
59
80
  renderer,
81
+ sortValue,
60
82
  inputProps,
61
83
  defaultOp,
62
84
  ...rest
@@ -65,6 +87,7 @@ export class GridFilterFieldSpec extends BaseFilterFieldSpec {
65
87
 
66
88
  this.filterModel = filterModel;
67
89
  this.renderer = renderer;
90
+ this.sortValue = sortValue;
68
91
  this.inputProps = inputProps;
69
92
  this.defaultOp = this.ops.includes(defaultOp) ? defaultOp : this.ops[0];
70
93
  }
@@ -280,15 +280,13 @@ function levelExpandAction(gridModel: GridModel): RecordAction {
280
280
  return new RecordAction({
281
281
  text: 'Expand to...',
282
282
  displayFn: () => {
283
- const {maxDepth, expandLevel, resolvedLevelLabels} = gridModel;
283
+ const {maxDepth, resolvedLevelLabels} = gridModel;
284
284
 
285
285
  // Don't show for flat grid models or if we don't have labels
286
286
  if (!maxDepth || !resolvedLevelLabels) return {hidden: true};
287
287
 
288
288
  const items = resolvedLevelLabels.map((label, idx) => {
289
- const isCurrLevel =
290
- expandLevel === idx ||
291
- (expandLevel > maxDepth && idx === resolvedLevelLabels.length - 1);
289
+ const isCurrLevel = gridModel.isCurrentExpandLevel(idx);
292
290
 
293
291
  return {
294
292
  icon: isCurrLevel ? Icon.check() : null,
@@ -28,7 +28,8 @@ export interface ExpandToLevelButtonProps extends Omit<ButtonProps, 'title'> {
28
28
 
29
29
  /**
30
30
  * A menu button to expand a multi-level grouped or tree grid out to a desired level.
31
- * Requires {@link GridConfig.levelLabels} to be configured on the bound GridModel.
31
+ * Requires {@link GridConfig.levelLabels} to be configured on the bound GridModel - the menu offers
32
+ * one entry per labelled level, so a partial array will limit the available expand-to targets.
32
33
  */
33
34
  export const [ExpandToLevelButton, expandToLevelButton] =
34
35
  hoistCmp.withFactory<ExpandToLevelButtonProps>({
@@ -53,13 +54,11 @@ export const [ExpandToLevelButton, expandToLevelButton] =
53
54
  }
54
55
 
55
56
  // Render a disabled button if requested, if we have a flat grid, or no level labels
56
- const {maxDepth, expandLevel, resolvedLevelLabels} = gridModel;
57
+ const {maxDepth, resolvedLevelLabels} = gridModel;
57
58
  if (disabled || !maxDepth || !resolvedLevelLabels) return disabledButton();
58
59
 
59
60
  const menuItems: MenuItemLike[] = resolvedLevelLabels.map((label, idx) => {
60
- const isCurrLevel =
61
- expandLevel === idx ||
62
- (expandLevel > maxDepth && idx === resolvedLevelLabels.length - 1);
61
+ const isCurrLevel = gridModel.isCurrentExpandLevel(idx);
63
62
 
64
63
  return {
65
64
  icon: isCurrLevel ? Icon.check() : Icon.placeholder(),
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import {withDefault} from '@xh/hoist/utils/js';
8
8
  import {isNil} from 'lodash';
9
- import {useCallback, useEffect} from 'react';
10
- import {CustomCellEditorProps, useGridCellEditor} from '@xh/hoist/kit/ag-grid';
9
+ import {useEffect} from 'react';
10
+ import {CustomCellEditorProps} from '@xh/hoist/kit/ag-grid';
11
11
  import {hoistCmp} from '@xh/hoist/core';
12
12
  import {numberInput, NumberInputProps} from '@xh/hoist/desktop/cmp/input';
13
13
  import '@xh/hoist/desktop/register';
@@ -39,20 +39,19 @@ export const [NumberEditor, numberEditor] = hoistCmp.withFactory<NumberEditorPro
39
39
  }
40
40
  });
41
41
 
42
- const useNumberGuard = ({onValueChange, eventKey}: CustomCellEditorProps) => {
43
- // Needed (strangely) by agGrid to trigger call of isCancelBeforeStart
44
- useEffect(() => {
45
- if (eventKey?.length === 1) onValueChange(eventKey);
46
- }, [eventKey, onValueChange]);
47
-
48
- // Gets called before editor component is rendered, to give agGrid a chance to
49
- // cancel the editing before it even starts if char is not a number.
50
- const isCancelBeforeStart = useCallback(
51
- () => eventKey?.length === 1 && '1234567890'.indexOf(eventKey) < 0,
52
- [eventKey]
53
- );
42
+ // Characters that can validly begin a numeric entry - digits, sign, and decimal point.
43
+ const NUMBER_START_RE = /[0-9.+-]/;
54
44
 
55
- useGridCellEditor({
56
- isCancelBeforeStart
57
- });
45
+ const useNumberGuard = ({onValueChange, eventKey, stopEditing}: CustomCellEditorProps) => {
46
+ // When editing is started by typing a printable character, seed the editor with it if it can
47
+ // legally begin a number (digit, `-`, `+`, or `.`), otherwise stop editing to reject the
48
+ // keystroke - reverting to the original value, as the editor was never seeded.
49
+ useEffect(() => {
50
+ if (eventKey?.length !== 1) return;
51
+ if (NUMBER_START_RE.test(eventKey)) {
52
+ onValueChange(eventKey);
53
+ } else {
54
+ stopEditing(true);
55
+ }
56
+ }, [eventKey, onValueChange, stopEditing]);
58
57
  };
@@ -30,16 +30,21 @@ export const [SelectEditor, selectEditor] = hoistCmp.withFactory<SelectEditorPro
30
30
  onCommit: flushOnCommit
31
31
  ? () => wait().then(() => props.agParams.stopEditing())
32
32
  : null,
33
- rsOptions: {
34
- styles: {
35
- menu: styles => ({
36
- ...styles,
37
- whiteSpace: 'nowrap',
38
- width: 'auto',
39
- minWidth: '100%'
40
- })
41
- }
42
- },
33
+ // Auto-size the menu to content, not the narrow cell - but skip when width is already
34
+ // set, via windowed measurement (#4325) or an explicit `menuWidth` (#4057).
35
+ rsOptions:
36
+ props.inputProps?.enableWindowed || props.inputProps?.menuWidth != null
37
+ ? {}
38
+ : {
39
+ styles: {
40
+ menu: styles => ({
41
+ ...styles,
42
+ whiteSpace: 'nowrap',
43
+ width: 'auto',
44
+ minWidth: '100%'
45
+ })
46
+ }
47
+ },
43
48
  ...props.inputProps
44
49
  }
45
50
  };
@@ -55,7 +55,7 @@ const filterForm = hoistCmp.factory(({impl}) => {
55
55
  fieldDefaults: {label: null, minimal: true},
56
56
  item: formField({
57
57
  field: 'filter',
58
- item: jsonInput()
58
+ item: jsonInput({autoFormat: true})
59
59
  })
60
60
  });
61
61
  });
@@ -147,7 +147,7 @@ class GridFilterDialogLocalModel extends HoistModel {
147
147
  loadForm() {
148
148
  const filter = this.model.filter?.removeFunctionFilters();
149
149
  this.formModel.init({
150
- filter: JSON.stringify(filter?.toJSON() ?? null, undefined, 2)
150
+ filter: JSON.stringify(filter ?? null)
151
151
  });
152
152
  }
153
153
 
@@ -4,13 +4,28 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {GridFilterModel, GridModel} from '@xh/hoist/cmp/grid';
7
+ import {
8
+ GridFilterModel,
9
+ GridFilterRenderer,
10
+ GridFilterSortValueFn,
11
+ GridModel
12
+ } from '@xh/hoist/cmp/grid';
8
13
  import {HoistModel, managed} from '@xh/hoist/core';
9
14
  import type {FieldFilterOperator, FieldFilterSpec} from '@xh/hoist/data';
10
15
  import {checkbox} from '@xh/hoist/desktop/cmp/input';
11
16
  import {Icon} from '@xh/hoist/icon';
12
17
  import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx';
13
- import {castArray, difference, flatten, isEmpty, map, partition, uniq, without} from 'lodash';
18
+ import {
19
+ castArray,
20
+ difference,
21
+ flatten,
22
+ isEmpty,
23
+ isFunction,
24
+ map,
25
+ partition,
26
+ uniq,
27
+ without
28
+ } from 'lodash';
14
29
  import {HeaderFilterModel} from '../HeaderFilterModel';
15
30
 
16
31
  export class ValuesTabModel extends HoistModel {
@@ -281,8 +296,18 @@ export class ValuesTabModel extends HoistModel {
281
296
  private createGridModel() {
282
297
  const {BLANK_PLACEHOLDER} = GridFilterModel,
283
298
  {headerFilterModel, fieldSpec} = this,
284
- {fieldType, column} = headerFilterModel,
285
- renderer = fieldSpec.renderer ?? (fieldType !== 'tags' ? column.renderer : null);
299
+ {fieldType, column} = headerFilterModel;
300
+
301
+ // Default to the column's renderer/sortValue, but only where they apply to a bare value -
302
+ // we call them with the value alone (see below), so treat them as pure value transforms.
303
+ const renderer =
304
+ fieldSpec.renderer ??
305
+ (fieldType !== 'tags' ? (column.renderer as GridFilterRenderer) : null),
306
+ sortValue =
307
+ fieldSpec.sortValue ??
308
+ (fieldType !== 'tags' && isFunction(column.sortValue)
309
+ ? (column.sortValue as GridFilterSortValueFn)
310
+ : null);
286
311
 
287
312
  return new GridModel({
288
313
  store: {
@@ -340,9 +365,23 @@ export class ValuesTabModel extends HoistModel {
340
365
  if (v2 === BLANK_PLACEHOLDER) return -1 * mul;
341
366
  return defaultComparator(v1, v2);
342
367
  },
343
- renderer: (value, context) => {
344
- if (value === BLANK_PLACEHOLDER) return value;
345
- return renderer ? renderer(value, context) : value;
368
+ // Apply renderer/sortValue as pure value transforms - pass no context, skip the
369
+ // blank placeholder, and fall back to the raw value if either throws.
370
+ sortValue: v => {
371
+ if (v === BLANK_PLACEHOLDER || !sortValue) return v;
372
+ try {
373
+ return sortValue(v);
374
+ } catch {
375
+ return v;
376
+ }
377
+ },
378
+ renderer: v => {
379
+ if (v === BLANK_PLACEHOLDER || !renderer) return v;
380
+ try {
381
+ return renderer(v);
382
+ } catch {
383
+ return v;
384
+ }
346
385
  }
347
386
  }
348
387
  ],
@@ -47,7 +47,27 @@ class CheckboxButtonInputModel extends HoistInputModel {
47
47
  // Implementation
48
48
  //----------------------------------
49
49
  const cmp = hoistCmp.factory<CheckboxButtonInputModel>(
50
- ({model, text, icon, rightIcon, checkedIcon, uncheckedIcon, iconSide, ...props}, ref) => {
50
+ (
51
+ {
52
+ // HoistInput props - exclude from passthrough to BP
53
+ bind,
54
+ value,
55
+ commitOnChange,
56
+ onChange,
57
+ onCommit,
58
+ // Consumed by this component
59
+ model,
60
+ text,
61
+ icon,
62
+ rightIcon,
63
+ checkedIcon,
64
+ uncheckedIcon,
65
+ iconSide,
66
+ // Remainder passed to hoist button & BP button
67
+ ...props
68
+ },
69
+ ref
70
+ ) => {
51
71
  const checked = !!model.renderValue,
52
72
  toggleIcon = checked
53
73
  ? withDefault(checkedIcon, Icon.checkSquare({prefix: 'fas', intent: 'primary'}))
@@ -62,6 +62,14 @@ export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps
62
62
  /** True to focus the control on render. */
63
63
  autoFocus?: boolean;
64
64
 
65
+ /**
66
+ * True to automatically format content for display using the configured `formatter`.
67
+ * Defaults to true for `readonly` inputs - set false to opt out. May also be enabled on
68
+ * editable inputs, in which case content is formatted on blur (never mid-edit, so user
69
+ * edits and cursor position are preserved while typing).
70
+ */
71
+ autoFormat?: boolean;
72
+
65
73
  /** False to not commit on every change/keystroke, default true. */
66
74
  commitOnChange?: boolean;
67
75
 
@@ -224,7 +232,7 @@ class CodeInputModel extends HoistInputModel {
224
232
  ? button({
225
233
  icon: Icon.magic(),
226
234
  title: 'Auto-format',
227
- onClick: () => this.onAutoFormat()
235
+ onClick: () => this.formatAndSetEditorValue()
228
236
  })
229
237
  : null,
230
238
  showFullscreenButton
@@ -321,7 +329,16 @@ class CodeInputModel extends HoistInputModel {
321
329
  this.editor = new EditorView({state, parent: container});
322
330
  };
323
331
 
324
- onAutoFormat() {
332
+ get autoFormat(): boolean {
333
+ const {autoFormat, readonly} = this.componentProps;
334
+ return withDefault(autoFormat, !!readonly);
335
+ }
336
+
337
+ override toInternal(val: any) {
338
+ return this.autoFormat ? this.tryPrettyPrint(val) : val;
339
+ }
340
+
341
+ private formatAndSetEditorValue() {
325
342
  if (!this.editor) return;
326
343
  const val = this.tryPrettyPrint(this.editor.state.doc.toString());
327
344
  this.editor.dispatch({changes: {from: 0, to: this.editor.state.doc.length, insert: val}});
@@ -443,7 +460,7 @@ class CodeInputModel extends HoistInputModel {
443
460
  {
444
461
  key: 'Mod-p',
445
462
  run: () => {
446
- this.onAutoFormat();
463
+ this.formatAndSetEditorValue();
447
464
  return true;
448
465
  }
449
466
  }
@@ -61,7 +61,7 @@ export interface NumberInputProps extends HoistProps, LayoutProps, StyleProps, H
61
61
  /** Text to display when control is empty. */
62
62
  placeholder?: string;
63
63
 
64
- /** Max decimal precision of the value, defaults to 4. */
64
+ /** Max decimal precision of the value, defaults to 4. Set to null for full, unrestricted precision. */
65
65
  precision?: NumericPrecision;
66
66
 
67
67
  /** Element to display inline on the right side of the input. */
@@ -34,6 +34,7 @@ import classNames from 'classnames';
34
34
  import {castArray, escapeRegExp, isEmpty, isEqual, isNil, isPlainObject, keyBy} from 'lodash';
35
35
  import {ReactElement, ReactNode} from 'react';
36
36
  import {components} from 'react-select';
37
+ import {calcWindowedMenuWidth} from './impl/CalcWindowedMenuWidth';
37
38
  import './Select.scss';
38
39
 
39
40
  export const MENU_PORTAL_ID = 'xh-select-input-portal';
@@ -224,6 +225,8 @@ class SelectInputModel extends HoistInputModel {
224
225
  // Maintained for (but not passed to) async select to resolve value string <> option objects.
225
226
  @bindable.ref internalOptions = [];
226
227
 
228
+ @observable windowedMenuWidth: number = null;
229
+
227
230
  // Prop-backed convenience getters
228
231
  get asyncMode(): boolean {
229
232
  return !!this.componentProps.queryFn;
@@ -290,6 +293,13 @@ class SelectInputModel extends HoistInputModel {
290
293
  run: opts => {
291
294
  opts = this.normalizeOptions(opts);
292
295
  this.internalOptions = opts;
296
+ if (this.windowedMode) {
297
+ this.windowedMenuWidth = calcWindowedMenuWidth(
298
+ opts,
299
+ o => this.formatOptionLabel(o, {context: 'menu'}),
300
+ this.getOrCreatePortalDiv()
301
+ );
302
+ }
293
303
  },
294
304
  fireImmediately: true
295
305
  });
@@ -780,9 +790,10 @@ const cmp = hoistCmp.factory<SelectInputModel>(({model, className, ...props}, re
780
790
  rsProps.formatCreateLabel = model.createMessageFn;
781
791
  }
782
792
 
783
- if (props.menuWidth) {
793
+ const menuWidth = props.menuWidth ?? (model.windowedMode ? model.windowedMenuWidth : null);
794
+ if (menuWidth != null) {
784
795
  rsProps.styles = {
785
- menu: provided => ({...provided, width: `${props.menuWidth}px`}),
796
+ menu: provided => ({...provided, width: menuWidth, minWidth: '100%'}),
786
797
  ...props.rsOptions?.styles
787
798
  };
788
799
  }
@@ -0,0 +1,106 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2026 Extremely Heavy Industries Inc.
6
+ */
7
+ import {logWarn, stripTags} from '@xh/hoist/utils/js';
8
+ import {renderToStaticMarkup} from '@xh/hoist/utils/react';
9
+ import {isEmpty, isNil, isString, sortBy, takeRight} from 'lodash';
10
+ import {isValidElement, ReactNode} from 'react';
11
+
12
+ /** Max number of (widest) options to render and measure when sizing a windowed menu. */
13
+ const SIZE_CALC_SAMPLES = 25;
14
+ /** Allowance for the vertical scrollbar present in windowed (virtualized) menus. */
15
+ const SCROLLBAR_PX = 20;
16
+
17
+ /**
18
+ * Compute an explicit pixel width for a windowed (virtualized) `Select` menu.
19
+ *
20
+ * Windowed menus can't auto-size via CSS - react-window absolutely-positions rows at width:100%,
21
+ * so option content never widens the menu and it collapses to the control width. This restores
22
+ * content-based sizing using Canvas + off-screen DOM, the same render-and-measure approach as the
23
+ * grid's `ColumnWidthCalculator`: render each option's actual menu markup (via the configured
24
+ * `optionRenderer`, if any), canvas-estimate its width to pick the widest candidates, then measure
25
+ * only those candidates' displayed width in a hidden probe - so custom `optionRenderer`s (icons,
26
+ * multi-element content, etc.) size correctly. Unlike the grid (which may autosize many columns
27
+ * over thousands of records), this renders one menu's options once per options change.
28
+ *
29
+ * @param options - normalized (possibly grouped) Select options.
30
+ * @param renderOption - renders an option to its menu `ReactNode` (e.g. model.formatOptionLabel).
31
+ * @param portal - shared menu portal div, used to host probes so menu CSS (font, padding) applies.
32
+ * @returns explicit menu width in px, or null to fall back to the control width.
33
+ * @internal
34
+ */
35
+ export function calcWindowedMenuWidth(
36
+ options: any[],
37
+ renderOption: (opt: any) => ReactNode,
38
+ portal: HTMLElement
39
+ ): number {
40
+ const flatOpts = [],
41
+ collect = opts => opts.forEach(o => (o.options ? collect(o.options) : flatOpts.push(o)));
42
+ collect(options);
43
+ if (isEmpty(flatOpts)) return null;
44
+
45
+ // Render a hidden probe (menu > option) into the portal, so menu CSS (font, padding) applies
46
+ // via its location under document.body.xh-app.
47
+ const menu = document.createElement('div'),
48
+ option = document.createElement('div');
49
+ menu.className = 'xh-select__menu';
50
+ menu.style.cssText = 'position:absolute; visibility:hidden; height:0; overflow:hidden';
51
+ option.className = 'xh-select__option';
52
+ option.style.cssText = 'width:max-content; white-space:nowrap';
53
+ menu.appendChild(option);
54
+ portal.appendChild(menu);
55
+
56
+ try {
57
+ // 1) Render each option's menu markup and canvas-estimate its width (tags stripped).
58
+ const ctx = getCanvasContext(option),
59
+ estimates = flatOpts.map(o => {
60
+ const node = renderOption(o),
61
+ markup = isValidElement(node)
62
+ ? renderToStaticMarkup(node)
63
+ : isNil(node)
64
+ ? ''
65
+ : String(node);
66
+ return {markup, width: getStringWidth(ctx, stripTags(markup))};
67
+ });
68
+
69
+ // 2) Measure only the widest sample's displayed width in the hidden probe.
70
+ const sample = takeRight(sortBy(estimates, 'width'), SIZE_CALC_SAMPLES);
71
+ let ret = 0;
72
+ sample.forEach(({markup}) => {
73
+ option.innerHTML = markup;
74
+ ret = Math.max(ret, Math.ceil(option.clientWidth));
75
+ });
76
+
77
+ // Option padding is already measured; pad only for the windowed scrollbar.
78
+ return ret ? ret + SCROLLBAR_PX : null;
79
+ } catch (e) {
80
+ // E.g. an optionRenderer that can't be statically rendered - fall back to control width.
81
+ logWarn(['Error calculating windowed menu width.', e], 'Select');
82
+ return null;
83
+ } finally {
84
+ portal.removeChild(menu);
85
+ }
86
+ }
87
+
88
+ //------------------
89
+ // Canvas-based width estimation - mirrors grid's ColumnWidthCalculator.
90
+ //------------------
91
+ let _canvas: HTMLCanvasElement;
92
+ function getCanvasContext(measureEl: HTMLElement): CanvasRenderingContext2D {
93
+ if (!_canvas) _canvas = document.createElement('canvas');
94
+
95
+ const ctx = _canvas.getContext('2d'),
96
+ style = window.getComputedStyle(measureEl),
97
+ fontSize = style.getPropertyValue('font-size'),
98
+ fontFamily = style.getPropertyValue('font-family');
99
+
100
+ ctx.font = `${fontSize} ${fontFamily}`;
101
+ return ctx;
102
+ }
103
+
104
+ function getStringWidth(ctx: CanvasRenderingContext2D, string: any): number {
105
+ return isString(string) ? ctx.measureText(string).width : 0;
106
+ }
@@ -68,7 +68,7 @@ function renderDefaultInput(name: string, model: RestFormModel) {
68
68
  case 'number':
69
69
  return numberInput();
70
70
  case 'json':
71
- return jsonInput({enableSearch: true, height: 250});
71
+ return jsonInput({autoFormat: true, enableSearch: true, height: 250});
72
72
  case 'date':
73
73
  return dateInput();
74
74
  case 'localDate':