@xh/hoist 86.0.1 → 86.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/admin/tabs/userData/roles/RoleModel.ts +1 -19
  3. package/appcontainer/RouterModel.ts +2 -0
  4. package/appcontainer/ThemeModel.ts +41 -0
  5. package/appcontainer/login/LoginPanelModel.ts +6 -2
  6. package/build/types/appcontainer/RouterModel.d.ts +1 -0
  7. package/build/types/appcontainer/ThemeModel.d.ts +9 -0
  8. package/build/types/cmp/input/SegmentedControlOption.d.ts +44 -0
  9. package/build/types/cmp/input/index.d.ts +1 -0
  10. package/build/types/core/AppSpec.d.ts +8 -2
  11. package/build/types/core/HoistAuthModel.d.ts +6 -3
  12. package/build/types/data/filter/BaseFilterFieldSpec.d.ts +7 -1
  13. package/build/types/data/filter/FieldFilter.d.ts +2 -0
  14. package/build/types/desktop/cmp/filechooser/FileChooserModel.d.ts +1 -1
  15. package/build/types/desktop/cmp/input/SegmentedControl.d.ts +1 -41
  16. package/build/types/icon/Icon.d.ts +1 -0
  17. package/build/types/mobile/cmp/appOption/SizingModeAppOption.d.ts +3 -3
  18. package/build/types/mobile/cmp/appOption/ThemeAppOption.d.ts +3 -3
  19. package/build/types/mobile/cmp/input/SegmentedControl.d.ts +47 -0
  20. package/build/types/mobile/cmp/input/SwitchInput.d.ts +1 -1
  21. package/build/types/mobile/cmp/input/TextInput.d.ts +3 -0
  22. package/build/types/mobile/cmp/input/index.d.ts +1 -0
  23. package/build/types/svc/JsonBlobService.d.ts +7 -7
  24. package/cmp/error/ErrorMessage.scss +2 -0
  25. package/cmp/grid/filter/GridFilterFieldSpec.ts +5 -2
  26. package/cmp/grid/impl/GridHScrollbar.ts +10 -3
  27. package/cmp/input/SegmentedControlOption.ts +61 -0
  28. package/cmp/input/index.ts +1 -0
  29. package/core/AppSpec.ts +9 -1
  30. package/core/HoistAuthModel.ts +13 -6
  31. package/data/Field.ts +3 -1
  32. package/data/filter/BaseFilterFieldSpec.ts +16 -2
  33. package/data/filter/FieldFilter.ts +90 -21
  34. package/desktop/appcontainer/LoginPanel.ts +5 -3
  35. package/desktop/cmp/filechooser/FileChooserModel.ts +1 -1
  36. package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilterModel.ts +9 -4
  37. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +2 -2
  38. package/desktop/cmp/input/SegmentedControl.ts +8 -51
  39. package/desktop/cmp/tab/Tabs.scss +6 -2
  40. package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts +7 -3
  41. package/docs/error-handling.md +9 -0
  42. package/icon/Icon.ts +3 -0
  43. package/icon/index.ts +8 -0
  44. package/mobile/appcontainer/LoginPanel.scss +53 -11
  45. package/mobile/appcontainer/LoginPanel.ts +69 -44
  46. package/mobile/cmp/appOption/SizingModeAppOption.ts +5 -16
  47. package/mobile/cmp/appOption/ThemeAppOption.ts +8 -25
  48. package/mobile/cmp/error/impl/ErrorMessage.ts +4 -8
  49. package/mobile/cmp/input/SegmentedControl.scss +126 -0
  50. package/mobile/cmp/input/SegmentedControl.ts +210 -0
  51. package/mobile/cmp/input/SwitchInput.ts +1 -1
  52. package/mobile/cmp/input/TextInput.scss +7 -0
  53. package/mobile/cmp/input/TextInput.ts +11 -1
  54. package/mobile/cmp/input/index.ts +1 -0
  55. package/mobile/cmp/tab/impl/Tabs.scss +8 -0
  56. package/mobile/cmp/toolbar/Toolbar.scss +8 -0
  57. package/package.json +2 -2
  58. package/styles/vars.scss +6 -1
  59. package/svc/JsonBlobService.ts +21 -20
@@ -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.1.0",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,7 +39,7 @@
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
45
  "@codemirror/commands": "~6.10.3",
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'
@@ -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
  });