@xh/hoist 86.1.0 → 86.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/.gitattributes +4 -0
- package/CHANGELOG.md +104 -0
- package/admin/AdminJsonDisplay.ts +31 -0
- package/admin/App.scss +3 -11
- package/admin/AppModel.ts +3 -4
- package/admin/jsonsearch/impl/JsonSearchImplModel.ts +4 -4
- package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +56 -6
- package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +9 -26
- package/admin/tabs/clients/ClientsModel.ts +4 -1
- package/admin/tabs/clients/activity/ClientDetailModel.ts +5 -1
- package/admin/tabs/cluster/instances/connpool/ConnPoolMonitorPanel.ts +3 -11
- package/admin/tabs/cluster/instances/services/DetailsPanel.ts +2 -13
- package/admin/tabs/cluster/objects/DetailPanel.ts +3 -12
- package/admin/tabs/userData/roles/RolePanel.ts +2 -2
- package/appcontainer/RouterModel.ts +6 -6
- package/build/types/admin/AdminJsonDisplay.d.ts +7 -0
- package/build/types/admin/AppModel.d.ts +3 -4
- package/build/types/admin/jsonsearch/impl/JsonSearchImplModel.d.ts +2 -2
- package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +14 -3
- package/build/types/admin/tabs/clients/ClientsModel.d.ts +3 -0
- package/build/types/appcontainer/RouterModel.d.ts +5 -5
- package/build/types/cmp/grid/Grid.d.ts +8 -4
- package/build/types/cmp/grid/GridModel.d.ts +13 -2
- package/build/types/cmp/grid/Types.d.ts +8 -1
- package/build/types/cmp/grid/filter/GridFilterFieldSpec.d.ts +24 -6
- package/build/types/cmp/input/SegmentedControlOption.d.ts +12 -0
- package/build/types/core/HoistAppModel.d.ts +2 -3
- package/build/types/core/types/Types.d.ts +12 -0
- package/build/types/desktop/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
- package/build/types/desktop/cmp/filter/FilterChooser.d.ts +5 -0
- package/build/types/desktop/cmp/filter/PopoverFilterChooser.d.ts +3 -1
- package/build/types/desktop/cmp/grid/impl/colchooser/ColChooserModel.d.ts +2 -2
- package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.d.ts +9 -1
- package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.d.ts +8 -2
- package/build/types/desktop/cmp/input/CodeInput.d.ts +7 -0
- package/build/types/desktop/cmp/input/NumberInput.d.ts +1 -1
- package/build/types/desktop/cmp/input/Select.d.ts +8 -0
- package/build/types/desktop/cmp/input/impl/CalcWindowedMenuWidth.d.ts +20 -0
- package/build/types/desktop/cmp/rest/impl/RestFormModel.d.ts +1 -1
- package/build/types/format/FormatNumber.d.ts +11 -3
- package/build/types/icon/Icon.d.ts +4 -3
- package/build/types/kit/react-dropzone/index.d.ts +1 -0
- package/build/types/kit/swiper/index.d.ts +4 -3
- package/build/types/mobile/cmp/button/grid/ExpandToLevelButton.d.ts +2 -1
- package/build/types/mobile/cmp/grid/impl/ColChooserModel.d.ts +2 -2
- package/build/types/mobile/cmp/input/NumberInput.d.ts +1 -1
- package/build/types/mobile/cmp/input/Select.d.ts +8 -0
- package/cmp/grid/Grid.ts +27 -2
- package/cmp/grid/GridModel.ts +21 -8
- package/cmp/grid/Types.ts +9 -1
- package/cmp/grid/filter/GridFilterFieldSpec.ts +28 -5
- package/cmp/grid/impl/MenuSupport.ts +3 -5
- package/cmp/input/SegmentedControlOption.ts +14 -0
- package/cmp/store/impl/StoreFilterFieldImplModel.ts +5 -6
- package/core/HoistAppModel.ts +2 -3
- package/core/types/Types.ts +14 -5
- package/desktop/cmp/button/grid/ExpandToLevelButton.ts +4 -5
- package/desktop/cmp/filechooser/FileChooser.ts +6 -3
- package/desktop/cmp/filechooser/FileChooserModel.ts +12 -5
- package/desktop/cmp/filter/FilterChooser.scss +41 -0
- package/desktop/cmp/filter/FilterChooser.ts +147 -7
- package/desktop/cmp/filter/PopoverFilterChooser.ts +7 -84
- package/desktop/cmp/grid/editors/NumberEditor.ts +16 -17
- package/desktop/cmp/grid/editors/SelectEditor.ts +15 -10
- package/desktop/cmp/grid/editors/impl/InlineEditorModel.ts +6 -1
- package/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts +3 -5
- package/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts +2 -2
- package/desktop/cmp/grid/impl/filter/GridFilterDialog.ts +2 -2
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRow.ts +12 -12
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +18 -0
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.scss +5 -0
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.ts +13 -2
- package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.ts +97 -14
- package/desktop/cmp/grid/impl/filter/headerfilter/values/ValuesTabModel.ts +46 -7
- package/desktop/cmp/input/CheckboxButton.ts +21 -1
- package/desktop/cmp/input/CodeInput.ts +20 -3
- package/desktop/cmp/input/NumberInput.ts +1 -1
- package/desktop/cmp/input/SegmentedControl.ts +7 -3
- package/desktop/cmp/input/Select.ts +37 -18
- package/desktop/cmp/input/impl/CalcWindowedMenuWidth.ts +106 -0
- package/desktop/cmp/rest/impl/RestFormField.ts +1 -1
- package/desktop/cmp/tab/Tabs.scss +72 -25
- package/docs/routing.md +4 -2
- package/docs/upgrade-notes/v86-upgrade-notes.md +73 -8
- package/format/FormatNumber.ts +69 -32
- package/icon/Icon.scss +13 -0
- package/icon/Icon.ts +4 -3
- package/icon/impl/IconHtml.ts +1 -1
- package/kit/react-dropzone/index.ts +1 -0
- package/mcp/README.md +12 -0
- package/mcp/data/ts-registry.spec.ts +156 -0
- package/mcp/data/ts-registry.ts +14 -7
- package/mcp/formatters/typescript.ts +7 -3
- package/mcp/util/paths.ts +21 -0
- package/mobile/cmp/button/grid/ExpandToLevelButton.ts +4 -5
- package/mobile/cmp/grid/impl/ColChooserModel.ts +2 -2
- package/mobile/cmp/input/CheckboxButton.ts +18 -1
- package/mobile/cmp/input/NumberInput.ts +1 -1
- package/mobile/cmp/input/SegmentedControl.ts +7 -3
- package/mobile/cmp/input/Select.ts +21 -15
- package/mobx/README.md +2 -1
- package/package.json +10 -10
- package/styles/vars.scss +15 -0
- package/desktop/cmp/filter/PopoverFilterChooser.scss +0 -42
|
@@ -36,9 +36,36 @@
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Generic tab defaults - orientation-specific blocks below layer on top of these.
|
|
40
|
+
.bp6-tab {
|
|
41
|
+
color: var(--xh-tab-text-color);
|
|
42
|
+
|
|
43
|
+
&[aria-disabled='true'] {
|
|
44
|
+
color: var(--xh-tab-disabled-text-color);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Tooltip
|
|
48
|
+
.bp6-popover-target {
|
|
49
|
+
width: 100%;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Active/hover color override
|
|
54
|
+
.bp6-tab[aria-selected='true'],
|
|
55
|
+
.bp6-tab:not([aria-disabled='true']):hover {
|
|
56
|
+
.xh-tab-switcher__tab {
|
|
57
|
+
color: var(--xh-tab-active-text-color);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
39
61
|
&--top {
|
|
40
62
|
padding-left: var(--xh-pad-px);
|
|
41
63
|
max-width: 100%;
|
|
64
|
+
|
|
65
|
+
// Active indicator sits on the bottom edge of the tab, adjacent to the content below.
|
|
66
|
+
.bp6-tab[aria-selected='true'] {
|
|
67
|
+
box-shadow: inset 0 -3px 0 var(--xh-tab-active-text-color);
|
|
68
|
+
}
|
|
42
69
|
}
|
|
43
70
|
|
|
44
71
|
&--bottom {
|
|
@@ -56,6 +83,8 @@
|
|
|
56
83
|
&--right {
|
|
57
84
|
flex-direction: column;
|
|
58
85
|
max-height: 100%;
|
|
86
|
+
min-width: var(--xh-tab-switcher-vertical-min-width);
|
|
87
|
+
padding: var(--xh-tab-switcher-vertical-padding);
|
|
59
88
|
|
|
60
89
|
// Make overflow button full width
|
|
61
90
|
.bp6-popover-target {
|
|
@@ -65,6 +94,49 @@
|
|
|
65
94
|
width: 100%;
|
|
66
95
|
}
|
|
67
96
|
}
|
|
97
|
+
|
|
98
|
+
// Modern vertical rail: rounded, padded items with a muted resting color, a clear hover
|
|
99
|
+
// affordance, and a filled active "pill". Fully driven off the --xh-tab-switcher-vertical-*
|
|
100
|
+
// tokens so it stays light/dark-theme safe and is easily customized or disabled per-app.
|
|
101
|
+
.bp6-tab {
|
|
102
|
+
margin-bottom: var(--xh-tab-switcher-vertical-item-gap);
|
|
103
|
+
padding: var(--xh-tab-switcher-vertical-item-padding);
|
|
104
|
+
border-radius: var(--xh-tab-switcher-vertical-item-border-radius);
|
|
105
|
+
color: var(--xh-tab-switcher-vertical-item-text-color);
|
|
106
|
+
font-weight: var(--xh-tab-switcher-vertical-item-font-weight);
|
|
107
|
+
transition:
|
|
108
|
+
background-color 0.15s ease,
|
|
109
|
+
color 0.15s ease,
|
|
110
|
+
box-shadow 0.15s ease;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.bp6-tab:not([aria-disabled='true']):hover {
|
|
114
|
+
background-color: var(--xh-tab-switcher-vertical-item-hover-bg);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.bp6-tab[aria-selected='true'] {
|
|
118
|
+
background-color: var(--xh-tab-switcher-vertical-item-active-bg);
|
|
119
|
+
font-weight: var(--xh-tab-switcher-vertical-item-active-font-weight);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Hover + active labels take the active/intent text color, re-applied to both the tab and its
|
|
123
|
+
// inner label so it overrides the muted resting color set on the item above.
|
|
124
|
+
.bp6-tab[aria-selected='true'],
|
|
125
|
+
.bp6-tab:not([aria-disabled='true']):hover {
|
|
126
|
+
color: var(--xh-tab-switcher-vertical-item-active-text-color);
|
|
127
|
+
|
|
128
|
+
.xh-tab-switcher__tab {
|
|
129
|
+
color: var(--xh-tab-switcher-vertical-item-active-text-color);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
&--left {
|
|
135
|
+
border-right: var(--xh-tab-switcher-vertical-border);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
&--right {
|
|
139
|
+
border-left: var(--xh-tab-switcher-vertical-border);
|
|
68
140
|
}
|
|
69
141
|
|
|
70
142
|
&__tab {
|
|
@@ -104,29 +176,4 @@
|
|
|
104
176
|
min-height: 20px !important;
|
|
105
177
|
}
|
|
106
178
|
}
|
|
107
|
-
|
|
108
|
-
.bp6-tab {
|
|
109
|
-
color: var(--xh-tab-text-color);
|
|
110
|
-
|
|
111
|
-
&[aria-disabled='true'] {
|
|
112
|
-
color: var(--xh-tab-disabled-text-color);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Tooltip
|
|
116
|
-
.bp6-popover-target {
|
|
117
|
-
width: 100%;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Active/hover color override
|
|
122
|
-
.bp6-tab[aria-selected='true'],
|
|
123
|
-
.bp6-tab:not([aria-disabled='true']):hover {
|
|
124
|
-
.xh-tab-switcher__tab {
|
|
125
|
-
color: var(--xh-tab-active-text-color);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
.bp6-tab[aria-selected='true'] {
|
|
130
|
-
box-shadow: inset 0 -3px 0 var(--xh-tab-active-text-color);
|
|
131
|
-
}
|
|
132
179
|
}
|
package/docs/routing.md
CHANGED
|
@@ -150,10 +150,12 @@ numeric-only parameters). See the
|
|
|
150
150
|
### Conditional Route Exclusion
|
|
151
151
|
|
|
152
152
|
Routes support an `omit` property (a Hoist extension, not part of Router5) that allows
|
|
153
|
-
declarative exclusion of routes at registration time. This is useful for role-gated sections
|
|
153
|
+
declarative exclusion of routes at registration time. This is useful for role-gated sections.
|
|
154
|
+
`getRoutes()` returns `HoistRoute[]` - Hoist's `Route` type extended with the optional `omit` key -
|
|
155
|
+
so the property type-checks without any cast:
|
|
154
156
|
|
|
155
157
|
```typescript
|
|
156
|
-
override getRoutes() {
|
|
158
|
+
override getRoutes(): HoistRoute[] {
|
|
157
159
|
return [{
|
|
158
160
|
name: 'default',
|
|
159
161
|
path: '/app',
|
|
@@ -221,7 +221,21 @@ grep -rn "fileChooser\|FileChooser" client-app/src/
|
|
|
221
221
|
| `minSize` (prop) | `FileChooserModel` config `minFileSize` |
|
|
222
222
|
| `enableMulti` / `enableAddMulti` (props) | `FileChooserModel` config `maxFiles` (set `1` for single-file; omit for unlimited) |
|
|
223
223
|
| `showFileGrid` (prop) | Removed - default display is a grid; customize via the `fileDisplay` content prop |
|
|
224
|
-
| `targetText` (prop) | `FileChooserModel` config `emptyDisplayPrompt` (+ `emptyDisplayHint`)
|
|
224
|
+
| `targetText` (prop) | **Preferred:** `FileChooserModel` config `emptyDisplayPrompt` (+ `emptyDisplayHint`). Use the `emptyDisplay` content prop only for a fully custom layout. |
|
|
225
|
+
|
|
226
|
+
> **`emptyDisplay` and `fileDisplay` switch on file *presence*; v85's `targetText` did not.**
|
|
227
|
+
> In v85 the drop target that rendered `targetText` was **always visible** - even with files
|
|
228
|
+
> selected - and the file grid (if enabled) sat alongside it. v86 has no always-visible text slot:
|
|
229
|
+
> `emptyDisplay` renders **only when no files are selected**, and `fileDisplay` (defaulting to the
|
|
230
|
+
> grid/card) renders **only when files are present**. There is therefore no mechanical mapping for a
|
|
231
|
+
> `targetText` - you must decide which presence-state its content belonged to:
|
|
232
|
+
> - A drop prompt ("Drag and drop files here") is empty-state content - set it as
|
|
233
|
+
> `emptyDisplayPrompt` (a plain string, preferred - keeps Hoist's icon, styling, and the
|
|
234
|
+
> auto-generated constraints hint). When files exist, the grid/`fileDisplay` takes over.
|
|
235
|
+
> - A message that only makes sense once a file is chosen (e.g. "Ready to upload") belongs in
|
|
236
|
+
> `fileDisplay`, which replaces the default grid - so set it only if you don't need that grid.
|
|
237
|
+
> - If your `targetText` varied on an **app flag** rather than on file presence, preserve that flag
|
|
238
|
+
> *inside* the slot you choose - do not let the empty/file switch silently stand in for it.
|
|
225
239
|
|
|
226
240
|
Before (v85 - config as component props, model takes no args):
|
|
227
241
|
```typescript
|
|
@@ -283,17 +297,68 @@ constructor() {
|
|
|
283
297
|
}
|
|
284
298
|
```
|
|
285
299
|
|
|
286
|
-
**
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
300
|
+
**Prefer the styled text prompt over rebuilding the UI.** Most `targetText` values were a string or
|
|
301
|
+
a simple `placeholder(Icon.upload(), '...')` - which is exactly what the default empty display
|
|
302
|
+
already renders. Map these to `emptyDisplayPrompt` (a plain string on the model), optionally with
|
|
303
|
+
`emptyDisplayHint`: you keep Hoist's upload icon, styling, and the auto-generated summary of accepted
|
|
304
|
+
types and size/count limits. This holds even when `targetText` was a `placeholder(...)` element -
|
|
305
|
+
collapse it back to the prompt string rather than reaching for a content prop.
|
|
290
306
|
|
|
291
307
|
```typescript
|
|
292
|
-
// Before (v85)
|
|
308
|
+
// Before (v85) - a plain-text or simple-placeholder targetText
|
|
309
|
+
fileChooser({model, targetText: 'Drop loan docs here'})
|
|
293
310
|
fileChooser({model, targetText: placeholder(Icon.upload(), 'Drop loan docs here')})
|
|
294
311
|
|
|
295
|
-
// After (v86)
|
|
296
|
-
|
|
312
|
+
// After (v86) - map targetText to a styled prompt on the model; icon, styling, and the constraints
|
|
313
|
+
// hint come for free, and the component render needs no change
|
|
314
|
+
new FileChooserModel({emptyDisplayPrompt: 'Drop loan docs here'});
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Reserve the `emptyDisplay` / `fileDisplay` content props for layouts the prompt/hint genuinely can't
|
|
318
|
+
express. Note they are **not** interchangeable targets for a `targetText`: `emptyDisplay` renders
|
|
319
|
+
only when empty and `fileDisplay` only when files are present, so content meant for the
|
|
320
|
+
file-populated state must go to `fileDisplay` - never `emptyDisplay`.
|
|
321
|
+
|
|
322
|
+
**If your `targetText` was conditional** - a ternary or computed expression - work out what the
|
|
323
|
+
condition actually tracked before migrating, because v86 already switches content on file presence.
|
|
324
|
+
|
|
325
|
+
In the common case the condition tracked *file presence* (a flag toggled as files are added and
|
|
326
|
+
removed). v86's built-in switch replaces it directly: the no-file prompt becomes `emptyDisplayPrompt`
|
|
327
|
+
and the flag is dropped. The plain drop text needs no content prop at all - only the custom "ready"
|
|
328
|
+
affirmation, meant for the file-populated state, uses `fileDisplay` (which overrides the default
|
|
329
|
+
grid). `enableMulti` moves to `maxFiles` on the model:
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
// Before (v85) - targetText always visible, toggled by a presence-derived flag
|
|
333
|
+
fileChooser({
|
|
334
|
+
model,
|
|
335
|
+
enableMulti: !singleDoc,
|
|
336
|
+
targetText: hasFile
|
|
337
|
+
? placeholder(Icon.check({intent: 'success'}), 'Ready to upload')
|
|
338
|
+
: placeholder(Icon.upload(), 'Drag and drop files here')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// After (v86) - the presence switch is built in, so the flag is no longer needed
|
|
342
|
+
this.chooserModel = new FileChooserModel({
|
|
343
|
+
maxFiles: singleDoc ? 1 : null,
|
|
344
|
+
emptyDisplayPrompt: 'Drag and drop files here'
|
|
345
|
+
});
|
|
346
|
+
fileChooser({
|
|
347
|
+
model: this.chooserModel,
|
|
348
|
+
fileDisplay: placeholder(Icon.check({intent: 'success'}), 'Ready to upload')
|
|
349
|
+
})
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The trap is a condition that is **independent of file presence** (e.g. an "uploads open" flag). Its
|
|
353
|
+
branches are *not* an empty/file pair, so splitting them across `emptyDisplay` / `fileDisplay` would
|
|
354
|
+
drop the condition and silently re-key the content onto presence. Keep such a flag *inside* the slot
|
|
355
|
+
its content belongs to - here, still on the styled prompt:
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// independent flag - preserved on the styled prompt (model config), not mapped onto presence
|
|
359
|
+
new FileChooserModel({
|
|
360
|
+
emptyDisplayPrompt: acceptingUploads ? 'Drag and drop files here' : 'Uploads are closed'
|
|
361
|
+
});
|
|
297
362
|
```
|
|
298
363
|
|
|
299
364
|
### 4. Remove Deleted Mobile `DateInput` Props
|
package/format/FormatNumber.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
isNil,
|
|
15
15
|
isNumber,
|
|
16
16
|
isString,
|
|
17
|
+
isUndefined,
|
|
17
18
|
round
|
|
18
19
|
} from 'lodash';
|
|
19
20
|
import Numbro from 'numbro';
|
|
@@ -78,8 +79,9 @@ export interface NumberFormatOptions extends Omit<FormatOptions<number>, 'toolti
|
|
|
78
79
|
omitFourDigitComma?: boolean;
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
|
-
* Desired number of decimal places,
|
|
82
|
-
* automatically based on the scale of the value
|
|
82
|
+
* Desired number of decimal places, 'auto' (default) to adjust the displayed precision
|
|
83
|
+
* automatically based on the scale of the value, or null for full, unrestricted precision
|
|
84
|
+
* (capped at the max supported precision, with trailing zeros trimmed).
|
|
83
85
|
*/
|
|
84
86
|
precision?: Precision;
|
|
85
87
|
|
|
@@ -118,15 +120,23 @@ export interface NumberFormatOptions extends Omit<FormatOptions<number>, 'toolti
|
|
|
118
120
|
*
|
|
119
121
|
* e.g. `{precision:4, zeroPad:2}` will format `1.2` → "1.20" and `1.234` → "1.234"
|
|
120
122
|
*
|
|
121
|
-
* Default is true if a fixed precision is set, false if precision is 'auto'.
|
|
123
|
+
* Default is true if a fixed precision is set, false if precision is 'auto' or null (full).
|
|
122
124
|
*/
|
|
123
125
|
zeroPad?: ZeroPad;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
export interface QuantityFormatOptions extends NumberFormatOptions {
|
|
129
|
+
/** True to compact values \>= 1 million into units of millions (m). Default true. */
|
|
127
130
|
useMillions?: boolean;
|
|
128
131
|
|
|
132
|
+
/** True to compact values \>= 1 billion into units of billions (b). Default true. */
|
|
129
133
|
useBillions?: boolean;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* True to compact to m/b units only when no precision is lost, else render the full value.
|
|
137
|
+
* Default false.
|
|
138
|
+
*/
|
|
139
|
+
lossless?: boolean;
|
|
130
140
|
}
|
|
131
141
|
|
|
132
142
|
/** Config for pos/neg/neutral color classes. */
|
|
@@ -175,9 +185,16 @@ export function fmtNumber(v: number, opts?: NumberFormatOptions): ReactNode {
|
|
|
175
185
|
} = opts ?? ({} as NumberFormatOptions);
|
|
176
186
|
if (isInvalidInput(v)) return nullDisplay;
|
|
177
187
|
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
if (
|
|
188
|
+
// Resolve precision: null means full precision, other non-integers (e.g. undefined) mean 'auto'.
|
|
189
|
+
const fullPrecision = precision === null;
|
|
190
|
+
if (fullPrecision) {
|
|
191
|
+
precision = MAX_NUMERIC_PRECISION;
|
|
192
|
+
} else if (!isInteger(precision)) {
|
|
193
|
+
precision = 'auto';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Default zeroPad to pad only for a fixed precision - 'auto' and full precision trim zeros.
|
|
197
|
+
if (isNil(zeroPad)) zeroPad = precision != 'auto' && !fullPrecision;
|
|
181
198
|
|
|
182
199
|
formatConfig =
|
|
183
200
|
formatConfig || buildFormatConfig(v, precision, zeroPad, withCommas, omitFourDigitComma);
|
|
@@ -271,20 +288,40 @@ export function fmtQuantity(v: number, opts?: QuantityFormatOptions) {
|
|
|
271
288
|
saveOriginal(v, opts);
|
|
272
289
|
if (isInvalidInput(v)) return fmtNumber(v, opts);
|
|
273
290
|
|
|
274
|
-
const lessThanM = Math.abs(v) < MILLION,
|
|
275
|
-
lessThanB = Math.abs(v) < BILLION;
|
|
276
|
-
|
|
277
291
|
defaults(opts, {
|
|
278
292
|
ledger: true,
|
|
279
293
|
label: true,
|
|
280
|
-
precision: lessThanM ? 0 : 2,
|
|
281
294
|
useMillions: true,
|
|
282
|
-
useBillions: true
|
|
295
|
+
useBillions: true,
|
|
296
|
+
lossless: false
|
|
283
297
|
});
|
|
284
298
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
299
|
+
const absV = Math.abs(v),
|
|
300
|
+
lessM = absV < MILLION,
|
|
301
|
+
lessB = absV < BILLION,
|
|
302
|
+
targetPrecision = opts.precision ?? (lessM ? 0 : 2);
|
|
303
|
+
|
|
304
|
+
// Compute scaling, if any (Lossless flag may preclude).
|
|
305
|
+
let scale = !lessB && opts.useBillions ? BILLION : !lessM && opts.useMillions ? MILLION : null;
|
|
306
|
+
if (scale && opts.lossless) {
|
|
307
|
+
const precision = parsePrecision(absV / scale, targetPrecision),
|
|
308
|
+
lossy = v % (scale / 10 ** precision) !== 0;
|
|
309
|
+
if (lossy) scale = null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Resolve render precision (unless the caller set one).
|
|
313
|
+
if (isUndefined(opts.precision)) {
|
|
314
|
+
opts.precision = opts.lossless ? null : targetPrecision;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
switch (scale) {
|
|
318
|
+
case BILLION:
|
|
319
|
+
return fmtBillions(v, opts);
|
|
320
|
+
case MILLION:
|
|
321
|
+
return fmtMillions(v, opts);
|
|
322
|
+
default:
|
|
323
|
+
return fmtNumber(v, opts);
|
|
324
|
+
}
|
|
288
325
|
}
|
|
289
326
|
|
|
290
327
|
/**
|
|
@@ -462,6 +499,23 @@ function calcStyleFromColorSpec(v: number, colorSpec: ColorSpec | boolean): CSSP
|
|
|
462
499
|
return !isString(possibleStyles) ? possibleStyles : {};
|
|
463
500
|
}
|
|
464
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Resolve a precisionSpec to a concrete number of decimal places. A fixed precision is used as-is
|
|
504
|
+
* (capped at max), while 'auto' is derived from the scale of the value.
|
|
505
|
+
*/
|
|
506
|
+
function parsePrecision(v: number, precisionSpec: Precision): number {
|
|
507
|
+
// Fixed precision - use requested, capped at max.
|
|
508
|
+
if (precisionSpec !== 'auto') return Math.min(precisionSpec, MAX_NUMERIC_PRECISION);
|
|
509
|
+
|
|
510
|
+
// 'auto' - derive from the scale of the value.
|
|
511
|
+
const absVal = Math.abs(v);
|
|
512
|
+
if (absVal === 0) return 2;
|
|
513
|
+
if (absVal < 0.01) return 6;
|
|
514
|
+
if (absVal < 100) return 4;
|
|
515
|
+
if (absVal < 10000) return 2;
|
|
516
|
+
return 0;
|
|
517
|
+
}
|
|
518
|
+
|
|
465
519
|
function buildFormatConfig(
|
|
466
520
|
v: number,
|
|
467
521
|
precisionSpec: Precision,
|
|
@@ -472,24 +526,7 @@ function buildFormatConfig(
|
|
|
472
526
|
const absVal = Math.abs(v),
|
|
473
527
|
config: Numbro.Format = {};
|
|
474
528
|
|
|
475
|
-
|
|
476
|
-
if (precisionSpec === 'auto') {
|
|
477
|
-
// Auto-precision - base on scale of number
|
|
478
|
-
if (absVal === 0) {
|
|
479
|
-
precision = 2;
|
|
480
|
-
} else if (absVal < 0.01) {
|
|
481
|
-
precision = 6;
|
|
482
|
-
} else if (absVal < 100) {
|
|
483
|
-
precision = 4;
|
|
484
|
-
} else if (absVal < 10000) {
|
|
485
|
-
precision = 2;
|
|
486
|
-
} else {
|
|
487
|
-
precision = 0;
|
|
488
|
-
}
|
|
489
|
-
} else {
|
|
490
|
-
// Fixed precision - use requested, capped at max.
|
|
491
|
-
precision = precisionSpec < MAX_NUMERIC_PRECISION ? precisionSpec : MAX_NUMERIC_PRECISION;
|
|
492
|
-
}
|
|
529
|
+
const precision = parsePrecision(v, precisionSpec);
|
|
493
530
|
|
|
494
531
|
// If zeroPad gte precision, treat as `true` to pad out to (but not beyond) full precision.
|
|
495
532
|
// We don't support applying some precision (rounding) then padding out zeroes after that.
|
package/icon/Icon.scss
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
// FA v7 sizes icons via the `--fa-width` custom property (default 1.25em) rather than the old
|
|
9
|
+
// `fa-fw` class. The placeholder is a plain div, so it reads the same property to stay aligned
|
|
10
|
+
// with sibling icons under any width override, falling back to FA's default when unset.
|
|
11
|
+
.xh-icon--placeholder {
|
|
12
|
+
width: var(--fa-width, 1.25em);
|
|
13
|
+
}
|
package/icon/Icon.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {ReactElement} from 'react';
|
|
|
15
15
|
import {iconCmp} from './impl/IconCmp';
|
|
16
16
|
import {enhanceFaClasses, iconHtml} from './impl/IconHtml';
|
|
17
17
|
import {SetRequired} from 'type-fest';
|
|
18
|
+
import './Icon.scss';
|
|
18
19
|
|
|
19
20
|
export interface IconProps extends HoistProps, Partial<Omit<FontAwesomeIconProps, 'ref'>> {
|
|
20
21
|
/** Name of the icon in FontAwesome. */
|
|
@@ -932,9 +933,9 @@ export const Icon = {
|
|
|
932
933
|
},
|
|
933
934
|
|
|
934
935
|
/**
|
|
935
|
-
* Returns an empty div
|
|
936
|
-
* where an icon might otherwise go - e.g. to align a series of menu items,
|
|
937
|
-
* not have an icon but others do.
|
|
936
|
+
* Returns an empty div sized to occupy the width of a standard icon. Can be used to take up
|
|
937
|
+
* room in a layout where an icon might otherwise go - e.g. to align a series of menu items,
|
|
938
|
+
* where some items do not have an icon but others do.
|
|
938
939
|
*/
|
|
939
940
|
placeholder(opts?: IconProps): any {
|
|
940
941
|
const {size, asHtml = false} = opts ?? {},
|
package/icon/impl/IconHtml.ts
CHANGED
|
@@ -32,5 +32,5 @@ export function iconHtml({
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function enhanceFaClasses(className: string, size: string) {
|
|
35
|
-
return classNames(className, '
|
|
35
|
+
return classNames(className, 'xh-icon', isString(size) ? `fa-${size}` : null);
|
|
36
36
|
}
|
|
@@ -8,5 +8,6 @@ import {elementFactory} from '@xh/hoist/core';
|
|
|
8
8
|
import Dropzone from 'react-dropzone';
|
|
9
9
|
|
|
10
10
|
export {Dropzone};
|
|
11
|
+
export {ErrorCode} from 'react-dropzone';
|
|
11
12
|
export type {Accept, FileRejection, FileWithPath} from 'react-dropzone';
|
|
12
13
|
export const dropzone = elementFactory(Dropzone);
|
package/mcp/README.md
CHANGED
|
@@ -725,6 +725,18 @@ The `resolveDocPath()` utility in `util/paths.ts` validates that resolved paths
|
|
|
725
725
|
repository root. It rejects paths containing `..` segments. Always use this function when resolving
|
|
726
726
|
file paths from external input.
|
|
727
727
|
|
|
728
|
+
### Path Separators (Cross-Platform)
|
|
729
|
+
|
|
730
|
+
ts-morph's `SourceFile.getFilePath()` always returns **forward-slash** paths on every platform
|
|
731
|
+
(e.g. `D:/hoist-react/cmp/grid/GridModel.ts` on Windows), whereas `resolveRepoRoot()` returns a
|
|
732
|
+
native path from Node's `path` module -- **backslash-separated** on Windows (`D:\hoist-react`).
|
|
733
|
+
Comparing or slicing one against the other (e.g. `filePath.startsWith(repoRoot + '/')`) silently
|
|
734
|
+
fails on Windows, filtering out every source file and yielding an empty symbol index. When
|
|
735
|
+
comparing against or slicing a ts-morph path, use `resolveRepoRootPosix()` (and `toPosixPath()` for
|
|
736
|
+
any incoming file-path argument) from `util/paths.ts` rather than `resolveRepoRoot()`. Filesystem
|
|
737
|
+
access that stays within Node's `path`/`fs` APIs (e.g. the doc registry, the index cache) can keep
|
|
738
|
+
using `resolveRepoRoot()`, since those are separator-consistent on both sides.
|
|
739
|
+
|
|
728
740
|
### Registry Sync
|
|
729
741
|
|
|
730
742
|
The doc registry is hardcoded, not filesystem-scanned. When documentation files are added or
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test harness for the TypeScript symbol registry's cross-platform path handling.
|
|
3
|
+
* Run with:
|
|
4
|
+
* npx tsx mcp/data/ts-registry.spec.ts
|
|
5
|
+
*
|
|
6
|
+
* The hoist-react repo has no general test framework configured, so this script
|
|
7
|
+
* is a self-contained, exit-coded driver. It guards the path normalization the
|
|
8
|
+
* symbol index depends on: ts-morph's `getFilePath()` reports forward-slash paths
|
|
9
|
+
* on every platform, while Node's `path` reports backslashes on Windows. The two
|
|
10
|
+
* must be reconciled (to POSIX form) before comparison - a regression silently
|
|
11
|
+
* filters out every source file and empties the entire symbol index on Windows.
|
|
12
|
+
* See the "Path Separators (Cross-Platform)" pitfall in mcp/README.md.
|
|
13
|
+
*
|
|
14
|
+
* Two layers:
|
|
15
|
+
* - Synthetic path-helper cases (`toPosixPath`, `resolveRepoRootPosix`,
|
|
16
|
+
* `toRelativePath`, and the exact index-guard comparison) - these simulate a
|
|
17
|
+
* Windows path explicitly, so they are meaningful on any host OS, including
|
|
18
|
+
* Linux/macOS CI.
|
|
19
|
+
* - Live index integration - exercises `searchSymbols` / `getSymbolDetail` /
|
|
20
|
+
* `getMembers` against the real hoist-react sources to prove symbols resolve
|
|
21
|
+
* end-to-end (and that repo-relative `--file` disambiguation works).
|
|
22
|
+
*
|
|
23
|
+
* Run after every change to ts-registry.ts, index-cache.ts, or util/paths.ts.
|
|
24
|
+
*/
|
|
25
|
+
import {resolveRepoRoot, resolveRepoRootPosix, toPosixPath} from '../util/paths.js';
|
|
26
|
+
import {toRelativePath} from '../formatters/typescript.js';
|
|
27
|
+
import {searchSymbols, getSymbolDetail, getMembers} from './ts-registry.js';
|
|
28
|
+
|
|
29
|
+
// Suppress info logs during test run; we only want test output. (Warns still print.)
|
|
30
|
+
process.env.HOIST_MCP_QUIET = '1';
|
|
31
|
+
|
|
32
|
+
//---------------------------------------------------------------------
|
|
33
|
+
// Minimal assertion helpers (match the exit-coded style of the sibling
|
|
34
|
+
// doc-id-resolver.spec.ts).
|
|
35
|
+
//---------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
let passed = 0;
|
|
38
|
+
let failed = 0;
|
|
39
|
+
const failures: string[] = [];
|
|
40
|
+
|
|
41
|
+
function ok(label: string, cond: boolean, detail?: string): void {
|
|
42
|
+
if (cond) {
|
|
43
|
+
passed++;
|
|
44
|
+
console.log(` PASS ${label}`);
|
|
45
|
+
} else {
|
|
46
|
+
failed++;
|
|
47
|
+
failures.push(detail ? `${label} -- ${detail}` : label);
|
|
48
|
+
console.log(` FAIL ${label}`);
|
|
49
|
+
if (detail) console.log(` ${detail}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function eq(label: string, actual: unknown, expected: unknown): void {
|
|
54
|
+
ok(
|
|
55
|
+
label,
|
|
56
|
+
actual === expected,
|
|
57
|
+
`got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
//---------------------------------------------------------------------
|
|
62
|
+
// Synthetic path-helper cases (host-OS-independent).
|
|
63
|
+
//---------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
console.log('toPosixPath cases:');
|
|
66
|
+
eq(
|
|
67
|
+
'converts backslashes',
|
|
68
|
+
toPosixPath('D:\\hoist-react\\core\\XH.ts'),
|
|
69
|
+
'D:/hoist-react/core/XH.ts'
|
|
70
|
+
);
|
|
71
|
+
eq('leaves forward slashes untouched', toPosixPath('/repo/core/XH.ts'), '/repo/core/XH.ts');
|
|
72
|
+
eq('normalizes mixed separators', toPosixPath('a\\b/c\\d'), 'a/b/c/d');
|
|
73
|
+
eq('is idempotent', toPosixPath(toPosixPath('D:\\a\\b')), 'D:/a/b');
|
|
74
|
+
|
|
75
|
+
console.log('\nresolveRepoRootPosix cases:');
|
|
76
|
+
const rootPosix = resolveRepoRootPosix();
|
|
77
|
+
ok('has no backslashes', !rootPosix.includes('\\'), `got "${rootPosix}"`);
|
|
78
|
+
eq('equals toPosixPath(resolveRepoRoot())', rootPosix, toPosixPath(resolveRepoRoot()));
|
|
79
|
+
|
|
80
|
+
console.log('\nIndex-guard regression cases (simulated Windows paths):');
|
|
81
|
+
// The exact scenario that broke: on Windows, resolveRepoRoot() yields a
|
|
82
|
+
// backslash path while ts-morph yields forward slashes. Assert the naive guard
|
|
83
|
+
// fails and the POSIX-normalized guard succeeds - regardless of the host OS.
|
|
84
|
+
const winRootNative = 'D:\\hoist-react'; // what resolveRepoRoot() returns on Windows
|
|
85
|
+
const winFile = 'D:/hoist-react/cmp/grid/GridModel.ts'; // what ts-morph getFilePath() returns on Windows
|
|
86
|
+
ok(
|
|
87
|
+
'naive backslash-root comparison fails (the original bug)',
|
|
88
|
+
!winFile.startsWith(winRootNative + '/')
|
|
89
|
+
);
|
|
90
|
+
ok(
|
|
91
|
+
'posix-normalized root comparison succeeds (the fix)',
|
|
92
|
+
winFile.startsWith(toPosixPath(winRootNative) + '/')
|
|
93
|
+
);
|
|
94
|
+
eq(
|
|
95
|
+
'relative slice under posix root is correct',
|
|
96
|
+
winFile.slice(toPosixPath(winRootNative).length + 1),
|
|
97
|
+
'cmp/grid/GridModel.ts'
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
console.log('\ntoRelativePath cases:');
|
|
101
|
+
const sampleRel = 'cmp/grid/GridModel.ts';
|
|
102
|
+
eq('strips a posix absolute path', toRelativePath(`${rootPosix}/${sampleRel}`), sampleRel);
|
|
103
|
+
// A backslash absolute path (same root) must still strip AND normalize to posix.
|
|
104
|
+
const backslashAbs = `${rootPosix.replace(/\//g, '\\')}\\cmp\\grid\\GridModel.ts`;
|
|
105
|
+
eq('strips + normalizes a backslash absolute path', toRelativePath(backslashAbs), sampleRel);
|
|
106
|
+
|
|
107
|
+
//---------------------------------------------------------------------
|
|
108
|
+
// Live index integration (needs the real hoist-react sources; builds or
|
|
109
|
+
// loads the cached index on first query).
|
|
110
|
+
//---------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
console.log('\nLive index cases:');
|
|
113
|
+
|
|
114
|
+
const results = await searchSymbols('GridModel', {limit: 5});
|
|
115
|
+
const gm = results.find(r => r.name === 'GridModel' && r.kind === 'class');
|
|
116
|
+
ok('searchSymbols("GridModel") finds the class', !!gm, `got ${results.length} results`);
|
|
117
|
+
if (gm) {
|
|
118
|
+
eq('GridModel resolves to cmp/grid/GridModel.ts', toRelativePath(gm.filePath), sampleRel);
|
|
119
|
+
eq('GridModel sourcePackage is cmp/grid', gm.sourcePackage, 'cmp/grid');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const detail = await getSymbolDetail('GridModel');
|
|
123
|
+
ok(
|
|
124
|
+
'getSymbolDetail("GridModel") resolves',
|
|
125
|
+
!!detail && detail.kind === 'class',
|
|
126
|
+
`got ${JSON.stringify(detail && {name: detail.name, kind: detail.kind})}`
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const members = await getMembers('GridModel');
|
|
130
|
+
ok(
|
|
131
|
+
'getMembers("GridModel") returns members',
|
|
132
|
+
!!members && members.members.length > 0,
|
|
133
|
+
`count=${members?.members.length ?? 0}`
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// --file disambiguation (the secondary Windows bug): a repo-relative path passed
|
|
137
|
+
// to findIndexEntry must resolve to the matching entry. `View` exists in both
|
|
138
|
+
// cmp/viewmanager and data/cube; the path selects the data/cube one.
|
|
139
|
+
const view = await getSymbolDetail('View', 'data/cube/View.ts');
|
|
140
|
+
ok(
|
|
141
|
+
'getSymbolDetail("View", "data/cube/View.ts") disambiguates by repo-relative path',
|
|
142
|
+
!!view && toRelativePath(view.filePath) === 'data/cube/View.ts',
|
|
143
|
+
`relPath=${view ? toRelativePath(view.filePath) : 'null'}`
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
//---------------------------------------------------------------------
|
|
147
|
+
// Tally
|
|
148
|
+
//---------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
console.log(`\nTotal: ${passed} passed, ${failed} failed.`);
|
|
151
|
+
|
|
152
|
+
if (failed > 0) {
|
|
153
|
+
console.log('\nFailure details:');
|
|
154
|
+
for (const f of failures) console.log(` - ${f}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|