@xh/hoist 86.0.1 → 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.
- package/CHANGELOG.md +104 -0
- package/admin/AdminJsonDisplay.ts +31 -0
- package/admin/App.scss +3 -11
- package/admin/jsonsearch/impl/JsonSearchImplModel.ts +4 -4
- package/admin/tabs/cluster/instances/connpool/ConnPoolMonitorPanel.ts +3 -11
- package/admin/tabs/cluster/instances/services/DetailsPanel.ts +2 -13
- package/admin/tabs/cluster/objects/DetailPanel.ts +3 -12
- package/admin/tabs/userData/roles/RoleModel.ts +1 -19
- package/appcontainer/RouterModel.ts +2 -0
- package/appcontainer/ThemeModel.ts +41 -0
- package/appcontainer/login/LoginPanelModel.ts +6 -2
- package/build/types/admin/AdminJsonDisplay.d.ts +7 -0
- package/build/types/admin/jsonsearch/impl/JsonSearchImplModel.d.ts +2 -2
- package/build/types/appcontainer/RouterModel.d.ts +1 -0
- package/build/types/appcontainer/ThemeModel.d.ts +9 -0
- package/build/types/cmp/grid/Grid.d.ts +6 -3
- package/build/types/cmp/grid/GridModel.d.ts +11 -0
- package/build/types/cmp/grid/filter/GridFilterFieldSpec.d.ts +24 -6
- package/build/types/cmp/input/SegmentedControlOption.d.ts +44 -0
- package/build/types/cmp/input/index.d.ts +1 -0
- package/build/types/core/AppSpec.d.ts +8 -2
- package/build/types/core/HoistAuthModel.d.ts +6 -3
- package/build/types/data/filter/BaseFilterFieldSpec.d.ts +7 -1
- package/build/types/data/filter/FieldFilter.d.ts +2 -0
- package/build/types/desktop/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
- package/build/types/desktop/cmp/filechooser/FileChooserModel.d.ts +1 -1
- package/build/types/desktop/cmp/input/CodeInput.d.ts +7 -0
- package/build/types/desktop/cmp/input/NumberInput.d.ts +1 -1
- package/build/types/desktop/cmp/input/SegmentedControl.d.ts +1 -41
- package/build/types/desktop/cmp/input/impl/CalcWindowedMenuWidth.d.ts +20 -0
- package/build/types/desktop/cmp/rest/impl/RestFormModel.d.ts +1 -1
- package/build/types/format/FormatNumber.d.ts +11 -3
- package/build/types/icon/Icon.d.ts +5 -3
- package/build/types/mobile/cmp/appOption/SizingModeAppOption.d.ts +3 -3
- package/build/types/mobile/cmp/appOption/ThemeAppOption.d.ts +3 -3
- package/build/types/mobile/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
- package/build/types/mobile/cmp/input/NumberInput.d.ts +1 -1
- package/build/types/mobile/cmp/input/SegmentedControl.d.ts +47 -0
- package/build/types/mobile/cmp/input/SwitchInput.d.ts +1 -1
- package/build/types/mobile/cmp/input/TextInput.d.ts +3 -0
- package/build/types/mobile/cmp/input/index.d.ts +1 -0
- package/build/types/svc/JsonBlobService.d.ts +7 -7
- package/cmp/error/ErrorMessage.scss +2 -0
- package/cmp/grid/Grid.ts +17 -0
- package/cmp/grid/GridModel.ts +17 -5
- package/cmp/grid/filter/GridFilterFieldSpec.ts +33 -7
- package/cmp/grid/impl/GridHScrollbar.ts +10 -3
- package/cmp/grid/impl/MenuSupport.ts +2 -4
- package/cmp/input/SegmentedControlOption.ts +61 -0
- package/cmp/input/index.ts +1 -0
- package/core/AppSpec.ts +9 -1
- package/core/HoistAuthModel.ts +13 -6
- package/data/Field.ts +3 -1
- package/data/filter/BaseFilterFieldSpec.ts +16 -2
- package/data/filter/FieldFilter.ts +90 -21
- package/desktop/appcontainer/LoginPanel.ts +5 -3
- package/desktop/cmp/button/grid/ExpandToLevelButton.ts +4 -5
- package/desktop/cmp/filechooser/FileChooserModel.ts +1 -1
- package/desktop/cmp/grid/editors/NumberEditor.ts +16 -17
- package/desktop/cmp/grid/editors/SelectEditor.ts +15 -10
- package/desktop/cmp/grid/impl/filter/GridFilterDialog.ts +2 -2
- package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilterModel.ts +9 -4
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +2 -2
- package/desktop/cmp/grid/impl/filter/headerfilter/values/ValuesTabModel.ts +46 -7
- package/desktop/cmp/input/CheckboxButton.ts +21 -1
- package/desktop/cmp/input/CodeInput.ts +20 -3
- package/desktop/cmp/input/NumberInput.ts +1 -1
- package/desktop/cmp/input/SegmentedControl.ts +8 -51
- package/desktop/cmp/input/Select.ts +13 -2
- package/desktop/cmp/input/impl/CalcWindowedMenuWidth.ts +106 -0
- package/desktop/cmp/rest/impl/RestFormField.ts +1 -1
- package/desktop/cmp/tab/Tabs.scss +78 -27
- package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts +7 -3
- package/docs/error-handling.md +9 -0
- package/format/FormatNumber.ts +69 -32
- package/icon/Icon.scss +13 -0
- package/icon/Icon.ts +7 -3
- package/icon/impl/IconHtml.ts +1 -1
- package/icon/index.ts +8 -0
- package/mcp/data/ts-registry.ts +1 -1
- package/mobile/appcontainer/LoginPanel.scss +53 -11
- package/mobile/appcontainer/LoginPanel.ts +69 -44
- package/mobile/cmp/appOption/SizingModeAppOption.ts +5 -16
- package/mobile/cmp/appOption/ThemeAppOption.ts +8 -25
- package/mobile/cmp/button/grid/ExpandToLevelButton.ts +4 -5
- package/mobile/cmp/error/impl/ErrorMessage.ts +4 -8
- package/mobile/cmp/input/CheckboxButton.ts +18 -1
- package/mobile/cmp/input/NumberInput.ts +1 -1
- package/mobile/cmp/input/SegmentedControl.scss +126 -0
- package/mobile/cmp/input/SegmentedControl.ts +210 -0
- package/mobile/cmp/input/SwitchInput.ts +1 -1
- package/mobile/cmp/input/TextInput.scss +7 -0
- package/mobile/cmp/input/TextInput.ts +11 -1
- package/mobile/cmp/input/index.ts +1 -0
- package/mobile/cmp/tab/impl/Tabs.scss +8 -0
- package/mobile/cmp/toolbar/Toolbar.scss +8 -0
- package/package.json +7 -7
- package/styles/vars.scss +21 -1
- package/svc/JsonBlobService.ts +21 -20
|
@@ -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
|
|
150
|
+
filter: JSON.stringify(filter ?? null)
|
|
151
151
|
});
|
|
152
152
|
}
|
|
153
153
|
|
|
@@ -98,10 +98,15 @@ export class HeaderFilterModel extends HoistModel {
|
|
|
98
98
|
|
|
99
99
|
@computed
|
|
100
100
|
get isCustomFilter() {
|
|
101
|
-
const {columnCompoundFilter, columnFilters} = this;
|
|
101
|
+
const {columnCompoundFilter, columnFilters, fieldType} = this;
|
|
102
102
|
if (columnCompoundFilter) return true;
|
|
103
103
|
if (isEmpty(columnFilters)) return false;
|
|
104
|
-
return columnFilters.some(it =>
|
|
104
|
+
return columnFilters.some(it => {
|
|
105
|
+
const isValuesTabOp = ['=', '!=', 'includes'].includes(it.op),
|
|
106
|
+
isTagsBlank =
|
|
107
|
+
fieldType === 'tags' && ['=', '!='].includes(it.op) && it.value == null;
|
|
108
|
+
return !isValuesTabOp || isTagsBlank;
|
|
109
|
+
});
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
get commitOnChange() {
|
|
@@ -170,8 +175,8 @@ export class HeaderFilterModel extends HoistModel {
|
|
|
170
175
|
if (close) {
|
|
171
176
|
this.parent.close();
|
|
172
177
|
} else {
|
|
173
|
-
//
|
|
174
|
-
wait().then(() => this.
|
|
178
|
+
// Wait as setFilter is async.
|
|
179
|
+
wait().then(() => this.syncWithFilter());
|
|
175
180
|
}
|
|
176
181
|
}
|
|
177
182
|
|
|
@@ -8,7 +8,7 @@ import {HoistModel} from '@xh/hoist/core';
|
|
|
8
8
|
import {FieldFilterOperator, FieldFilterSpec} from '@xh/hoist/data';
|
|
9
9
|
import {HeaderFilterModel} from '../HeaderFilterModel';
|
|
10
10
|
import {bindable, computed, makeObservable} from '@xh/hoist/mobx';
|
|
11
|
-
import {isArray, isNil} from 'lodash';
|
|
11
|
+
import {isArray, isEmpty, isNil} from 'lodash';
|
|
12
12
|
import {CustomTabModel} from './CustomTabModel';
|
|
13
13
|
|
|
14
14
|
type OperatorOptionValue = 'blank' | 'not blank' | FieldFilterOperator;
|
|
@@ -39,7 +39,7 @@ export class CustomRowModel extends HoistModel {
|
|
|
39
39
|
} else if (op === 'not blank') {
|
|
40
40
|
op = '!=';
|
|
41
41
|
value = null;
|
|
42
|
-
} else if (isNil(value)) {
|
|
42
|
+
} else if (isNil(value) || (isArray(value) && isEmpty(value))) {
|
|
43
43
|
return null;
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -4,13 +4,28 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Copyright © 2026 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
(
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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. */
|
|
@@ -4,22 +4,25 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Copyright © 2026 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
HoistInputModel,
|
|
9
|
+
HoistInputProps,
|
|
10
|
+
OptionPrimitive,
|
|
11
|
+
SegmentedControlNullOption,
|
|
12
|
+
SegmentedControlOption,
|
|
13
|
+
useHoistInputModel
|
|
14
|
+
} from '@xh/hoist/cmp/input';
|
|
8
15
|
import {div} from '@xh/hoist/cmp/layout';
|
|
9
16
|
import {hoistCmp, HoistProps, Intent} from '@xh/hoist/core';
|
|
10
17
|
import '@xh/hoist/desktop/register';
|
|
11
18
|
import {bpSegmentedControl} from '@xh/hoist/kit/blueprint';
|
|
12
19
|
import {computed, makeObservable} from '@xh/hoist/mobx';
|
|
13
|
-
import {LocalDate} from '@xh/hoist/utils/datetime';
|
|
14
20
|
import {getLayoutProps, getNonLayoutProps} from '@xh/hoist/utils/react';
|
|
15
21
|
import {TEST_ID} from '@xh/hoist/utils/js';
|
|
16
22
|
import classNames from 'classnames';
|
|
17
23
|
import {filter, isObject} from 'lodash';
|
|
18
|
-
import {ReactElement} from 'react';
|
|
19
24
|
import './SegmentedControl.scss';
|
|
20
25
|
|
|
21
|
-
type OptionPrimitive = string | number | boolean | LocalDate;
|
|
22
|
-
|
|
23
26
|
export interface SegmentedControlProps extends HoistProps, HoistInputProps {
|
|
24
27
|
/** True to render in a compact mode with reduced sizing for space-constrained contexts. */
|
|
25
28
|
compact?: boolean;
|
|
@@ -51,52 +54,6 @@ export interface SegmentedControlProps extends HoistProps, HoistInputProps {
|
|
|
51
54
|
outlined?: boolean;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
export interface SegmentedControlOption {
|
|
55
|
-
/** Value for this option. */
|
|
56
|
-
value: OptionPrimitive;
|
|
57
|
-
|
|
58
|
-
/** Display label. Defaults to `value.toString()` if omitted. */
|
|
59
|
-
label?: string;
|
|
60
|
-
|
|
61
|
-
/** Icon element, displayed before the label. */
|
|
62
|
-
icon?: ReactElement;
|
|
63
|
-
|
|
64
|
-
/** True to disable this individual option. */
|
|
65
|
-
disabled?: boolean;
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Visual intent for this option - rendered as a solid fill when selected and as a subtle
|
|
69
|
-
* text-color hint when not (e.g. to flag a destructive choice). Overrides any control-level
|
|
70
|
-
* `intent` default. Defaults to the control's `intent`.
|
|
71
|
-
*/
|
|
72
|
-
intent?: Intent;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Variant of SegmentedControlOption for representing a null/"no value" selection.
|
|
77
|
-
* Label is required to force use case to override default js 'null' toString rendering.
|
|
78
|
-
*/
|
|
79
|
-
export interface SegmentedControlNullOption {
|
|
80
|
-
/** Null value for this option. */
|
|
81
|
-
value: null;
|
|
82
|
-
|
|
83
|
-
/** Display label - required for null options. */
|
|
84
|
-
label: string;
|
|
85
|
-
|
|
86
|
-
/** Icon element, displayed before the label. */
|
|
87
|
-
icon?: ReactElement;
|
|
88
|
-
|
|
89
|
-
/** True to disable this individual option. */
|
|
90
|
-
disabled?: boolean;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Visual intent for this option - rendered as a solid fill when selected and as a subtle
|
|
94
|
-
* text-color hint when not (e.g. to flag a destructive choice). Overrides any control-level
|
|
95
|
-
* `intent` default. Defaults to the control's `intent`.
|
|
96
|
-
*/
|
|
97
|
-
intent?: Intent;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
57
|
/**
|
|
101
58
|
* An input for selecting a single value from a small set of mutually exclusive options,
|
|
102
59
|
* rendered as a group of toggle buttons with clear visual indication of the active
|
|
@@ -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
|
-
|
|
793
|
+
const menuWidth = props.menuWidth ?? (model.windowedMode ? model.windowedMenuWidth : null);
|
|
794
|
+
if (menuWidth != null) {
|
|
784
795
|
rsProps.styles = {
|
|
785
|
-
menu: provided => ({...provided, width:
|
|
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':
|
|
@@ -10,13 +10,17 @@
|
|
|
10
10
|
max-width: 100%;
|
|
11
11
|
max-height: 100%;
|
|
12
12
|
flex-direction: column;
|
|
13
|
-
|
|
13
|
+
//noinspection CssRedundantUnit
|
|
14
|
+
flex: 1 1 0%;
|
|
15
|
+
// % unit used to avoid browser fallback to px,
|
|
16
|
+
// which prevents container expansion
|
|
14
17
|
|
|
15
18
|
.xh-tab-wrapper {
|
|
16
19
|
display: flex;
|
|
17
20
|
min-width: 0;
|
|
18
21
|
min-height: 0;
|
|
19
|
-
|
|
22
|
+
//noinspection CssRedundantUnit
|
|
23
|
+
flex: 1 1 0%;
|
|
20
24
|
}
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -32,9 +36,36 @@
|
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
// Generic tab defaults - orientation-specific blocks below layer on top of these.
|
|
40
|
+
.bp6-tab {
|
|
41
|
+
color: var(--xh-tab-text-color);
|
|
42
|
+
|
|
43
|
+
&[aria-disabled='true'] {
|
|
44
|
+
color: var(--xh-tab-disabled-text-color);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Tooltip
|
|
48
|
+
.bp6-popover-target {
|
|
49
|
+
width: 100%;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Active/hover color override
|
|
54
|
+
.bp6-tab[aria-selected='true'],
|
|
55
|
+
.bp6-tab:not([aria-disabled='true']):hover {
|
|
56
|
+
.xh-tab-switcher__tab {
|
|
57
|
+
color: var(--xh-tab-active-text-color);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
&--top {
|
|
36
62
|
padding-left: var(--xh-pad-px);
|
|
37
63
|
max-width: 100%;
|
|
64
|
+
|
|
65
|
+
// Active indicator sits on the bottom edge of the tab, adjacent to the content below.
|
|
66
|
+
.bp6-tab[aria-selected='true'] {
|
|
67
|
+
box-shadow: inset 0 -3px 0 var(--xh-tab-active-text-color);
|
|
68
|
+
}
|
|
38
69
|
}
|
|
39
70
|
|
|
40
71
|
&--bottom {
|
|
@@ -52,6 +83,8 @@
|
|
|
52
83
|
&--right {
|
|
53
84
|
flex-direction: column;
|
|
54
85
|
max-height: 100%;
|
|
86
|
+
min-width: var(--xh-tab-switcher-vertical-min-width);
|
|
87
|
+
padding: var(--xh-tab-switcher-vertical-padding);
|
|
55
88
|
|
|
56
89
|
// Make overflow button full width
|
|
57
90
|
.bp6-popover-target {
|
|
@@ -61,6 +94,49 @@
|
|
|
61
94
|
width: 100%;
|
|
62
95
|
}
|
|
63
96
|
}
|
|
97
|
+
|
|
98
|
+
// Modern vertical rail: rounded, padded items with a muted resting color, a clear hover
|
|
99
|
+
// affordance, and a filled active "pill". Fully driven off the --xh-tab-switcher-vertical-*
|
|
100
|
+
// tokens so it stays light/dark-theme safe and is easily customized or disabled per-app.
|
|
101
|
+
.bp6-tab {
|
|
102
|
+
margin-bottom: var(--xh-tab-switcher-vertical-item-gap);
|
|
103
|
+
padding: var(--xh-tab-switcher-vertical-item-padding);
|
|
104
|
+
border-radius: var(--xh-tab-switcher-vertical-item-border-radius);
|
|
105
|
+
color: var(--xh-tab-switcher-vertical-item-text-color);
|
|
106
|
+
font-weight: var(--xh-tab-switcher-vertical-item-font-weight);
|
|
107
|
+
transition:
|
|
108
|
+
background-color 0.15s ease,
|
|
109
|
+
color 0.15s ease,
|
|
110
|
+
box-shadow 0.15s ease;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.bp6-tab:not([aria-disabled='true']):hover {
|
|
114
|
+
background-color: var(--xh-tab-switcher-vertical-item-hover-bg);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.bp6-tab[aria-selected='true'] {
|
|
118
|
+
background-color: var(--xh-tab-switcher-vertical-item-active-bg);
|
|
119
|
+
font-weight: var(--xh-tab-switcher-vertical-item-active-font-weight);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Hover + active labels take the active/intent text color, re-applied to both the tab and its
|
|
123
|
+
// inner label so it overrides the muted resting color set on the item above.
|
|
124
|
+
.bp6-tab[aria-selected='true'],
|
|
125
|
+
.bp6-tab:not([aria-disabled='true']):hover {
|
|
126
|
+
color: var(--xh-tab-switcher-vertical-item-active-text-color);
|
|
127
|
+
|
|
128
|
+
.xh-tab-switcher__tab {
|
|
129
|
+
color: var(--xh-tab-switcher-vertical-item-active-text-color);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
&--left {
|
|
135
|
+
border-right: var(--xh-tab-switcher-vertical-border);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
&--right {
|
|
139
|
+
border-left: var(--xh-tab-switcher-vertical-border);
|
|
64
140
|
}
|
|
65
141
|
|
|
66
142
|
&__tab {
|
|
@@ -100,29 +176,4 @@
|
|
|
100
176
|
min-height: 20px !important;
|
|
101
177
|
}
|
|
102
178
|
}
|
|
103
|
-
|
|
104
|
-
.bp6-tab {
|
|
105
|
-
color: var(--xh-tab-text-color);
|
|
106
|
-
|
|
107
|
-
&[aria-disabled='true'] {
|
|
108
|
-
color: var(--xh-tab-disabled-text-color);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Tooltip
|
|
112
|
-
.bp6-popover-target {
|
|
113
|
-
width: 100%;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Active/hover color override
|
|
118
|
-
.bp6-tab[aria-selected='true'],
|
|
119
|
-
.bp6-tab:not([aria-disabled='true']):hover {
|
|
120
|
-
.xh-tab-switcher__tab {
|
|
121
|
-
color: var(--xh-tab-active-text-color);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
.bp6-tab[aria-selected='true'] {
|
|
126
|
-
box-shadow: inset 0 -3px 0 var(--xh-tab-active-text-color);
|
|
127
|
-
}
|
|
128
179
|
}
|
|
@@ -24,7 +24,7 @@ import {Icon} from '@xh/hoist/icon';
|
|
|
24
24
|
import {tooltip as bpTooltip, showContextMenu} from '@xh/hoist/kit/blueprint';
|
|
25
25
|
import {dragDropContext, draggable, droppable} from '@xh/hoist/kit/react-beautiful-dnd';
|
|
26
26
|
import {wait} from '@xh/hoist/promise';
|
|
27
|
-
import {consumeEvent} from '@xh/hoist/utils/js';
|
|
27
|
+
import {consumeEvent, getTestId} from '@xh/hoist/utils/js';
|
|
28
28
|
import {composeRefs, getLayoutProps} from '@xh/hoist/utils/react';
|
|
29
29
|
import classNames from 'classnames';
|
|
30
30
|
import {first, isFinite, last} from 'lodash';
|
|
@@ -41,13 +41,14 @@ export const [DynamicTabSwitcher, dynamicTabSwitcher] = hoistCmp.withFactory<Tab
|
|
|
41
41
|
className: 'xh-dynamic-tab-switcher',
|
|
42
42
|
displayName: 'DynamicTabSwitcher',
|
|
43
43
|
model: uses(TabContainerModel),
|
|
44
|
-
render({className, orientation, ...props}) {
|
|
44
|
+
render({className, orientation, testId, ...props}) {
|
|
45
45
|
const impl = useLocalModel(DynamicTabSwitcherLocalModel);
|
|
46
46
|
return scroller({
|
|
47
47
|
className: classNames(className, impl.isVertical && `${className}--vertical`),
|
|
48
48
|
content: tabs,
|
|
49
49
|
contentProps: {localModel: impl},
|
|
50
50
|
orientation: ['left', 'right'].includes(orientation) ? 'vertical' : 'horizontal',
|
|
51
|
+
testId,
|
|
51
52
|
...getLayoutProps(props)
|
|
52
53
|
});
|
|
53
54
|
}
|
|
@@ -127,7 +128,8 @@ const tabCmp = hoistCmp.factory<TabProps>(({tab, index, localModel, model}) => {
|
|
|
127
128
|
{disabled, icon, tooltip} = tab,
|
|
128
129
|
isFavorite = model.isTabFavorite(tab.id),
|
|
129
130
|
{isVertical, props} = localModel,
|
|
130
|
-
{tabWidth, tabMinWidth, tabMaxWidth} = props
|
|
131
|
+
{tabWidth, tabMinWidth, tabMaxWidth} = props,
|
|
132
|
+
tabTestId = getTestId(props, tab.id);
|
|
131
133
|
|
|
132
134
|
// Handle tab sizing props
|
|
133
135
|
const tabStyle: CSSProperties = {};
|
|
@@ -172,6 +174,7 @@ const tabCmp = hoistCmp.factory<TabProps>(({tab, index, localModel, model}) => {
|
|
|
172
174
|
);
|
|
173
175
|
},
|
|
174
176
|
ref: composeRefs(provided.innerRef, tabRef),
|
|
177
|
+
testId: tabTestId,
|
|
175
178
|
...provided.draggableProps,
|
|
176
179
|
...provided.dragHandleProps,
|
|
177
180
|
style: getStyles(isVertical, provided.draggableProps.style),
|
|
@@ -199,6 +202,7 @@ const tabCmp = hoistCmp.factory<TabProps>(({tab, index, localModel, model}) => {
|
|
|
199
202
|
button({
|
|
200
203
|
className:
|
|
201
204
|
'xh-dynamic-tab-switcher__tabs__tab__close-button',
|
|
205
|
+
testId: getTestId(tabTestId, 'remove-btn'),
|
|
202
206
|
icon: Icon.x({size: 'sm'}),
|
|
203
207
|
title: 'Remove Tab',
|
|
204
208
|
minimal: true,
|