@xh/hoist 86.2.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.
Files changed (66) hide show
  1. package/.gitattributes +4 -0
  2. package/CHANGELOG.md +58 -0
  3. package/admin/AppModel.ts +3 -4
  4. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +56 -6
  5. package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +9 -26
  6. package/admin/tabs/clients/ClientsModel.ts +4 -1
  7. package/admin/tabs/clients/activity/ClientDetailModel.ts +5 -1
  8. package/admin/tabs/userData/roles/RolePanel.ts +2 -2
  9. package/appcontainer/RouterModel.ts +6 -6
  10. package/build/types/admin/AppModel.d.ts +3 -4
  11. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +14 -3
  12. package/build/types/admin/tabs/clients/ClientsModel.d.ts +3 -0
  13. package/build/types/appcontainer/RouterModel.d.ts +5 -5
  14. package/build/types/cmp/grid/Grid.d.ts +2 -1
  15. package/build/types/cmp/grid/GridModel.d.ts +2 -2
  16. package/build/types/cmp/grid/Types.d.ts +8 -1
  17. package/build/types/cmp/input/SegmentedControlOption.d.ts +12 -0
  18. package/build/types/core/HoistAppModel.d.ts +2 -3
  19. package/build/types/core/types/Types.d.ts +12 -0
  20. package/build/types/desktop/cmp/filter/FilterChooser.d.ts +5 -0
  21. package/build/types/desktop/cmp/filter/PopoverFilterChooser.d.ts +3 -1
  22. package/build/types/desktop/cmp/grid/impl/colchooser/ColChooserModel.d.ts +2 -2
  23. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.d.ts +9 -1
  24. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.d.ts +8 -2
  25. package/build/types/desktop/cmp/input/Select.d.ts +8 -0
  26. package/build/types/kit/react-dropzone/index.d.ts +1 -0
  27. package/build/types/kit/swiper/index.d.ts +4 -3
  28. package/build/types/mobile/cmp/grid/impl/ColChooserModel.d.ts +2 -2
  29. package/build/types/mobile/cmp/input/Select.d.ts +8 -0
  30. package/cmp/grid/Grid.ts +10 -2
  31. package/cmp/grid/GridModel.ts +4 -3
  32. package/cmp/grid/Types.ts +9 -1
  33. package/cmp/grid/impl/MenuSupport.ts +1 -1
  34. package/cmp/input/SegmentedControlOption.ts +14 -0
  35. package/cmp/store/impl/StoreFilterFieldImplModel.ts +5 -6
  36. package/core/HoistAppModel.ts +2 -3
  37. package/core/types/Types.ts +14 -5
  38. package/desktop/cmp/filechooser/FileChooser.ts +6 -3
  39. package/desktop/cmp/filechooser/FileChooserModel.ts +12 -5
  40. package/desktop/cmp/filter/FilterChooser.scss +41 -0
  41. package/desktop/cmp/filter/FilterChooser.ts +147 -7
  42. package/desktop/cmp/filter/PopoverFilterChooser.ts +7 -84
  43. package/desktop/cmp/grid/editors/impl/InlineEditorModel.ts +6 -1
  44. package/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts +3 -5
  45. package/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts +2 -2
  46. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRow.ts +12 -12
  47. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +18 -0
  48. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.scss +5 -0
  49. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.ts +13 -2
  50. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.ts +97 -14
  51. package/desktop/cmp/input/SegmentedControl.ts +7 -3
  52. package/desktop/cmp/input/Select.ts +24 -16
  53. package/docs/routing.md +4 -2
  54. package/docs/upgrade-notes/v86-upgrade-notes.md +73 -8
  55. package/kit/react-dropzone/index.ts +1 -0
  56. package/mcp/README.md +12 -0
  57. package/mcp/data/ts-registry.spec.ts +156 -0
  58. package/mcp/data/ts-registry.ts +13 -6
  59. package/mcp/formatters/typescript.ts +7 -3
  60. package/mcp/util/paths.ts +21 -0
  61. package/mobile/cmp/grid/impl/ColChooserModel.ts +2 -2
  62. package/mobile/cmp/input/SegmentedControl.ts +7 -3
  63. package/mobile/cmp/input/Select.ts +21 -15
  64. package/mobx/README.md +2 -1
  65. package/package.json +5 -5
  66. package/desktop/cmp/filter/PopoverFilterChooser.scss +0 -42
@@ -1,8 +1,16 @@
1
+ import type { GridFilterFieldSpec } from '@xh/hoist/cmp/grid';
1
2
  import { HoistModel } from '@xh/hoist/core';
2
3
  import { FieldFilterOperator, FieldFilterSpec } from '@xh/hoist/data';
3
4
  import { HeaderFilterModel } from '../HeaderFilterModel';
4
5
  import { CustomTabModel } from './CustomTabModel';
5
6
  type OperatorOptionValue = 'blank' | 'not blank' | FieldFilterOperator;
7
+ /**
8
+ * Whether a row for the given op renders the multi-value `select` input - the only custom-tab input
9
+ * that holds an array of values directly. The single-value inputs (number, date, text) are used
10
+ * otherwise, so array filter values bound for them must be expanded into one row each (see
11
+ * `CustomTabModel.doSyncWithFilter`). Mirrors the input choice made in `CustomRow`.
12
+ */
13
+ export declare function usesMultiValueInput(fieldSpec: GridFilterFieldSpec, op: FieldFilterOperator): boolean;
6
14
  /**
7
15
  * @internal
8
16
  */
@@ -14,7 +22,7 @@ export declare class CustomRowModel extends HoistModel {
14
22
  inputVal: any;
15
23
  /** FieldFilter config output of this row. */
16
24
  get value(): FieldFilterSpec;
17
- get fieldSpec(): import("../../../../../../../cmp/grid").GridFilterFieldSpec;
25
+ get fieldSpec(): GridFilterFieldSpec;
18
26
  get options(): {
19
27
  label: string;
20
28
  value: OperatorOptionValue;
@@ -1,5 +1,5 @@
1
1
  import { HoistModel } from '@xh/hoist/core';
2
- import { CompoundFilterOperator, FilterLike } from '@xh/hoist/data';
2
+ import { CompoundFilterOperator, FieldFilter, FilterLike } from '@xh/hoist/data';
3
3
  import { HeaderFilterModel } from '../HeaderFilterModel';
4
4
  import { CustomRowModel } from './CustomRowModel';
5
5
  export declare class CustomTabModel extends HoistModel {
@@ -11,11 +11,17 @@ export declare class CustomTabModel extends HoistModel {
11
11
  get filter(): FilterLike;
12
12
  get fieldSpec(): import("../../../../../../../cmp/grid").GridFilterFieldSpec;
13
13
  get currentGridFilter(): import("@xh/hoist/data").Filter;
14
- get columnFilters(): import("@xh/hoist/data").FieldFilter[];
14
+ get columnFilters(): FieldFilter[];
15
+ get columnCompoundFilter(): import("@xh/hoist/data").CompoundFilter;
15
16
  constructor(headerFilterModel: HeaderFilterModel);
16
17
  syncWithFilter(): void;
17
18
  reset(): void;
18
19
  addEmptyRow(): void;
19
20
  removeRow(model: any): void;
20
21
  private doSyncWithFilter;
22
+ private needsExpansion;
23
+ private mergeOpFor;
24
+ private deriveOp;
25
+ private get isRepresentable();
26
+ private collapseToArrayFilters;
21
27
  }
@@ -127,6 +127,14 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps {
127
127
  selectOnFocus?: boolean;
128
128
  /** Field on provided options for sourcing each option's value (default `value`). */
129
129
  valueField?: string;
130
+ /**
131
+ * Function to generate a `SelectOption` for a (non-null) selected value not present in the
132
+ * current options list. Return null to fall back to the default value-as-label behavior.
133
+ *
134
+ * Useful with queryFn-based selects, readonly forms, or any case where options may not be
135
+ * loaded when a value is set, ensuring the value renders with its proper label.
136
+ */
137
+ generateOptionFn?: (value: any) => SelectOption;
130
138
  }
131
139
  /**
132
140
  * A managed wrapper around the React-Select combobox/dropdown component.
@@ -1,4 +1,5 @@
1
1
  import Dropzone from 'react-dropzone';
2
2
  export { Dropzone };
3
+ export { ErrorCode } from 'react-dropzone';
3
4
  export type { Accept, FileRejection, FileWithPath } from 'react-dropzone';
4
5
  export declare const dropzone: import("@xh/hoist/core").ElementFactory<import("react-dropzone").DropzoneProps & import("react").RefAttributes<import("react-dropzone").DropzoneRef>>;
@@ -27,6 +27,7 @@ export declare const swiper: import("@xh/hoist/core").ElementFactory<import("rea
27
27
  onScrollbarDragStart?: (swiper: import("swiper/types").Swiper, event: MouseEvent | TouchEvent | PointerEvent) => void;
28
28
  onScrollbarDragMove?: (swiper: import("swiper/types").Swiper, event: MouseEvent | TouchEvent | PointerEvent) => void;
29
29
  onScrollbarDragEnd?: (swiper: import("swiper/types").Swiper, event: MouseEvent | TouchEvent | PointerEvent) => void;
30
+ onVirtualUpdate?: (swiper: import("swiper/types").Swiper) => void;
30
31
  onZoomChange?: (swiper: import("swiper/types").Swiper, scale: number, imageEl: HTMLElement, slideEl: HTMLElement) => void;
31
32
  onInit?: (swiper: import("swiper/types").Swiper) => any;
32
33
  onBeforeDestroy?: (swiper: import("swiper/types").Swiper) => void;
@@ -53,8 +54,8 @@ export declare const swiper: import("@xh/hoist/core").ElementFactory<import("rea
53
54
  onReachEnd?: (swiper: import("swiper/types").Swiper) => void;
54
55
  onToEdge?: (swiper: import("swiper/types").Swiper) => void;
55
56
  onFromEdge?: (swiper: import("swiper/types").Swiper) => void;
56
- onSetTranslate?: (swiper: import("swiper/types").Swiper, translate: number) => void;
57
- onSetTransition?: (swiper: import("swiper/types").Swiper, transition: number) => void;
57
+ onSetTranslate?: (swiper: import("swiper/types").Swiper, translate: number, byController?: boolean | Swiper) => void;
58
+ onSetTransition?: (swiper: import("swiper/types").Swiper, transition: number, byController?: boolean | Swiper) => void;
58
59
  onResize?: (swiper: import("swiper/types").Swiper) => void;
59
60
  onObserverUpdate?: (swiper: import("swiper/types").Swiper) => void;
60
61
  onBeforeLoopFix?: (swiper: import("swiper/types").Swiper) => void;
@@ -77,7 +78,7 @@ export declare const swiper: import("@xh/hoist/core").ElementFactory<import("rea
77
78
  onBeforeInit?: (swiper: import("swiper/types").Swiper) => void;
78
79
  onBeforeResize?: (swiper: import("swiper/types").Swiper) => void;
79
80
  onBeforeSlideChangeStart?: (swiper: import("swiper/types").Swiper) => void;
80
- onBeforeTransitionStart?: (swiper: import("swiper/types").Swiper, speed: number, internal: any) => void;
81
+ onBeforeTransitionStart?: (swiper: import("swiper/types").Swiper, speed: number, internal: unknown) => void;
81
82
  onChangeDirection?: (swiper: import("swiper/types").Swiper) => void;
82
83
  onDoubleClick?: (swiper: import("swiper/types").Swiper, event: MouseEvent | TouchEvent | PointerEvent) => void;
83
84
  onDestroy?: (swiper: import("swiper/types").Swiper) => void;
@@ -1,4 +1,4 @@
1
- import { GridModel } from '@xh/hoist/cmp/grid';
1
+ import { GridModel, IColChooserModel } from '@xh/hoist/cmp/grid';
2
2
  import { HoistModel, HSide } from '@xh/hoist/core';
3
3
  import '@xh/hoist/mobile/register';
4
4
  /**
@@ -7,7 +7,7 @@ import '@xh/hoist/mobile/register';
7
7
  * It is not necessary to manually create instances of this class within an application.
8
8
  * @internal
9
9
  */
10
- export declare class ColChooserModel extends HoistModel {
10
+ export declare class ColChooserModel extends HoistModel implements IColChooserModel {
11
11
  xhImpl: boolean;
12
12
  gridModel: GridModel;
13
13
  showRestoreDefaults: boolean;
@@ -109,6 +109,14 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps {
109
109
  title?: string;
110
110
  /** Field on provided options for sourcing each option's value (default `value`). */
111
111
  valueField?: string;
112
+ /**
113
+ * Function to generate a `SelectOption` for a (non-null) selected value not present in the
114
+ * current options list. Return null to fall back to the default value-as-label behavior.
115
+ *
116
+ * Useful with queryFn-based selects, readonly forms, or any case where options may not be
117
+ * loaded when a value is set, ensuring the value renders with its proper label.
118
+ */
119
+ generateOptionFn?: (value: any) => SelectOption;
112
120
  }
113
121
  /**
114
122
  * A managed wrapper around the React-Select combobox/dropdown component.
package/cmp/grid/Grid.ts CHANGED
@@ -49,6 +49,7 @@ import {composeRefs, createObservableRef, getLayoutProps} from '@xh/hoist/utils/
49
49
  import classNames from 'classnames';
50
50
  import {compact, debounce, isBoolean, isEmpty, isEqual, isNil, max, maxBy, merge} from 'lodash';
51
51
  import {type MouseEvent} from 'react';
52
+ import {PartialDeep} from 'type-fest';
52
53
  import './Grid.scss';
53
54
  import {GridModel} from './GridModel';
54
55
  import {columnGroupHeader} from './impl/ColumnGroupHeader';
@@ -66,7 +67,7 @@ export interface GridProps<M extends GridModel = GridModel>
66
67
  *
67
68
  * Note that changes to these options after the component's initial render will be ignored.
68
69
  */
69
- agOptions?: GridOptions;
70
+ agOptions?: PartialDeep<GridOptions>;
70
71
 
71
72
  /**
72
73
  * Callback when the grid has initialized. The component will call this with the ag-Grid
@@ -392,7 +393,14 @@ export class GridLocalModel extends HoistModel {
392
393
  return {
393
394
  track: () => [model.agApi, model.groupBy],
394
395
  run: ([agApi, groupBy]) => {
395
- if (agApi) agApi.setRowGroupColumns(groupBy);
396
+ if (!agApi) return;
397
+ agApi.setRowGroupColumns(groupBy);
398
+
399
+ // Re-assert configured visibility - AG Grid re-shows a column when ungrouped (#4473).
400
+ const state = model.columnState
401
+ .filter(({colId}) => !groupBy.includes(colId))
402
+ .map(({colId, hidden}) => ({colId, hide: hidden}));
403
+ agApi.applyColumnState({state});
396
404
  }
397
405
  };
398
406
  }
@@ -16,6 +16,7 @@ import {
16
16
  GridAutosizeMode,
17
17
  GridFilterModelConfig,
18
18
  GridGroupSortFn,
19
+ IColChooserModel,
19
20
  isColumnSpec,
20
21
  TreeStyle
21
22
  } from '@xh/hoist/cmp/grid';
@@ -498,7 +499,7 @@ export class GridModel extends HoistModel {
498
499
  store: Store;
499
500
  selModel: StoreSelectionModel;
500
501
  treeMode: boolean;
501
- colChooserModel: HoistModel;
502
+ colChooserModel: IColChooserModel;
502
503
  rowClassFn: RowClassFn;
503
504
  rowClassRules: Record<string, RowClassRuleFn>;
504
505
  contextMenu: GridContextMenuSpec;
@@ -1259,7 +1260,7 @@ export class GridModel extends HoistModel {
1259
1260
  }
1260
1261
 
1261
1262
  showColChooser() {
1262
- (this.colChooserModel as any)?.open();
1263
+ this.colChooserModel?.open();
1263
1264
  }
1264
1265
 
1265
1266
  noteAgColumnStateChanged(agColState: AgColumnState[]) {
@@ -1986,7 +1987,7 @@ export class GridModel extends HoistModel {
1986
1987
  };
1987
1988
  }
1988
1989
 
1989
- private parseChooserModel(chooserModel: GridConfig['colChooserModel']): HoistModel {
1990
+ private parseChooserModel(chooserModel: GridConfig['colChooserModel']): IColChooserModel {
1990
1991
  if (!chooserModel) return null;
1991
1992
 
1992
1993
  const modelClass = XH.isMobileApp ? MobileColChooserModel : DesktopColChooserModel;
package/cmp/grid/Types.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
- import type {HSide, PersistOptions, Some} from '@xh/hoist/core';
8
+ import type {HoistModel, HSide, PersistOptions, Some} from '@xh/hoist/core';
9
9
  import type {
10
10
  FilterBindTarget,
11
11
  FilterMatchMode,
@@ -142,6 +142,14 @@ export interface GridFilterBindTarget extends FilterBindTarget, FilterValueSourc
142
142
  */
143
143
  export type GroupRowRenderer = (context: ICellRendererParams) => ReactNode;
144
144
 
145
+ /** Cross-platform interface for desktop and mobile ColChooserModels. */
146
+ export interface IColChooserModel extends HoistModel {
147
+ readonly gridModel: GridModel;
148
+ readonly isOpen: boolean;
149
+ open(): void;
150
+ close(): void;
151
+ }
152
+
145
153
  /**
146
154
  * Configuration for a {@link ColChooserModel} - the model backing the grid column chooser UI.
147
155
  * Passed via the `colChooserModel` config on {@link GridConfig}, or set app-wide via
@@ -138,7 +138,7 @@ function replaceHoistToken(token: string, gridModel: GridModel): Some<RecordActi
138
138
  text: 'Columns...',
139
139
  icon: Icon.gridPanel(),
140
140
  hidden: !gridModel?.colChooserModel,
141
- actionFn: () => (gridModel.colChooserModel as any)?.open()
141
+ actionFn: () => gridModel.colChooserModel?.open()
142
142
  });
143
143
  case 'expandCollapseAll': // For backward compatibility
144
144
  case 'expandCollapse':
@@ -33,6 +33,13 @@ export interface SegmentedControlOption {
33
33
  * `intent` default. Defaults to the control's `intent`.
34
34
  */
35
35
  intent?: Intent;
36
+
37
+ /**
38
+ * Optional stable identifier emitted on this option's rendered button as `data-testid`, for
39
+ * use by E2E tests. If omitted and the control itself has a `testId`, one is auto-derived as
40
+ * `${controlTestId}-${value}`. If neither is set, no attribute is emitted.
41
+ */
42
+ testId?: string;
36
43
  }
37
44
 
38
45
  /**
@@ -58,4 +65,11 @@ export interface SegmentedControlNullOption {
58
65
  * `intent` default. Defaults to the control's `intent`.
59
66
  */
60
67
  intent?: Intent;
68
+
69
+ /**
70
+ * Optional stable identifier emitted on this option's rendered button as `data-testid`, for
71
+ * use by E2E tests. If omitted and the control itself has a `testId`, one is auto-derived as
72
+ * `${controlTestId}-null`. If neither is set, no attribute is emitted.
73
+ */
74
+ testId?: string;
61
75
  }
@@ -66,7 +66,7 @@ export class StoreFilterFieldImplModel extends HoistModel {
66
66
 
67
67
  this.addReaction(
68
68
  {
69
- track: () => [this.filterText, gridModel?.columns, gridModel?.groupBy],
69
+ track: () => [this.filterText, gridModel?.columns],
70
70
  run: () => this.regenerateFilter(),
71
71
  fireImmediately: true
72
72
  },
@@ -161,8 +161,7 @@ export class StoreFilterFieldImplModel extends HoistModel {
161
161
  if (excludeFields) ret = without(ret, ...excludeFields);
162
162
 
163
163
  if (gridModel) {
164
- const groupBy = gridModel.groupBy,
165
- visibleCols = gridModel.getVisibleLeafColumns();
164
+ const visibleCols = gridModel.getVisibleLeafColumns();
166
165
 
167
166
  // Push on dot-delimited grid column fields. These are supported by Grid and traverse
168
167
  // sub-objects in StoreRecord.data to display nested properties. Given that Grid treats these
@@ -182,12 +181,12 @@ export class StoreFilterFieldImplModel extends HoistModel {
182
181
  // Run exclude once more to support explicitly excluding a dot-sep field added above.
183
182
  if (excludeFields) ret = without(ret, ...excludeFields);
184
183
 
185
- // Final filter for column visibility, or explicit request for inclusion.
184
+ // Final filter for column visibility, or explicit request for inclusion. Deliberately
185
+ // not keyed to groupBy, so filter results stay stable across regrouping (see #4070).
186
186
  ret = ret.filter(f => {
187
187
  return (
188
188
  (includeFields && includeFields.includes(f)) ||
189
- visibleCols.find(c => c.field === f) ||
190
- groupBy.includes(f)
189
+ visibleCols.find(c => c.field === f)
191
190
  );
192
191
  });
193
192
  }
@@ -5,8 +5,7 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {webSocketIndicator} from '@xh/hoist/cmp/websocket';
8
- import {AppOptionSpec, HoistModel, InitContext, Thunkable} from './';
9
- import {Route} from 'router5';
8
+ import {AppOptionSpec, HoistModel, HoistRoute, InitContext, Thunkable} from './';
10
9
  import {ReactNode} from 'react';
11
10
  /**
12
11
  * Specialized base class for defining the central model for a Hoist app as specified by its
@@ -52,7 +51,7 @@ export class HoistAppModel extends HoistModel {
52
51
  /**
53
52
  * Provide the initial set of Router5 Routes to be used by this application.
54
53
  */
55
- getRoutes(): Route[] {
54
+ getRoutes(): HoistRoute[] {
56
55
  return [];
57
56
  }
58
57
 
@@ -9,6 +9,7 @@ import {LocalDate} from '@xh/hoist/utils/datetime';
9
9
  import {MomentInput} from 'moment';
10
10
  import {Component, FunctionComponent, ReactElement} from 'react';
11
11
  import {DebounceSettings} from 'lodash';
12
+ import {Route} from 'router5';
12
13
 
13
14
  /** Values available for intents. */
14
15
  export type Intent = 'primary' | 'success' | 'warning' | 'danger';
@@ -55,14 +56,22 @@ export type DebounceSpec = number | (DebounceSettings & {interval: number});
55
56
  * function that returns a ReactElement. In either case, the function will be called with no arguments.
56
57
  */
57
58
  export type Content =
58
- | ReactElement
59
- | FunctionComponent
60
- | Component
61
- | ElementFactory
62
- | (() => ReactElement);
59
+ ReactElement | FunctionComponent | Component | ElementFactory | (() => ReactElement);
63
60
 
64
61
  export type DateLike = Date | LocalDate | MomentInput;
65
62
 
63
+ /**
64
+ * A Router5 {@link Route} spec, extended with Hoist's `omit` support for declarative exclusion of
65
+ * routes at registration time (e.g. role-gated sections). Note `omit` is evaluated once when routes
66
+ * are added to the router during app startup - it is not reactive.
67
+ *
68
+ * @see HoistAppModel.getRoutes
69
+ */
70
+ export interface HoistRoute extends Omit<Route, 'children'> {
71
+ omit?: Thunkable<boolean>;
72
+ children?: HoistRoute[];
73
+ }
74
+
66
75
  /** Valid units for the {@link LocalDate} adjustment methods. */
67
76
  export type LocalDateUnit =
68
77
  | 'year'
@@ -12,7 +12,7 @@ import {dropzone} from '@xh/hoist/kit/react-dropzone';
12
12
  import {elementFromContent, getLayoutProps} from '@xh/hoist/utils/react';
13
13
  import {FileRejection} from 'react-dropzone';
14
14
  import {FileChooserModel} from './FileChooserModel';
15
- import {fromPairs, isEmpty} from 'lodash';
15
+ import {isEmpty} from 'lodash';
16
16
  import classNames from 'classnames';
17
17
  import {defaultEmptyDisplay} from './impl/DefaultEmptyDisplay';
18
18
  import {defaultFileDisplay} from './impl/DefaultFileDisplay';
@@ -88,8 +88,11 @@ export const [FileChooser, fileChooser] = hoistCmp.withFactory<FileChooserProps>
88
88
 
89
89
  return dropzone({
90
90
  ref: model.dropzoneRef,
91
- // react-dropzone wants a {type: [extensions]} map; extensions serve as their own keys.
92
- accept: accept ? fromPairs(accept.map(ext => [ext, [ext]])) : null,
91
+ // react-dropzone expects `accept` to be a {type: [extensions]} map. The FileChooserModel
92
+ // only accepts a list of extensions. Extensions alone are sufficient for validation and
93
+ // a MIME type is not required. Use `_/_` as a dummy MIME type to prevent console warnings
94
+ // from react-dropzone.
95
+ accept: accept ? {'_/_': accept} : null,
93
96
  // Disable interaction (drag/click/drop) at the limit; the target shows a clear message.
94
97
  disabled: disabled || atLimit,
95
98
  maxFiles,
@@ -7,7 +7,7 @@
7
7
  import {em, li, span, ul, vbox} from '@xh/hoist/cmp/layout';
8
8
  import {HoistModel, Some, ToastSpec, XH} from '@xh/hoist/core';
9
9
  import '@xh/hoist/desktop/register';
10
- import {FileRejection} from '@xh/hoist/kit/react-dropzone';
10
+ import {ErrorCode, FileRejection} from '@xh/hoist/kit/react-dropzone';
11
11
  import {action, makeObservable, observable} from '@xh/hoist/mobx';
12
12
  import {pluralize, withDefault} from '@xh/hoist/utils/js';
13
13
  import {createObservableRef} from '@xh/hoist/utils/react';
@@ -187,10 +187,17 @@ export class FileChooserModel extends HoistModel {
187
187
  return true;
188
188
  }
189
189
 
190
- private defaultRejectMessage(rejections: FileRejection[]): ReactElement {
191
- // 1) Map rejected files to error messages
190
+ private defaultRejectMessage = (rejections: FileRejection[]): ReactElement => {
191
+ // 1) Map rejected files to error messages. Use custom message for invalid types to avoid displaying dummy MIME type.
192
192
  const errorsByFile = fromPairs(
193
- map(rejections, ({file, errors}) => [file.name, map(errors, 'message')])
193
+ map(rejections, ({file, errors}) => [
194
+ file.name,
195
+ map(errors, e =>
196
+ e.code === ErrorCode.FileInvalidType
197
+ ? `File type must be one of ${this.accept.join(', ')}`
198
+ : e.message
199
+ )
200
+ ])
194
201
  );
195
202
 
196
203
  // 2) List files with bulleted error messages
@@ -207,7 +214,7 @@ export class FileChooserModel extends HoistModel {
207
214
  });
208
215
 
209
216
  return vbox(rejectItems);
210
- }
217
+ };
211
218
 
212
219
  private getRejectToastSpec(params: Partial<ToastSpec> | boolean): Partial<ToastSpec> {
213
220
  if (params == false) return null;
@@ -15,6 +15,47 @@
15
15
  margin: 7px 4px;
16
16
  align-self: start;
17
17
  }
18
+
19
+ // Popover mode - collapsed trigger expands into an overlaid popover.
20
+ &--popover > .bp6-popover-target {
21
+ display: flex;
22
+ flex: 1;
23
+ }
24
+
25
+ &__trigger {
26
+ .xh-select__value-container--is-multi {
27
+ overflow-y: hidden !important;
28
+ }
29
+ }
30
+
31
+ // Extra class names required to override the default styles of the popover.
32
+ &__popover.bp6-popover.bp6-minimal {
33
+ box-shadow: none;
34
+
35
+ // Overlay the expanded input onto the collapsed 30px trigger. Offset follows Blueprint's
36
+ // resolved placement, which flips (bottom-anchored) near the viewport's bottom edge.
37
+ &.bp6-popover-placement-bottom {
38
+ margin-top: -30px !important;
39
+ }
40
+ &.bp6-popover-placement-top {
41
+ margin-bottom: -30px !important;
42
+ }
43
+
44
+ // Content is portaled out of .xh-filter-chooser - restate the flex rule so it fills the width.
45
+ .bp6-popover-target {
46
+ display: flex;
47
+ flex: 1;
48
+ }
49
+
50
+ .bp6-popover-content {
51
+ background: transparent;
52
+ }
53
+
54
+ .xh-select__value-container--is-multi {
55
+ height: unset;
56
+ line-height: unset;
57
+ }
58
+ }
18
59
  }
19
60
 
20
61
  .xh-filter-chooser-option {