@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 +19 -3
- package/build/types/cmp/grid/impl/ColumnWidthCalculator.d.ts +1 -1
- package/build/types/desktop/hooks/UseHotkeys.d.ts +4 -3
- package/build/types/mobile/cmp/navigator/NavigatorModel.d.ts +5 -1
- package/cmp/clock/Clock.ts +1 -1
- package/cmp/grid/impl/ColumnWidthCalculator.ts +5 -10
- package/desktop/cmp/grid/editors/DateEditor.ts +11 -2
- package/desktop/cmp/input/SegmentedControl.scss +12 -3
- package/desktop/cmp/toolbar/Toolbar.scss +20 -6
- package/desktop/hooks/UseHotkeys.ts +41 -12
- package/mobile/cmp/navigator/NavigatorModel.ts +14 -7
- package/package.json +1 -1
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(
|
|
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 -
|
|
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
|
|
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);
|
package/cmp/clock/Clock.ts
CHANGED
|
@@ -83,7 +83,7 @@ class ClockLocalModel extends HoistModel {
|
|
|
83
83
|
|
|
84
84
|
override onLinked() {
|
|
85
85
|
this.addReaction({
|
|
86
|
-
track: () => this.componentProps.
|
|
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,
|
|
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 ||
|
|
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(
|
|
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(
|
|
346
|
-
const {
|
|
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 =
|
|
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
|
-
|
|
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 -
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 {
|
|
9
|
-
import {cloneElement, ReactElement,
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
222
|
-
this.
|
|
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.
|
|
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);
|