@xh/hoist 87.2.0 → 87.3.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 CHANGED
@@ -12,6 +12,25 @@
12
12
  3. Plain ASCII punctuation only. Use " - " for in-sentence breaks, never an em dash.
13
13
  -->
14
14
 
15
+ ## 87.3.0 - 2026-09-10
16
+
17
+ ### 🐞 Bug Fixes
18
+
19
+ * Fixed `SegmentedControl` clipping an option's label when `equalSegmentWidths` divided the tray
20
+ too narrowly - options now hold their own content width, sharing equally only where there is room.
21
+ * Fixed `useHotkeys()` throwing a React hook-count error when its arguments changed across
22
+ renders - a collapsible `Panel` given `hotkeys` no longer crashes when first expanded. Hotkeys
23
+ may now also be changed after the first render.
24
+ * Fixed mobile `Navigator` back-navigation leaving the page stack and the route permanently out of
25
+ sync when a stale `allowSlidePrev` caused Swiper to silently skip the transition.
26
+ * Fixed `RadioInput` and `Checkbox` options overhanging the top of the `Toolbar` - they
27
+ are now centered within the compact item height, sharing one rule with `SwitchInput`.
28
+ * Fixed an unlabeled `Slider` (`labelRenderer: false`) sitting high in a compact `Toolbar`.
29
+ * Fixed `SegmentedControl` overflowing a compact `Toolbar` - it now takes its compact sizing from
30
+ the toolbar, without needing its own `compact: true`.
31
+ * Fixed `dateEditor` crashing when opening its picker on a column backed by a `localDate` field -
32
+ the editor now defaults its `valueType` from the Store field type.
33
+
15
34
  ## 87.2.0 - 2026-09-08
16
35
 
17
36
  ### 🎁 New Features
@@ -30,9 +49,6 @@
30
49
  trace spans (new `xh.name` tag), and the Inspector. Set it on any Hoist model config to tell peers
31
50
  of the same class apart - instances log as `ClassName [xhName]`, or `ClassName [id]` when unnamed.
32
51
  Hoist names services, `XH.appModel`, and models created by a named parent automatically.
33
- * Added Favorites to the Inspector's Instances grid - star any instance with an `xhName` to pin it,
34
- and toggle the new `Favorites` quick filter to show only pinned instances. Favorites persist
35
- across reloads, showing as placeholder rows when no instance is live.
36
52
  * Added read-only detail panels to the Admin Console's Config, User Preferences, and JSON Blobs
37
53
  tabs. The Config panel shows every view of a config's value - resolved, instance override,
38
54
  database, and typedClass defaults - and renders notes as Markdown. Editing is now confined to the
@@ -32,7 +32,7 @@ export declare class ColumnWidthCalculator {
32
32
  resetClassNames(): void;
33
33
  setClassNames(sizingMode: any, rowClass: any, cellClass: any): void;
34
34
  getCellEl(): any;
35
- getCellClass(gridModel: any, column: any, record: any, value: any): string;
35
+ getCellClass(column: any, record: any, value: any): string;
36
36
  getRowEl(): any;
37
37
  getRowClass(gridModel: any, record: any): string;
38
38
  getIndentation(depth: any): number;
@@ -6,9 +6,10 @@ import { HotkeyConfig } from '@blueprintjs/core/src/hooks/hotkeys/hotkeyConfig';
6
6
  * The implementation of this hook is based on BlueprintJS.
7
7
  * See their docs {@link https://blueprintjs.com/docs/#core/components/hotkeys} for more info.
8
8
  *
9
+ * Both args may change across renders - keys are (de)registered as they do.
10
+ *
9
11
  * @param child - element to be given hotkey support. Must specify Component
10
12
  * that takes react key events as props (e.g. boxes, panel, div, etc).
11
- * @param hotkeys - An array of hotkeys, or configs for hotkeys,
12
- * as prescribed by blueprint. A Hotkeys element may also be provided.
13
+ * @param hotkeys - configs for the hotkeys to be installed, as prescribed by blueprint.
13
14
  */
14
- export declare function useHotkeys(child?: ReactElement<any>, hotkeys?: HotkeyConfig[]): ReactElement<any, string | import("react").JSXElementConstructor<any>>;
15
+ export declare function useHotkeys(child?: ReactElement<any>, hotkeys?: HotkeyConfig[]): ReactElement;
@@ -45,6 +45,11 @@ export interface NavigatorConfig {
45
45
  export declare class NavigatorModel extends HoistModel {
46
46
  disableAppRefreshButton: boolean;
47
47
  stack: PageModel[];
48
+ /**
49
+ * Index of the active page, synced from Swiper as each transition completes. Observable so
50
+ * `allowSlideNext`/`allowSlidePrev` stay current - Swiper silently skips locked directions.
51
+ */
52
+ activePageIdx: number;
48
53
  pages: PageConfig[];
49
54
  track: boolean;
50
55
  pullDownToRefresh: boolean;
@@ -56,7 +61,6 @@ export declare class NavigatorModel extends HoistModel {
56
61
  private _touchStartX;
57
62
  get activePageId(): string;
58
63
  get activePage(): PageModel;
59
- get activePageIdx(): number;
60
64
  get allowSlideNext(): boolean;
61
65
  get allowSlidePrev(): boolean;
62
66
  constructor({ pages, track, pullDownToRefresh, transitionMs, renderMode, refreshMode, xhName }: NavigatorConfig);
@@ -83,7 +83,7 @@ class ClockLocalModel extends HoistModel {
83
83
 
84
84
  override onLinked() {
85
85
  this.addReaction({
86
- track: () => this.componentProps.timeZone,
86
+ track: () => this.componentProps.timezone,
87
87
  run: () => this.loadTimezoneOffsetAsync(),
88
88
  fireImmediately: true
89
89
  });
@@ -111,8 +111,7 @@ export class ColumnWidthCalculator {
111
111
  }
112
112
 
113
113
  async calcLevelWidthAsync(gridModel, records, column, options, indentationPx = 0) {
114
- const {field, getValueFn, renderer, rendererIsComplex, cellClassFn, cellClassRules} =
115
- column,
114
+ const {field, getValueFn, renderer, rendererIsComplex, cellClassRules} = column,
116
115
  {store, sizingMode, rowClassFn, rowClassRules} = gridModel,
117
116
  bufferPx = column.autosizeBufferPx ?? options.bufferPx;
118
117
 
@@ -163,11 +162,11 @@ export class ColumnWidthCalculator {
163
162
  await forEachAsync(sample, ({value, records}) => {
164
163
  // Get unique combinations of row and cell classes applied
165
164
  const classNames = new Set<string>();
166
- if (rowClassFn || cellClassFn || !isEmpty(rowClassRules) || !isEmpty(cellClassRules)) {
165
+ if (rowClassFn || !isEmpty(rowClassRules) || !isEmpty(cellClassRules)) {
167
166
  records.forEach(record => {
168
167
  const rawValue = getValueFn({record, field, column, gridModel, store}),
169
168
  rowClass = this.getRowClass(gridModel, record),
170
- cellClass = this.getCellClass(gridModel, column, record, rawValue);
169
+ cellClass = this.getCellClass(column, record, rawValue);
171
170
  classNames.add(rowClass + '|' + cellClass);
172
171
  });
173
172
  } else {
@@ -342,14 +341,10 @@ export class ColumnWidthCalculator {
342
341
  return this._cellEl;
343
342
  }
344
343
 
345
- getCellClass(gridModel, column, record, value) {
346
- const {cellClassFn, cellClassRules} = column,
344
+ getCellClass(column, record, value) {
345
+ const {cellClassRules} = column,
347
346
  ret = [];
348
347
 
349
- if (cellClassFn) {
350
- ret.push(cellClassFn({record, column, gridModel}));
351
- }
352
-
353
348
  if (cellClassRules) {
354
349
  forOwn(cellClassRules, (fn, className) => {
355
350
  if (fn({value, data: record})) {
@@ -5,6 +5,7 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {hoistCmp} from '@xh/hoist/core';
8
+ import {FieldType} from '@xh/hoist/data';
8
9
  import {dateInput, DateInputProps} from '@xh/hoist/desktop/cmp/input';
9
10
  import '@xh/hoist/desktop/register';
10
11
  import {warnIf} from '@xh/hoist/utils/js';
@@ -21,12 +22,19 @@ export const [DateEditor, dateEditor] = hoistCmp.withFactory<DateEditorProps>({
21
22
  memo: false,
22
23
  observer: false,
23
24
  render(props, ref) {
25
+ const {gridModel, column, inputProps} = props;
26
+
24
27
  // We need to render the day picker popover inside the grid viewport in order for
25
28
  // `stopEditingWhenCellsLoseFocus` to work properly - otherwise the day picker becomes
26
29
  // unusable due to the grid losing focus and stopping editing when clicking inside picker
27
- const portalContainer = props.gridModel.bodyViewport;
30
+ const portalContainer = gridModel.bodyViewport;
28
31
  warnIf(!portalContainer, 'Could not find the grid viewport for rendering DateEditor');
29
32
 
33
+ const valueType =
34
+ gridModel.store.getField(column.field)?.type === FieldType.LOCAL_DATE
35
+ ? 'localDate'
36
+ : 'date';
37
+
30
38
  props = {
31
39
  ...props,
32
40
  inputProps: {
@@ -34,7 +42,8 @@ export const [DateEditor, dateEditor] = hoistCmp.withFactory<DateEditorProps>({
34
42
  enablePicker: !!portalContainer,
35
43
  showPickerOnFocus: !!portalContainer,
36
44
  portalContainer,
37
- ...props.inputProps
45
+ valueType,
46
+ ...inputProps
38
47
  }
39
48
  };
40
49
  return useInlineEditorModel(dateInput, props, ref);
@@ -74,8 +74,14 @@
74
74
 
75
75
  // Equal segment widths - zeroing the flex basis makes each option take an identical share of the
76
76
  // tray. BP's flex-grow alone divides only the leftover space, leaving options at differing widths.
77
+ // The min-width keeps that equal share from shrinking an option below its own label. A zero
78
+ // basis otherwise lets each option fall to BP's fixed 30px floor, which is unrelated to its
79
+ // content: the horizontal padding is eaten first, then a single-word label spills past the
80
+ // border, since it can neither wrap nor ellipsize. Options only stop sharing equally once there
81
+ // is genuinely not enough room, and then only the ones that need the space keep it.
77
82
  &--fill#{&}--equal-widths .bp6-segmented-control > .bp6-button {
78
83
  flex-basis: 0;
84
+ min-width: min-content;
79
85
  }
80
86
 
81
87
  // Tray background suppressed - a lighter presentation, with no fill behind the options.
@@ -114,8 +120,10 @@
114
120
  }
115
121
  }
116
122
 
117
- // Compact mode - reduced sizing for toolbars and space-constrained contexts.
118
- &--compact .bp6-segmented-control {
123
+ // Compact mode - via the `compact` prop, or implicitly within a compact Toolbar, whose
124
+ // item height the standard sizing above would overflow.
125
+ &--compact .bp6-segmented-control,
126
+ .xh-toolbar--compact & .bp6-segmented-control {
119
127
  padding: 0;
120
128
  gap: 0;
121
129
 
@@ -132,7 +140,8 @@
132
140
  }
133
141
 
134
142
  // Compact + outlined - shrink buttons to account for the border.
135
- &--compact#{&}--outlined .bp6-segmented-control > .bp6-button {
143
+ &--compact#{&}--outlined .bp6-segmented-control > .bp6-button,
144
+ .xh-toolbar--compact &#{&}--outlined .bp6-segmented-control > .bp6-button {
136
145
  --compact-btn-h: calc(var(--xh-tbar-compact-item-height-px) - 2px);
137
146
  min-height: var(--compact-btn-h);
138
147
  max-height: var(--compact-btn-h);
@@ -155,19 +155,33 @@
155
155
  }
156
156
  }
157
157
 
158
- &.xh-switch-input {
158
+ // BP baseline-aligns control indicators, overhanging the bar when compact - flex to center.
159
+ &.xh-switch-input,
160
+ &.xh-check-box,
161
+ &.xh-radio-input .xh-radio-input-option {
159
162
  display: flex;
160
163
  align-items: center;
164
+ padding-inline-start: 0;
161
165
 
162
166
  .bp6-control-indicator {
163
- margin-top: 1px;
164
- margin-right: var(--xh-pad-half-px);
167
+ margin: 0 var(--xh-pad-half-px) 0 0;
168
+ }
169
+
170
+ &.bp6-align-right {
171
+ flex-direction: row-reverse;
172
+ padding-right: 0;
173
+
174
+ .bp6-control-indicator {
175
+ float: none;
176
+ margin: 0 0 0 var(--xh-pad-half-px);
177
+ }
165
178
  }
166
179
  }
167
180
 
168
- &.xh-switch-input.bp6-align-right {
169
- flex-direction: row-reverse;
170
- padding-right: 0;
181
+ // BP's unlabeled slider (labelRenderer: false) is 16px - center it. Labeled ones are 40px
182
+ // and overhang the bar.
183
+ &.xh-slider .bp6-slider-unlabeled {
184
+ margin-top: calc((var(--xh-tbar-compact-item-height-px) - 16px) / 2);
171
185
  }
172
186
 
173
187
  &.xh-select {
@@ -4,29 +4,58 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {hoistCmp, HoistProps} from '@xh/hoist/core';
7
8
  import {useHotkeys as useHotkeysBp} from '@xh/hoist/kit/blueprint';
8
- import {isEmpty} from 'lodash';
9
- import {cloneElement, ReactElement, useMemo} from 'react';
9
+ import {isEqualWith, isFunction} from 'lodash';
10
+ import {cloneElement, ReactElement, useRef} from 'react';
10
11
  import {HotkeyConfig} from '@blueprintjs/core/src/hooks/hotkeys/hotkeyConfig';
11
12
 
12
- /* eslint-disable react-hooks/exhaustive-deps */
13
-
14
13
  /**
15
14
  * Hook to add Key handling support to a component.
16
15
  *
17
16
  * The implementation of this hook is based on BlueprintJS.
18
17
  * See their docs {@link https://blueprintjs.com/docs/#core/components/hotkeys} for more info.
19
18
  *
19
+ * Both args may change across renders - keys are (de)registered as they do.
20
+ *
20
21
  * @param child - element to be given hotkey support. Must specify Component
21
22
  * that takes react key events as props (e.g. boxes, panel, div, etc).
22
- * @param hotkeys - An array of hotkeys, or configs for hotkeys,
23
- * as prescribed by blueprint. A Hotkeys element may also be provided.
23
+ * @param hotkeys - configs for the hotkeys to be installed, as prescribed by blueprint.
24
24
  */
25
- export function useHotkeys(child?: ReactElement<any>, hotkeys?: HotkeyConfig[]) {
26
- if (!child || isEmpty(hotkeys)) return child;
27
-
28
- const memoHotkeys = useMemo(() => hotkeys, []),
29
- {handleKeyDown, handleKeyUp} = useHotkeysBp(memoHotkeys);
25
+ export function useHotkeys(child?: ReactElement<any>, hotkeys?: HotkeyConfig[]): ReactElement {
26
+ return child && hotkeys ? hotkeysHost({child, hotkeys}) : child;
27
+ }
30
28
 
31
- return cloneElement(child, {onKeyDown: handleKeyDown, onKeyUp: handleKeyUp});
29
+ //------------------------
30
+ // Implementation
31
+ //------------------------
32
+ interface HotkeysHostProps extends HoistProps {
33
+ child: ReactElement<any>;
34
+ hotkeys: HotkeyConfig[];
32
35
  }
36
+
37
+ // Hosts BP's hooks, mounted only for callers that pass hotkeys, and only while they have a child
38
+ // to bind them to - BP adds document listeners and warns re. its missing `HotkeysProvider` for
39
+ // every caller of its hook, so it is not run on behalf of the many `Panel`s that specify no keys.
40
+ const hotkeysHost = hoistCmp.factory<HotkeysHostProps>({
41
+ displayName: 'HotkeysHost',
42
+ model: false,
43
+ memo: false,
44
+ observer: false,
45
+
46
+ render({child, hotkeys}) {
47
+ // Hold configs stable, refreshing handlers in place - BP re-registers on array identity.
48
+ const ref = useRef<HotkeyConfig[]>(null),
49
+ prev = ref.current;
50
+ if (prev && isEqualWith(prev, hotkeys, fnEquals)) {
51
+ prev.forEach((hk, idx) => Object.assign(hk, hotkeys[idx]));
52
+ } else {
53
+ ref.current = hotkeys.map(hk => ({...hk}));
54
+ }
55
+
56
+ const {handleKeyDown, handleKeyUp} = useHotkeysBp(ref.current);
57
+ return cloneElement(child, {onKeyDown: handleKeyDown, onKeyUp: handleKeyUp});
58
+ }
59
+ });
60
+
61
+ const fnEquals = (a, b) => (isFunction(a) && isFunction(b) ? true : undefined);
@@ -67,6 +67,12 @@ export class NavigatorModel extends HoistModel {
67
67
  @bindable.ref
68
68
  stack: PageModel[] = [];
69
69
 
70
+ /**
71
+ * Index of the active page, synced from Swiper as each transition completes. Observable so
72
+ * `allowSlideNext`/`allowSlidePrev` stay current - Swiper silently skips locked directions.
73
+ */
74
+ @bindable activePageIdx: number = 0;
75
+
70
76
  pages: PageConfig[] = [];
71
77
  track: boolean;
72
78
  pullDownToRefresh: boolean;
@@ -86,10 +92,6 @@ export class NavigatorModel extends HoistModel {
86
92
  return this.stack[this.activePageIdx];
87
93
  }
88
94
 
89
- get activePageIdx(): number {
90
- return this._swiper?.activeIndex ?? 0;
91
- }
92
-
93
95
  get allowSlideNext(): boolean {
94
96
  return this.activePageIdx < this.stack.length - 1;
95
97
  }
@@ -218,8 +220,9 @@ export class NavigatorModel extends HoistModel {
218
220
  /** @internal */
219
221
  @action
220
222
  onPageChange = () => {
221
- // 1) Clear any pages after the active page. These can be left over from a back swipe.
222
- this.stack = this.stack.slice(0, this._swiper.activeIndex + 1);
223
+ // 1) Sync the active index, then clear any pages after it - left over from a back swipe.
224
+ this.activePageIdx = this._swiper.activeIndex;
225
+ this.stack = this.stack.slice(0, this.activePageIdx + 1);
223
226
 
224
227
  // 2) Sync route to match the current page stack
225
228
  const newRouteName = this.stack.map(it => it.id).join('.'),
@@ -303,7 +306,8 @@ export class NavigatorModel extends HoistModel {
303
306
  if (init) {
304
307
  this.stack = stack;
305
308
  this._swiper.update();
306
- this._swiper.activeIndex = this.stack.length - 1;
309
+ this.activePageIdx = this.stack.length - 1;
310
+ this._swiper.activeIndex = this.activePageIdx;
307
311
  return;
308
312
  }
309
313
 
@@ -315,6 +319,9 @@ export class NavigatorModel extends HoistModel {
315
319
  forwardOnePage = isEqual(newKeyStack.slice(0, -1), currKeyStack);
316
320
 
317
321
  if (backOnePage) {
322
+ // Set directly - a stale `false` here makes slidePrev() a silent no-op.
323
+ this._swiper.allowSlidePrev = true;
324
+
318
325
  // Don't update the stack yet. Instead, wait until after the animation has
319
326
  // completed in onPageChange().
320
327
  this._swiper.slidePrev(transitionMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "87.2.0",
3
+ "version": "87.3.0",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": {
6
6
  "type": "git",