@xh/hoist 86.0.0 → 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 (75) hide show
  1. package/CHANGELOG.md +73 -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/build/types/svc/PrefService.d.ts +4 -3
  25. package/build/types/svc/TraceService.d.ts +8 -6
  26. package/build/types/svc/TrackService.d.ts +5 -3
  27. package/build/types/svc/impl/Fetch.d.ts +13 -0
  28. package/build/types/utils/js/TestUtils.d.ts +1 -1
  29. package/build/types/utils/react/LayoutPropUtils.d.ts +1 -1
  30. package/build/types/utils/react/ReactUtils.d.ts +1 -1
  31. package/cmp/error/ErrorMessage.scss +2 -0
  32. package/cmp/grid/filter/GridFilterFieldSpec.ts +5 -2
  33. package/cmp/grid/impl/GridHScrollbar.ts +10 -3
  34. package/cmp/input/SegmentedControlOption.ts +61 -0
  35. package/cmp/input/index.ts +1 -0
  36. package/core/AppSpec.ts +9 -1
  37. package/core/HoistAuthModel.ts +13 -6
  38. package/data/Field.ts +3 -1
  39. package/data/filter/BaseFilterFieldSpec.ts +16 -2
  40. package/data/filter/FieldFilter.ts +90 -21
  41. package/desktop/appcontainer/LoginPanel.ts +5 -3
  42. package/desktop/cmp/filechooser/FileChooserModel.ts +1 -1
  43. package/desktop/cmp/filter/FilterChooser.scss +0 -5
  44. package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilterModel.ts +9 -4
  45. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +2 -2
  46. package/desktop/cmp/input/SegmentedControl.ts +8 -51
  47. package/desktop/cmp/tab/Tabs.scss +6 -2
  48. package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts +7 -3
  49. package/docs/error-handling.md +9 -0
  50. package/icon/Icon.ts +3 -0
  51. package/icon/index.ts +8 -0
  52. package/mobile/appcontainer/LoginPanel.scss +53 -11
  53. package/mobile/appcontainer/LoginPanel.ts +69 -44
  54. package/mobile/cmp/appOption/SizingModeAppOption.ts +5 -16
  55. package/mobile/cmp/appOption/ThemeAppOption.ts +8 -25
  56. package/mobile/cmp/error/impl/ErrorMessage.ts +4 -8
  57. package/mobile/cmp/input/SegmentedControl.scss +126 -0
  58. package/mobile/cmp/input/SegmentedControl.ts +210 -0
  59. package/mobile/cmp/input/SwitchInput.ts +1 -1
  60. package/mobile/cmp/input/TextInput.scss +7 -0
  61. package/mobile/cmp/input/TextInput.ts +11 -1
  62. package/mobile/cmp/input/index.ts +1 -0
  63. package/mobile/cmp/tab/impl/Tabs.scss +8 -0
  64. package/mobile/cmp/toolbar/Toolbar.scss +8 -0
  65. package/package.json +4 -4
  66. package/styles/vars.scss +6 -1
  67. package/svc/JsonBlobService.ts +21 -20
  68. package/svc/PrefService.ts +20 -10
  69. package/svc/TraceService.ts +53 -41
  70. package/svc/TrackService.ts +21 -10
  71. package/svc/impl/Fetch.ts +33 -0
  72. package/utils/js/ClipboardUtils.ts +2 -2
  73. package/utils/js/TestUtils.ts +1 -1
  74. package/utils/react/LayoutPropUtils.ts +1 -1
  75. package/utils/react/ReactUtils.ts +1 -1
@@ -4,22 +4,25 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input';
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
@@ -10,13 +10,17 @@
10
10
  max-width: 100%;
11
11
  max-height: 100%;
12
12
  flex-direction: column;
13
- flex: 1 1 0;
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
- flex: 1 1 0;
22
+ //noinspection CssRedundantUnit
23
+ flex: 1 1 0%;
20
24
  }
21
25
  }
22
26
 
@@ -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,
@@ -490,6 +490,15 @@ The logged payload includes:
490
490
  - Whether the user was shown an alert
491
491
  - An optional user-provided message (via the "Report" dialog)
492
492
 
493
+ > **Routing client errors to a chat system:** Because reported errors land server-side as
494
+ > `'Client Error'` track entries, a Grails service can forward them to a realtime chat system
495
+ > (Slack, Teams, etc.) by subscribing to the `xhTrackReceived` cluster topic - no client changes
496
+ > required. XH's [Toolbox](https://github.com/xh/toolbox) demo app
497
+ > [includes a `SlackAlertService` that does exactly this](https://github.com/xh/toolbox/blob/develop/grails-app/services/io/xh/toolbox/SlackAlertService.groovy),
498
+ > posting client errors (alongside monitor alerts and user feedback) to Slack. See the hoist-core
499
+ > [activity tracking](https://github.com/xh/hoist-core/blob/develop/docs/activity-tracking.md) docs
500
+ > for the server-side topic mechanism.
501
+
493
502
  ### Sensitive Data Redaction
494
503
 
495
504
  The handler automatically redacts values at paths listed in
package/icon/Icon.ts CHANGED
@@ -651,6 +651,9 @@ export const Icon = {
651
651
  shieldCheck(p?: IconProps) {
652
652
  return Icon.icon({...p, iconName: 'shield-check'});
653
653
  },
654
+ shieldHalved(p?: IconProps) {
655
+ return Icon.icon({...p, iconName: 'shield-halved'});
656
+ },
654
657
  sigma(p?: IconProps) {
655
658
  return Icon.icon({...p, iconName: 'sigma'});
656
659
  },
package/icon/index.ts CHANGED
@@ -176,6 +176,7 @@ import {
176
176
  faServer as faServerLight,
177
177
  faShieldAlt as faShieldAltLight,
178
178
  faShieldCheck as faShieldCheckLight,
179
+ faShieldHalved as faShieldHalvedLight,
179
180
  faSigma as faSigmaLight,
180
181
  faSignIn as faSignInLight,
181
182
  faSignOut as faSignOutLight,
@@ -392,6 +393,7 @@ import {
392
393
  faServer,
393
394
  faShieldAlt,
394
395
  faShieldCheck,
396
+ faShieldHalved,
395
397
  faSigma,
396
398
  faSignIn,
397
399
  faSignOut,
@@ -608,6 +610,7 @@ import {
608
610
  faServer as faServerSolid,
609
611
  faShieldAlt as faShieldAltSolid,
610
612
  faShieldCheck as faShieldCheckSolid,
613
+ faShieldHalved as faShieldHalvedSolid,
611
614
  faSigma as faSigmaSolid,
612
615
  faSignIn as faSignInSolid,
613
616
  faSignOut as faSignOutSolid,
@@ -825,6 +828,7 @@ import {
825
828
  faServer as faServerThin,
826
829
  faShieldAlt as faShieldAltThin,
827
830
  faShieldCheck as faShieldCheckThin,
831
+ faShieldHalved as faShieldHalvedThin,
828
832
  faSigma as faSigmaThin,
829
833
  faSignIn as faSignInThin,
830
834
  faSignOut as faSignOutThin,
@@ -1540,6 +1544,10 @@ library.add(
1540
1544
  faShieldCheckLight,
1541
1545
  faShieldCheckSolid,
1542
1546
  faShieldCheckThin,
1547
+ faShieldHalved,
1548
+ faShieldHalvedLight,
1549
+ faShieldHalvedSolid,
1550
+ faShieldHalvedThin,
1543
1551
  faSigma,
1544
1552
  faSigmaLight,
1545
1553
  faSigmaSolid,
@@ -1,18 +1,59 @@
1
1
  .xh-login {
2
- .xh-toolbar {
3
- font-size: var(--xh-appbar-title-font-size-px);
4
- background-color: var(--xh-appbar-bg);
5
- color: var(--xh-appbar-title-color);
2
+ .xh-panel__content {
3
+ align-items: center;
4
+ justify-content: center;
6
5
  }
7
6
 
8
- &__body {
9
- margin: 40px;
7
+ &__content {
8
+ display: flex;
9
+ flex-direction: column;
10
+ width: 90vw;
11
+ max-width: 400px;
12
+ padding: 0 40px;
10
13
  }
11
14
 
12
- .xh-input {
13
- width: 100%;
15
+ &__icon {
16
+ align-self: center;
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ width: 64px;
21
+ height: 64px;
22
+ margin-bottom: var(--xh-pad-double-px);
23
+ font-size: 26px;
24
+ color: var(--xh-intent-primary);
25
+ background-color: var(--xh-intent-primary-trans1);
26
+ border-radius: 10px;
27
+ }
28
+
29
+ &__title {
30
+ color: var(--xh-text-color);
31
+ font-size: var(--xh-font-size-large-px);
32
+ font-weight: 500;
33
+ text-align: center;
34
+ }
35
+
36
+ &__subtitle {
37
+ color: var(--xh-text-color-muted);
38
+ font-size: var(--xh-font-size-small-px);
39
+ margin: 3px 0 var(--xh-pad-double-px);
40
+ text-align: center;
41
+ }
42
+
43
+ &__field {
14
44
  margin-bottom: var(--xh-pad-px);
15
- padding: 4px 8px;
45
+ }
46
+
47
+ &__field-label {
48
+ margin-bottom: var(--xh-pad-half-px);
49
+ font-size: var(--xh-font-size-small-px);
50
+ color: var(--xh-text-color-muted);
51
+ }
52
+
53
+ .xh-text-input {
54
+ width: 100%;
55
+ border: var(--xh-border-solid);
56
+ border-radius: var(--xh-border-radius-px);
16
57
  }
17
58
 
18
59
  &__warning,
@@ -32,7 +73,8 @@
32
73
  background-color: var(--xh-bg-alt);
33
74
  }
34
75
 
35
- .xh-button {
36
- padding: 1.5em 0;
76
+ &__submit {
77
+ margin-top: var(--xh-pad-px);
78
+ padding: var(--xh-pad-double-px) 0;
37
79
  }
38
80
  }
@@ -5,13 +5,12 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {LoginPanelModel} from '@xh/hoist/appcontainer/login/LoginPanelModel';
8
- import {div, filler, form} from '@xh/hoist/cmp/layout';
8
+ import {box, div, form, viewport} from '@xh/hoist/cmp/layout';
9
9
  import {creates, hoistCmp, XH} from '@xh/hoist/core';
10
10
  import {Icon} from '@xh/hoist/icon';
11
11
  import {button} from '@xh/hoist/mobile/cmp/button';
12
12
  import {textInput} from '@xh/hoist/mobile/cmp/input';
13
13
  import {panel} from '@xh/hoist/mobile/cmp/panel';
14
- import {toolbar} from '@xh/hoist/mobile/cmp/toolbar';
15
14
  import './LoginPanel.scss';
16
15
 
17
16
  /**
@@ -25,59 +24,85 @@ export const loginPanel = hoistCmp.factory({
25
24
  model: creates(LoginPanelModel),
26
25
 
27
26
  render({model}) {
28
- const {loginMessage} = XH.appSpec,
29
- {isValid, loadObserver, warning, loginInProgress} = model;
27
+ const {loginMessage, loginPanelIcon, clientAppName} = XH.appSpec,
28
+ {warning, loginInProgress} = model;
30
29
 
31
- return panel({
32
- className: 'xh-login',
33
- testId: 'xh-login',
34
- items: [
35
- toolbar(filler(), XH.clientAppName, filler()),
36
- panel({
37
- className: 'xh-login__body',
38
- mask: loadObserver,
30
+ return viewport(
31
+ panel({
32
+ className: 'xh-login',
33
+ testId: 'xh-login',
34
+ item: div({
35
+ className: 'xh-login__content',
39
36
  items: [
37
+ box({
38
+ className: 'xh-login__icon',
39
+ item: loginPanelIcon ?? Icon.shieldHalved({prefix: 'fas'})
40
+ }),
41
+ div({className: 'xh-login__title', item: `Login to ${clientAppName}`}),
42
+ div({
43
+ className: 'xh-login__subtitle',
44
+ item: 'Enter your credentials to continue.'
45
+ }),
40
46
  form({
41
47
  className: 'xh-login__fields',
42
48
  items: [
43
- textInput({
44
- bind: 'username',
45
- placeholder: 'Username',
46
- autoComplete: 'username',
47
- autoCapitalize: 'none',
48
- commitOnChange: true,
49
- testId: 'xh-login-username'
49
+ field({
50
+ label: 'Username',
51
+ input: textInput({
52
+ bind: 'username',
53
+ leftIcon: Icon.user(),
54
+ autoComplete: 'username',
55
+ autoCapitalize: 'none',
56
+ commitOnChange: true,
57
+ testId: 'xh-login-username'
58
+ })
59
+ }),
60
+ field({
61
+ label: 'Password',
62
+ input: textInput({
63
+ bind: 'password',
64
+ leftIcon: Icon.lock(),
65
+ type: 'password',
66
+ autoComplete: 'current-password',
67
+ commitOnChange: true,
68
+ testId: 'xh-login-password'
69
+ })
50
70
  }),
51
- textInput({
52
- bind: 'password',
53
- placeholder: 'Password',
54
- autoComplete: 'current-password',
55
- type: 'password',
56
- commitOnChange: true,
57
- testId: 'xh-login-password'
71
+ div({
72
+ className: 'xh-login__message',
73
+ omit: !loginMessage,
74
+ item: loginMessage
75
+ }),
76
+ div({
77
+ className: 'xh-login__warning',
78
+ omit: !warning,
79
+ item: warning
80
+ }),
81
+ button({
82
+ className: 'xh-login__submit',
83
+ icon: loginInProgress
84
+ ? Icon.spinner({spin: true})
85
+ : Icon.login(),
86
+ text: loginInProgress ? 'Logging in...' : 'Login',
87
+ intent: 'primary',
88
+ onClick: () => model.submitAsync(),
89
+ testId: 'xh-login-btn'
58
90
  })
59
91
  ]
60
- }),
61
- div({
62
- className: 'xh-login__message',
63
- omit: !loginMessage,
64
- item: loginMessage
65
- }),
66
- div({
67
- className: 'xh-login__warning',
68
- omit: !warning,
69
- item: warning
70
- }),
71
- button({
72
- icon: Icon.login(),
73
- text: loginInProgress ? 'Please wait...' : 'Login',
74
- disabled: !isValid || loginInProgress,
75
- onClick: () => model.submitAsync(),
76
- testId: 'xh-login-btn'
77
92
  })
78
93
  ]
79
94
  })
80
- ]
95
+ })
96
+ );
97
+ }
98
+ });
99
+
100
+ const field = hoistCmp.factory({
101
+ model: false,
102
+ render({label, input}) {
103
+ return div({
104
+ className: 'xh-login__field',
105
+ items: [div({className: 'xh-login__field-label', item: label}), input]
81
106
  });
82
107
  }
83
108
  });
@@ -5,8 +5,7 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {AppOptionSpec, SizingMode, XH} from '@xh/hoist/core';
8
- import {button} from '@xh/hoist/mobile/cmp/button';
9
- import {buttonGroupInput, ButtonGroupInputProps} from '@xh/hoist/mobile/cmp/input';
8
+ import {segmentedControl, SegmentedControlProps} from '@xh/hoist/mobile/cmp/input';
10
9
  import {startCase, values} from 'lodash';
11
10
  import {FormFieldProps} from '../form/FormField';
12
11
  import '@xh/hoist/mobile/register';
@@ -16,8 +15,8 @@ interface SizingModeAppOptionSpec {
16
15
  modes?: SizingMode[];
17
16
  /** Props for nested FormField. */
18
17
  formFieldProps?: Partial<FormFieldProps>;
19
- /** Props for nested ButtonGroupInput. */
20
- inputProps?: Partial<ButtonGroupInputProps>;
18
+ /** Props for nested SegmentedControl. */
19
+ inputProps?: Partial<SegmentedControlProps>;
21
20
  }
22
21
 
23
22
  /**
@@ -33,18 +32,8 @@ export const sizingModeAppOption = ({
33
32
  name: 'sizingMode',
34
33
  formField: {
35
34
  label: 'Grid sizing',
36
- item: buttonGroupInput({
37
- items: modes.map(mode =>
38
- button({
39
- value: mode,
40
- text: startCase(mode),
41
- flex: 1,
42
- style: {
43
- fontSize: `var(--xh-grid-${mode}-font-size-px)`
44
- }
45
- })
46
- ),
47
- width: '100%',
35
+ item: segmentedControl({
36
+ options: modes.map(mode => ({value: mode, label: startCase(mode)})),
48
37
  ...inputProps
49
38
  }),
50
39
  ...formFieldProps
@@ -6,16 +6,15 @@
6
6
  */
7
7
  import {AppOptionSpec, XH} from '@xh/hoist/core';
8
8
  import {Icon} from '@xh/hoist/icon/Icon';
9
- import {button} from '@xh/hoist/mobile/cmp/button';
10
9
  import {FormFieldProps} from '@xh/hoist/mobile/cmp/form';
11
- import {buttonGroupInput, ButtonGroupInputProps} from '@xh/hoist/mobile/cmp/input';
10
+ import {segmentedControl, SegmentedControlProps} from '@xh/hoist/mobile/cmp/input';
12
11
  import '@xh/hoist/mobile/register';
13
12
 
14
13
  interface ThemeAppOptionSpec {
15
14
  /** Props for nested FormField */
16
15
  formFieldProps?: Partial<FormFieldProps>;
17
- /** Props for nested ButtonGroupInput */
18
- inputProps?: Partial<ButtonGroupInputProps>;
16
+ /** Props for nested SegmentedControl */
17
+ inputProps?: Partial<SegmentedControlProps>;
19
18
  }
20
19
 
21
20
  /**
@@ -29,28 +28,12 @@ export const themeAppOption = ({
29
28
  name: 'theme',
30
29
  formField: {
31
30
  label: 'Theme',
32
- item: buttonGroupInput({
33
- items: [
34
- button({
35
- value: 'light',
36
- text: 'Light',
37
- icon: Icon.sun(),
38
- width: '33.33%'
39
- }),
40
- button({
41
- value: 'dark',
42
- text: 'Dark',
43
- icon: Icon.moon(),
44
- width: '33.33%'
45
- }),
46
- button({
47
- value: 'system',
48
- text: 'System',
49
- icon: Icon.sync(),
50
- width: '33.33%'
51
- })
31
+ item: segmentedControl({
32
+ options: [
33
+ {value: 'light', label: 'Light', icon: Icon.sun()},
34
+ {value: 'dark', label: 'Dark', icon: Icon.moon()},
35
+ {value: 'system', label: 'System', icon: Icon.sync()}
52
36
  ],
53
- width: '100%',
54
37
  ...inputProps
55
38
  }),
56
39
  ...formFieldProps
@@ -5,7 +5,7 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {hoistCmp} from '@xh/hoist/core';
8
- import {div, filler, hbox, p} from '@xh/hoist/cmp/layout';
8
+ import {div, p, vbox} from '@xh/hoist/cmp/layout';
9
9
  import {ErrorMessageProps} from '@xh/hoist/cmp/error';
10
10
  import {button, ButtonProps} from '@xh/hoist/mobile/cmp/button';
11
11
  import {Icon} from '@xh/hoist/icon';
@@ -21,15 +21,11 @@ export const errorMessageImpl = hoistCmp.factory<
21
21
  Omit<ErrorMessageProps, 'error' | 'actionFn' | 'detailsFn'>
22
22
  >({
23
23
  render({message, title, actionButtonProps, detailsButtonProps}) {
24
- let buttons = [],
25
- buttonBar = null;
24
+ // Stack action buttons vertically and full-width - idiomatic for mobile.
25
+ const buttons = [];
26
26
  if (detailsButtonProps) buttons.push(detailsButton(detailsButtonProps as ButtonProps));
27
27
  if (actionButtonProps) buttons.push(actionButton(actionButtonProps as ButtonProps));
28
- if (buttons.length == 1) {
29
- buttonBar = buttons[0];
30
- } else if (buttons.length == 2) {
31
- buttonBar = hbox(buttons[0], filler(), buttons[1]);
32
- }
28
+ const buttonBar = buttons.length ? vbox({gap: true, items: buttons}) : null;
33
29
 
34
30
  return div({
35
31
  className: 'xh-error-message__inner',