@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.
Files changed (99) hide show
  1. package/CHANGELOG.md +104 -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/admin/tabs/userData/roles/RoleModel.ts +1 -19
  9. package/appcontainer/RouterModel.ts +2 -0
  10. package/appcontainer/ThemeModel.ts +41 -0
  11. package/appcontainer/login/LoginPanelModel.ts +6 -2
  12. package/build/types/admin/AdminJsonDisplay.d.ts +7 -0
  13. package/build/types/admin/jsonsearch/impl/JsonSearchImplModel.d.ts +2 -2
  14. package/build/types/appcontainer/RouterModel.d.ts +1 -0
  15. package/build/types/appcontainer/ThemeModel.d.ts +9 -0
  16. package/build/types/cmp/grid/Grid.d.ts +6 -3
  17. package/build/types/cmp/grid/GridModel.d.ts +11 -0
  18. package/build/types/cmp/grid/filter/GridFilterFieldSpec.d.ts +24 -6
  19. package/build/types/cmp/input/SegmentedControlOption.d.ts +44 -0
  20. package/build/types/cmp/input/index.d.ts +1 -0
  21. package/build/types/core/AppSpec.d.ts +8 -2
  22. package/build/types/core/HoistAuthModel.d.ts +6 -3
  23. package/build/types/data/filter/BaseFilterFieldSpec.d.ts +7 -1
  24. package/build/types/data/filter/FieldFilter.d.ts +2 -0
  25. package/build/types/desktop/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
  26. package/build/types/desktop/cmp/filechooser/FileChooserModel.d.ts +1 -1
  27. package/build/types/desktop/cmp/input/CodeInput.d.ts +7 -0
  28. package/build/types/desktop/cmp/input/NumberInput.d.ts +1 -1
  29. package/build/types/desktop/cmp/input/SegmentedControl.d.ts +1 -41
  30. package/build/types/desktop/cmp/input/impl/CalcWindowedMenuWidth.d.ts +20 -0
  31. package/build/types/desktop/cmp/rest/impl/RestFormModel.d.ts +1 -1
  32. package/build/types/format/FormatNumber.d.ts +11 -3
  33. package/build/types/icon/Icon.d.ts +5 -3
  34. package/build/types/mobile/cmp/appOption/SizingModeAppOption.d.ts +3 -3
  35. package/build/types/mobile/cmp/appOption/ThemeAppOption.d.ts +3 -3
  36. package/build/types/mobile/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
  37. package/build/types/mobile/cmp/input/NumberInput.d.ts +1 -1
  38. package/build/types/mobile/cmp/input/SegmentedControl.d.ts +47 -0
  39. package/build/types/mobile/cmp/input/SwitchInput.d.ts +1 -1
  40. package/build/types/mobile/cmp/input/TextInput.d.ts +3 -0
  41. package/build/types/mobile/cmp/input/index.d.ts +1 -0
  42. package/build/types/svc/JsonBlobService.d.ts +7 -7
  43. package/cmp/error/ErrorMessage.scss +2 -0
  44. package/cmp/grid/Grid.ts +17 -0
  45. package/cmp/grid/GridModel.ts +17 -5
  46. package/cmp/grid/filter/GridFilterFieldSpec.ts +33 -7
  47. package/cmp/grid/impl/GridHScrollbar.ts +10 -3
  48. package/cmp/grid/impl/MenuSupport.ts +2 -4
  49. package/cmp/input/SegmentedControlOption.ts +61 -0
  50. package/cmp/input/index.ts +1 -0
  51. package/core/AppSpec.ts +9 -1
  52. package/core/HoistAuthModel.ts +13 -6
  53. package/data/Field.ts +3 -1
  54. package/data/filter/BaseFilterFieldSpec.ts +16 -2
  55. package/data/filter/FieldFilter.ts +90 -21
  56. package/desktop/appcontainer/LoginPanel.ts +5 -3
  57. package/desktop/cmp/button/grid/ExpandToLevelButton.ts +4 -5
  58. package/desktop/cmp/filechooser/FileChooserModel.ts +1 -1
  59. package/desktop/cmp/grid/editors/NumberEditor.ts +16 -17
  60. package/desktop/cmp/grid/editors/SelectEditor.ts +15 -10
  61. package/desktop/cmp/grid/impl/filter/GridFilterDialog.ts +2 -2
  62. package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilterModel.ts +9 -4
  63. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +2 -2
  64. package/desktop/cmp/grid/impl/filter/headerfilter/values/ValuesTabModel.ts +46 -7
  65. package/desktop/cmp/input/CheckboxButton.ts +21 -1
  66. package/desktop/cmp/input/CodeInput.ts +20 -3
  67. package/desktop/cmp/input/NumberInput.ts +1 -1
  68. package/desktop/cmp/input/SegmentedControl.ts +8 -51
  69. package/desktop/cmp/input/Select.ts +13 -2
  70. package/desktop/cmp/input/impl/CalcWindowedMenuWidth.ts +106 -0
  71. package/desktop/cmp/rest/impl/RestFormField.ts +1 -1
  72. package/desktop/cmp/tab/Tabs.scss +78 -27
  73. package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts +7 -3
  74. package/docs/error-handling.md +9 -0
  75. package/format/FormatNumber.ts +69 -32
  76. package/icon/Icon.scss +13 -0
  77. package/icon/Icon.ts +7 -3
  78. package/icon/impl/IconHtml.ts +1 -1
  79. package/icon/index.ts +8 -0
  80. package/mcp/data/ts-registry.ts +1 -1
  81. package/mobile/appcontainer/LoginPanel.scss +53 -11
  82. package/mobile/appcontainer/LoginPanel.ts +69 -44
  83. package/mobile/cmp/appOption/SizingModeAppOption.ts +5 -16
  84. package/mobile/cmp/appOption/ThemeAppOption.ts +8 -25
  85. package/mobile/cmp/button/grid/ExpandToLevelButton.ts +4 -5
  86. package/mobile/cmp/error/impl/ErrorMessage.ts +4 -8
  87. package/mobile/cmp/input/CheckboxButton.ts +18 -1
  88. package/mobile/cmp/input/NumberInput.ts +1 -1
  89. package/mobile/cmp/input/SegmentedControl.scss +126 -0
  90. package/mobile/cmp/input/SegmentedControl.ts +210 -0
  91. package/mobile/cmp/input/SwitchInput.ts +1 -1
  92. package/mobile/cmp/input/TextInput.scss +7 -0
  93. package/mobile/cmp/input/TextInput.ts +11 -1
  94. package/mobile/cmp/input/index.ts +1 -0
  95. package/mobile/cmp/tab/impl/Tabs.scss +8 -0
  96. package/mobile/cmp/toolbar/Toolbar.scss +8 -0
  97. package/package.json +7 -7
  98. package/styles/vars.scss +21 -1
  99. package/svc/JsonBlobService.ts +21 -20
@@ -44,7 +44,24 @@ class CheckboxButtonInputModel extends HoistInputModel {
44
44
  // Implementation
45
45
  //----------------------------------
46
46
  const cmp = hoistCmp.factory<CheckboxButtonInputModel>(
47
- ({model, text, checkedIcon, uncheckedIcon, ...props}, ref) => {
47
+ (
48
+ {
49
+ // HoistInput props - exclude from passthrough to Onsen
50
+ bind,
51
+ value,
52
+ commitOnChange,
53
+ onChange,
54
+ onCommit,
55
+ // Consumed by this component
56
+ model,
57
+ text,
58
+ checkedIcon,
59
+ uncheckedIcon,
60
+ // Remainder passed to hoist button & Onsen button
61
+ ...props
62
+ },
63
+ ref
64
+ ) => {
48
65
  const checked = !!model.renderValue;
49
66
  return button({
50
67
  text: withDefault(text, model.getField()?.displayName),
@@ -46,7 +46,7 @@ export interface NumberInputProps extends HoistProps, HoistInputProps, StyleProp
46
46
  /** Text to display when control is empty. */
47
47
  placeholder?: string;
48
48
 
49
- /** Max decimal precision of the value, defaults to 4. */
49
+ /** Max decimal precision of the value, defaults to 4. Set to null for full, unrestricted precision. */
50
50
  precision?: NumericPrecision;
51
51
 
52
52
  /**
@@ -0,0 +1,126 @@
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
+
8
+ // Scoped under .xh-app to win specificity over the .xh-app-scoped mobile Button styles.
9
+ .xh-app {
10
+ .xh-segmented-control {
11
+ // The tray - a recessed container the selected option rises out of.
12
+ display: flex;
13
+ align-items: stretch;
14
+ // Size the whole control (tray padding included) to one standard control height, so it matches
15
+ // a button / input rather than standing taller. Options stretch to fill the tray (below) rather
16
+ // than each taking the full button height, which - plus the tray padding - would overshoot.
17
+ box-sizing: border-box;
18
+ height: var(--xh-button-height);
19
+ background-color: var(--xh-segmented-control-bg);
20
+ border-radius: var(--xh-segmented-control-border-radius-px);
21
+ padding: var(--xh-segmented-control-pad-px);
22
+ gap: var(--xh-segmented-control-pad-px);
23
+
24
+ // Options - buttons receding into the tray by default. Override the minimal Button base so
25
+ // the tray background shows through and our segmented text color applies.
26
+ .xh-button.xh-segmented-control-option {
27
+ flex: 0 0 auto;
28
+ // Fill the tray height (via the tray's align-items: stretch) instead of the fixed button
29
+ // height, so tray padding does not add on top of a full-height button.
30
+ height: auto;
31
+ // Allow the button to shrink below its content width so the label can ellipsis-truncate
32
+ // (flex items default to min-width: auto, which forces overflow / hard clipping instead).
33
+ min-width: 0;
34
+ border-radius: var(--xh-segmented-control-border-radius-px);
35
+ background-color: transparent;
36
+ color: var(--xh-segmented-control-text-color);
37
+
38
+ // Keep icons at full size - only the label gives way when space is tight.
39
+ > svg {
40
+ flex: 0 0 auto;
41
+ }
42
+ }
43
+
44
+ // Label - truncate with an ellipsis when its segment is too narrow to fit the full text.
45
+ .xh-segmented-control-option__label {
46
+ overflow: hidden;
47
+ min-width: 0;
48
+ text-overflow: ellipsis;
49
+ white-space: nowrap;
50
+ }
51
+
52
+ // Selected option - a solid tile standing out from the tray.
53
+ .xh-button.xh-segmented-control-option--selected {
54
+ background-color: var(--xh-segmented-control-selected-bg);
55
+ color: var(--xh-segmented-control-selected-text-color);
56
+ }
57
+
58
+ // Per-option intents - keyed off the className applied to each button. Renders the selected
59
+ // option as a solid fill in the intent color, and tints unselected options' text so an intent
60
+ // (e.g. danger) reads as such even before it is chosen.
61
+ @each $intent in (primary, success, warning, danger) {
62
+ .xh-button.xh-segmented-control-option--#{$intent} {
63
+ color: var(--xh-intent-#{$intent}-text-color);
64
+ }
65
+
66
+ .xh-button.xh-segmented-control-option--#{$intent}.xh-segmented-control-option--selected {
67
+ background-color: var(--xh-intent-#{$intent});
68
+ color: var(--xh-white);
69
+ }
70
+ }
71
+
72
+ // Fill mode (default) - options grow to fill the available width. The basis set here is `auto`
73
+ // (content), so without `equalSegmentWidths` each option keeps its content width and only the
74
+ // leftover space is shared out - a wider label yields a wider segment. The equal-widths rule
75
+ // below (the default) overrides this basis. Options still shrink + ellipsis (see above) when
76
+ // the set genuinely cannot fit.
77
+ &.xh-segmented-control--fill .xh-button.xh-segmented-control-option {
78
+ flex: 1 1 auto;
79
+ }
80
+
81
+ // Equal-width segments (default, via the `equalSegmentWidths` prop) - a 0 flex-basis divides
82
+ // the row into equal parts regardless of label length, the conventional segmented-control
83
+ // appearance. Higher specificity than the content-basis rule above, so it wins when applied.
84
+ &.xh-segmented-control--fill.xh-segmented-control--equal-widths
85
+ .xh-button.xh-segmented-control-option {
86
+ flex-basis: 0;
87
+ }
88
+
89
+ // Outlined mode - border around the tray with no inner background fill.
90
+ &.xh-segmented-control--outlined {
91
+ background-color: transparent;
92
+ border: 1px solid var(--xh-border-color);
93
+
94
+ // Reserve border space on every option so selecting one does not shift the row. The
95
+ // transparent tray offers no recessed backdrop to set a filled tile against, so indicate
96
+ // selection with a ring instead - currentColor keeps it in step with the option's
97
+ // text / intent color.
98
+ .xh-button.xh-segmented-control-option {
99
+ border: 1px solid transparent;
100
+ }
101
+
102
+ .xh-button.xh-segmented-control-option--selected {
103
+ border-color: currentColor;
104
+ }
105
+ }
106
+
107
+ @each $intent in (primary, success, warning, danger) {
108
+ &.xh-segmented-control--outlined.xh-segmented-control--#{$intent} {
109
+ border-color: var(--xh-intent-#{$intent});
110
+ }
111
+ }
112
+
113
+ // Validation borders
114
+ &.xh-input--invalid {
115
+ border: var(--xh-form-field-invalid-border);
116
+ }
117
+
118
+ &.xh-input--warning {
119
+ border: var(--xh-form-field-warning-border);
120
+ }
121
+
122
+ &.xh-input--info {
123
+ border: var(--xh-form-field-info-border);
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,210 @@
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 {
8
+ HoistInputModel,
9
+ HoistInputProps,
10
+ OptionPrimitive,
11
+ SegmentedControlNullOption,
12
+ SegmentedControlOption,
13
+ useHoistInputModel
14
+ } from '@xh/hoist/cmp/input';
15
+ import {hbox, span} from '@xh/hoist/cmp/layout';
16
+ import {hoistCmp, HoistProps, Intent} from '@xh/hoist/core';
17
+ import {button} from '@xh/hoist/mobile/cmp/button';
18
+ import '@xh/hoist/mobile/register';
19
+ import {computed, makeObservable} from '@xh/hoist/mobx';
20
+ import {TEST_ID} from '@xh/hoist/utils/js';
21
+ import {getLayoutProps, getNonLayoutProps} from '@xh/hoist/utils/react';
22
+ import classNames from 'classnames';
23
+ import {filter, isObject} from 'lodash';
24
+ import './SegmentedControl.scss';
25
+
26
+ export interface SegmentedControlProps extends HoistProps, HoistInputProps {
27
+ /**
28
+ * True (default) to render all segments at an equal width when {@link fill} is enabled,
29
+ * dividing the available width into equal parts regardless of label length - the conventional
30
+ * segmented-control appearance. Set false to instead size each segment to its content and
31
+ * share only the leftover space, so a longer label yields a wider segment. No effect when
32
+ * `fill` is false. Either way, a label too wide for its segment truncates with an ellipsis.
33
+ */
34
+ equalSegmentWidths?: boolean;
35
+
36
+ /**
37
+ * True (default) to stretch the control to fill available width,
38
+ * distributing space equally among options.
39
+ */
40
+ fill?: boolean;
41
+
42
+ /**
43
+ * Default visual intent applied to the selected option, and as a subtle text-color hint
44
+ * to options when not selected. Serves as the default for any option that does not specify
45
+ * its own `intent`. Defaults to `'none'`.
46
+ */
47
+ intent?: 'none' | Intent;
48
+
49
+ /**
50
+ * Array of available options. Each entry may be a SegmentedControlOption object
51
+ * with value/label/icon/disabled properties, or a primitive value used as both
52
+ * the value and the display label.
53
+ */
54
+ options: Array<SegmentedControlOption | SegmentedControlNullOption | OptionPrimitive>;
55
+
56
+ /**
57
+ * True to render with an outlined style - a border around the control tray
58
+ * with no inner background fill. Border color follows the current intent.
59
+ */
60
+ outlined?: boolean;
61
+ }
62
+
63
+ /**
64
+ * An input for selecting a single value from a small set of mutually exclusive options,
65
+ * rendered as a group of toggle buttons with clear visual indication of the active
66
+ * selection.
67
+ *
68
+ * Similar to ButtonGroupInput but driven by an `options` prop (like Select) rather than Button
69
+ * children, and with stronger visual differentiation between selected and unselected states.
70
+ * The mobile counterpart to the desktop SegmentedControl, built on Hoist's mobile Button
71
+ * (no Blueprint dependency).
72
+ */
73
+ export const [SegmentedControl, segmentedControl] = hoistCmp.withFactory<SegmentedControlProps>({
74
+ displayName: 'SegmentedControl',
75
+ className: 'xh-segmented-control',
76
+ render(props, ref) {
77
+ return useHoistInputModel(cmp, props, ref, SegmentedControlModel);
78
+ }
79
+ });
80
+ (SegmentedControl as any).hasLayoutSupport = true;
81
+
82
+ //-----------------------
83
+ // Implementation
84
+ //-----------------------
85
+ interface NormalizedOption extends SegmentedControlOption {
86
+ label: string;
87
+ intent?: Intent;
88
+ _key: string;
89
+ }
90
+
91
+ class SegmentedControlModel extends HoistInputModel {
92
+ override xhImpl = true;
93
+
94
+ @computed
95
+ get normalizedOptions(): NormalizedOption[] {
96
+ const options = this.componentProps.options ?? [];
97
+ return options.map((o: any, idx: number) => {
98
+ const key = String(idx);
99
+ if (isObject(o)) {
100
+ const {label, value, icon, disabled, intent} = o as SegmentedControlOption;
101
+ return {
102
+ value: this.toInternal(value),
103
+ label: label ?? (icon ? '' : String(value)),
104
+ icon,
105
+ disabled,
106
+ intent,
107
+ _key: key
108
+ };
109
+ } else {
110
+ return {value: this.toInternal(o), label: String(o), _key: key};
111
+ }
112
+ });
113
+ }
114
+
115
+ /** Map the current render value to the string key used to identify the selected option. */
116
+ @computed
117
+ get selectedKey(): string {
118
+ const {renderValue, normalizedOptions} = this;
119
+ return normalizedOptions.find(o => o.value === renderValue)?._key;
120
+ }
121
+
122
+ get enabledButtons(): HTMLButtonElement[] {
123
+ const btns = this.domEl?.querySelectorAll('button') ?? [];
124
+ return filter(btns, (b: HTMLButtonElement) => !b.disabled) as HTMLButtonElement[];
125
+ }
126
+
127
+ constructor() {
128
+ super();
129
+ makeObservable(this);
130
+ }
131
+
132
+ onValueChange = (key: string) => {
133
+ const match = this.normalizedOptions.find(o => o._key === key);
134
+ if (match) this.noteValueChange(match.value);
135
+ };
136
+
137
+ override blur() {
138
+ this.enabledButtons.forEach(it => it.blur());
139
+ }
140
+
141
+ override focus() {
142
+ this.enabledButtons[0]?.focus();
143
+ }
144
+ }
145
+
146
+ const cmp = hoistCmp.factory<SegmentedControlModel>(({model, className, ...props}, ref) => {
147
+ const {
148
+ // HoistInput props - consumed here or by the model, not passed to the tray
149
+ bind,
150
+ disabled,
151
+ onChange,
152
+ onCommit,
153
+ tabIndex,
154
+ value,
155
+ commitOnChange,
156
+ options,
157
+ // Consumed by this component
158
+ equalSegmentWidths = true,
159
+ fill = true,
160
+ intent,
161
+ outlined,
162
+ testId,
163
+ ...rest
164
+ } = getNonLayoutProps(props);
165
+
166
+ const {selectedKey} = model,
167
+ defaultIntent = intent && intent !== 'none' ? intent : null;
168
+
169
+ const buttons = model.normalizedOptions.map(opt => {
170
+ const optIntent = opt.intent ?? defaultIntent,
171
+ selected = opt._key === selectedKey;
172
+ // Wrap the label so it can truncate with an ellipsis when the segment is too narrow,
173
+ // rather than hard-clipping mid-character. Pass null for icon-only options so the Button
174
+ // renders the icon alone (an empty span would suppress that).
175
+ const label = opt.label
176
+ ? span({className: 'xh-segmented-control-option__label', item: opt.label})
177
+ : null;
178
+
179
+ return button({
180
+ key: opt._key,
181
+ text: label,
182
+ icon: opt.icon,
183
+ disabled: disabled || opt.disabled,
184
+ minimal: true,
185
+ className: classNames(
186
+ 'xh-segmented-control-option',
187
+ selected && 'xh-segmented-control-option--selected',
188
+ optIntent && `xh-segmented-control-option--${optIntent}`
189
+ ),
190
+ onClick: () => model.onValueChange(opt._key)
191
+ });
192
+ });
193
+
194
+ return hbox({
195
+ className: classNames(
196
+ className,
197
+ defaultIntent && `xh-segmented-control--${defaultIntent}`,
198
+ outlined && 'xh-segmented-control--outlined',
199
+ fill && 'xh-segmented-control--fill',
200
+ fill && equalSegmentWidths && 'xh-segmented-control--equal-widths'
201
+ ),
202
+ ref,
203
+ onFocus: model.onFocus,
204
+ onBlur: model.onBlur,
205
+ ...getLayoutProps(props),
206
+ [TEST_ID]: testId,
207
+ items: buttons,
208
+ ...rest
209
+ });
210
+ });
@@ -12,7 +12,7 @@ import {TEST_ID} from '@xh/hoist/utils/js';
12
12
  import './SwitchInput.scss';
13
13
 
14
14
  export interface SwitchInputProps extends HoistProps, HoistInputProps, StyleProps {
15
- value?: string;
15
+ value?: boolean;
16
16
 
17
17
  /** Onsen modifier string */
18
18
  modifier?: string;
@@ -11,6 +11,13 @@
11
11
  flex: 1;
12
12
  }
13
13
 
14
+ &__left-icon {
15
+ display: flex;
16
+ align-items: center;
17
+ padding-left: var(--xh-pad-px);
18
+ color: var(--xh-text-color-muted);
19
+ }
20
+
14
21
  &__clear-button {
15
22
  padding-right: 8px;
16
23
  padding-left: 0;
@@ -5,7 +5,7 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input';
8
- import {hbox} from '@xh/hoist/cmp/layout';
8
+ import {box, hbox} from '@xh/hoist/cmp/layout';
9
9
  import {hoistCmp, HoistProps, LayoutProps, StyleProps} from '@xh/hoist/core';
10
10
  import {Icon} from '@xh/hoist/icon';
11
11
  import {input} from '@xh/hoist/kit/onsen';
@@ -15,6 +15,7 @@ import {getTestId, TEST_ID, withDefault} from '@xh/hoist/utils/js';
15
15
  import {getLayoutProps} from '@xh/hoist/utils/react';
16
16
  import type {Property} from 'csstype';
17
17
  import {isEmpty} from 'lodash';
18
+ import {ReactElement} from 'react';
18
19
  import './TextInput.scss';
19
20
 
20
21
  export interface TextInputProps extends HoistProps, HoistInputProps, StyleProps, LayoutProps {
@@ -46,6 +47,9 @@ export interface TextInputProps extends HoistProps, HoistInputProps, StyleProps,
46
47
  /** True to show a "clear" button aligned to the right of the control. Defaults to false. */
47
48
  enableClear?: boolean;
48
49
 
50
+ /** Icon to display inline on the left side of the input. */
51
+ leftIcon?: ReactElement;
52
+
49
53
  /** Onsen modifier string */
50
54
  modifier?: string;
51
55
 
@@ -123,6 +127,7 @@ const cmp = hoistCmp.factory<TextInputModel>(({model, className, ...props}, ref)
123
127
  width: withDefault(width, null)
124
128
  },
125
129
  items: [
130
+ leftIcon(),
126
131
  input({
127
132
  value: model.renderValue || '',
128
133
 
@@ -151,6 +156,11 @@ const cmp = hoistCmp.factory<TextInputModel>(({model, className, ...props}, ref)
151
156
  });
152
157
  });
153
158
 
159
+ const leftIcon = hoistCmp.factory<TextInputModel>(({model}) => {
160
+ const {leftIcon} = model.componentProps;
161
+ return leftIcon ? box({className: 'xh-text-input__left-icon', item: leftIcon}) : null;
162
+ });
163
+
154
164
  const clearButton = hoistCmp.factory<TextInputModel>(({model}) =>
155
165
  button({
156
166
  className: 'xh-text-input__clear-button',
@@ -11,6 +11,7 @@ export * from './CheckboxButton';
11
11
  export * from './DateInput';
12
12
  export * from './NumberInput';
13
13
  export * from './SearchInput';
14
+ export * from './SegmentedControl';
14
15
  export * from './Select';
15
16
  export * from './SwitchInput';
16
17
  export * from './TextInput';
@@ -25,6 +25,14 @@
25
25
  transform: translateX(0);
26
26
  }
27
27
 
28
+ // Theme the page backdrop. Onsen renders an opaque, light-by-default `.page__background` behind
29
+ // each page's content, and the content itself is forced transparent (so the app background shows
30
+ // through). That leaves the light backdrop visible for tab content that is not itself a panel,
31
+ // rendering themed text unreadable in dark mode - so paint the backdrop with the app background.
32
+ .xh-tab-page .page__background {
33
+ background-color: var(--xh-bg);
34
+ }
35
+
28
36
  .xh-tab-container {
29
37
  // Override Onsen's tabbar height CSS var
30
38
  --tabbar-height: var(--xh-tbar-min-size-px);
@@ -5,6 +5,14 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  .xh-toolbar {
8
+ // Toolbars are the compact-button special case: keep buttons short and tight-cornered so they
9
+ // fit dense bars, overriding the taller / rounder defaults used for buttons in body content
10
+ // (see vars.scss). Derive the button height from the bar size so a single token
11
+ // (--xh-tbar-min-size) drives both - subtracting the bar's vertical padding so buttons fit a
12
+ // horizontal bar. The same height is reused for vertical bars to keep buttons consistent.
13
+ --xh-button-height: calc(var(--xh-tbar-min-size-px) - 2 * var(--xh-pad-half-px));
14
+ --xh-button-border-radius-px: var(--xh-border-radius-px);
15
+
8
16
  padding: var(--xh-pad-half-px) var(--xh-pad-px);
9
17
  color: var(--xh-tbar-text-color);
10
18
  background-color: var(--xh-tbar-bg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "86.0.1",
3
+ "version": "86.2.0",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,14 +39,14 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@auth0/auth0-spa-js": "~2.21.1",
42
- "@azure/msal-browser": "~5.13.0",
42
+ "@azure/msal-browser": "~5.14.0",
43
43
  "@blueprintjs/core": "^6.3.2",
44
44
  "@blueprintjs/datetime": "^6.0.6",
45
- "@codemirror/commands": "~6.10.3",
46
- "@codemirror/language": "~6.12.3",
47
- "@codemirror/language-data": "~6.5.2",
48
- "@codemirror/lint": "~6.9.6",
49
- "@codemirror/state": "~6.6.0",
45
+ "@codemirror/commands": "^6.10.3",
46
+ "@codemirror/language": "^6.12.3",
47
+ "@codemirror/language-data": "^6.5.2",
48
+ "@codemirror/lint": "^6.9.6",
49
+ "@codemirror/state": "^6.7.0",
50
50
  "@codemirror/view": "^6.43.0",
51
51
  "@fortawesome/fontawesome-pro": "^7.2.0",
52
52
  "@fortawesome/fontawesome-svg-core": "^7.2.0",
package/styles/vars.scss CHANGED
@@ -344,7 +344,12 @@ body {
344
344
  --xh-button-active-text-color: var(--button-active-text-color, white);
345
345
  --xh-button-bg: var(--button-bg, #{mc('blue-grey', '600')});
346
346
  --xh-button-text-color: var(--button-text-color, white);
347
- --xh-button-height: var(--button-height, 28px);
347
+ // Default to a comfortable touch height for buttons in body content - a bit more compact than
348
+ // the 44px toolbar/touch-target max, which reads as oversized for XH's denser style. Toolbars
349
+ // derive their own slightly smaller button height from the bar size (see Toolbar.scss).
350
+ --xh-button-height: var(--button-height, 40px);
351
+ // Slightly rounder corners to suit the taller mobile button (the desktop default is tighter).
352
+ --xh-button-border-radius-px: var(--button-border-radius-px, 8px);
348
353
  --xh-toolbar-button-bg: var(--toolbar-button-bg, var(--xh-button-bg));
349
354
 
350
355
  // Light theme pressed bg for 'minimal' / 'outlined'
@@ -839,6 +844,21 @@ body {
839
844
  --xh-tab-font-size: var(--tab-font-size, var(--xh-font-size));
840
845
  --xh-tab-font-size-px: calc(var(--xh-tab-font-size) * 1px);
841
846
 
847
+ // Vertical (left/right) TabSwitcher - the styled "rail" of rounded, hoverable items with a
848
+ // filled active "pill". All overridable via the unprefixed hooks.
849
+ --xh-tab-switcher-vertical-min-width: var(--tab-switcher-vertical-min-width, auto);
850
+ --xh-tab-switcher-vertical-padding: var(--tab-switcher-vertical-padding, var(--xh-pad-half-px));
851
+ --xh-tab-switcher-vertical-border: var(--tab-switcher-vertical-border, var(--xh-border-solid));
852
+ --xh-tab-switcher-vertical-item-gap: var(--tab-switcher-vertical-item-gap, 2px);
853
+ --xh-tab-switcher-vertical-item-padding: var(--tab-switcher-vertical-item-padding, var(--xh-pad-half-px) var(--xh-pad-px));
854
+ --xh-tab-switcher-vertical-item-border-radius: var(--tab-switcher-vertical-item-border-radius, var(--xh-border-radius-px));
855
+ --xh-tab-switcher-vertical-item-text-color: var(--tab-switcher-vertical-item-text-color, var(--xh-text-color-muted));
856
+ --xh-tab-switcher-vertical-item-active-text-color: var(--tab-switcher-vertical-item-active-text-color, var(--xh-tab-active-text-color));
857
+ --xh-tab-switcher-vertical-item-hover-bg: var(--tab-switcher-vertical-item-hover-bg, var(--xh-bg-alt));
858
+ --xh-tab-switcher-vertical-item-active-bg: var(--tab-switcher-vertical-item-active-bg, var(--xh-bg-highlight));
859
+ --xh-tab-switcher-vertical-item-font-weight: var(--tab-switcher-vertical-item-font-weight, 500);
860
+ --xh-tab-switcher-vertical-item-active-font-weight: var(--tab-switcher-vertical-item-active-font-weight, 600);
861
+
842
862
  &.xh-dark {
843
863
  --xh-tab-active-text-color: var(--tab-active-text-color, var(--xh-intent-primary-lighter));
844
864
  }
@@ -52,8 +52,8 @@ export class JsonBlobService extends HoistService {
52
52
  static instance: JsonBlobService;
53
53
 
54
54
  /** Retrieve a single JSONBlob by its unique token. */
55
- async getAsync(token: string): Promise<JsonBlob> {
56
- return this.runner().span('get').fetchJson({
55
+ async getAsync(token: string, ctx?: CallContextLike): Promise<JsonBlob> {
56
+ return this.runner(ctx).span('get').fetchJson({
57
57
  url: 'xh/getJsonBlob',
58
58
  params: {token}
59
59
  });
@@ -63,7 +63,7 @@ export class JsonBlobService extends HoistService {
63
63
  async listAsync(spec: {
64
64
  type: string;
65
65
  includeValue?: boolean;
66
- ctx: CallContextLike;
66
+ ctx?: CallContextLike;
67
67
  }): Promise<JsonBlob[]> {
68
68
  const {type, includeValue, ctx} = spec;
69
69
  return this.runner(ctx)
@@ -72,15 +72,11 @@ export class JsonBlobService extends HoistService {
72
72
  }
73
73
 
74
74
  /** Persist a new JSONBlob back to the server. */
75
- async createAsync({
76
- acl,
77
- description,
78
- type,
79
- meta,
80
- name,
81
- value
82
- }: Partial<JsonBlob>): Promise<JsonBlob> {
83
- return this.runner()
75
+ async createAsync(
76
+ {acl, description, type, meta, name, value}: Partial<JsonBlob>,
77
+ ctx?: CallContextLike
78
+ ): Promise<JsonBlob> {
79
+ return this.runner(ctx)
84
80
  .span('create')
85
81
  .fetchJson({
86
82
  url: 'xh/createJsonBlob',
@@ -91,8 +87,12 @@ export class JsonBlobService extends HoistService {
91
87
  }
92
88
 
93
89
  /** Modify mutable properties of an existing JSONBlob, as identified by its unique token. */
94
- async updateAsync(token: string, update: Partial<JsonBlob>): Promise<JsonBlob> {
95
- return this.runner()
90
+ async updateAsync(
91
+ token: string,
92
+ update: Partial<JsonBlob>,
93
+ ctx?: CallContextLike
94
+ ): Promise<JsonBlob> {
95
+ return this.runner(ctx)
96
96
  .span('update')
97
97
  .run(async ctx => {
98
98
  update = pick(update, ['acl', 'description', 'meta', 'name', 'owner', 'value']);
@@ -110,9 +110,10 @@ export class JsonBlobService extends HoistService {
110
110
  async createOrUpdateAsync(
111
111
  type: string,
112
112
  name: string,
113
- data: Partial<JsonBlob>
113
+ data: Partial<JsonBlob>,
114
+ ctx?: CallContextLike
114
115
  ): Promise<JsonBlob> {
115
- return this.runner()
116
+ return this.runner(ctx)
116
117
  .span('createOrUpdate')
117
118
  .run(async ctx => {
118
119
  const update = pick(data, ['acl', 'description', 'meta', 'value']);
@@ -127,16 +128,16 @@ export class JsonBlobService extends HoistService {
127
128
  }
128
129
 
129
130
  /** Find a blob owned by this user with a specific type and name. If none exists, return null. */
130
- async findAsync(type: string, name: string): Promise<JsonBlob> {
131
- return this.runner().span('find').fetchJson({
131
+ async findAsync(type: string, name: string, ctx?: CallContextLike): Promise<JsonBlob> {
132
+ return this.runner(ctx).span('find').fetchJson({
132
133
  url: 'xh/findJsonBlob',
133
134
  params: {type, name}
134
135
  });
135
136
  }
136
137
 
137
138
  /** Archive (soft-delete) an existing JSONBlob, as identified by its unique token. */
138
- async archiveAsync(token: string): Promise<JsonBlob> {
139
- return this.runner().span('archive').fetchJson({
139
+ async archiveAsync(token: string, ctx?: CallContextLike): Promise<JsonBlob> {
140
+ return this.runner(ctx).span('archive').fetchJson({
140
141
  url: 'xh/archiveJsonBlob',
141
142
  params: {token}
142
143
  });