diff --git a/CHANGELOG.md b/CHANGELOG.md index cc9265c7db..e494a08983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 67.0.0-SNAPSHOT - unreleased +### 💥 Breaking Changes (upgrade difficulty: 🟢 TRIVIAL - renamed component prop) + +* `RefreshButton` component `model` prop renamed to `target`. + ### 🎁 New Features * Added support for Correlation IDs across fetch requests and error / activity tracking: @@ -23,10 +27,14 @@ ### 🐞 Bug Fixes * Fixed `SelectEditor` to ensure new value is flushed before editing stops. +* Fix bug where `model` passed to `RelativeTimestamp` was being ignored. ### ⚙️ Technical * Remove context menus from column choosers. +* Typescript: Overall type improvements and cleanup. Note: `AppConfigs` with `model: false` will + need to specify a `null` model type in the generic argument to `hoistCmp`, `hoistCmp.factory` or + `hoistCmp.withFactory` to avoid a type error. ### 📚 Libraries diff --git a/admin/tabs/cluster/logs/LogViewerModel.ts b/admin/tabs/cluster/logs/LogViewerModel.ts index c833edb1db..a7c56a69be 100644 --- a/admin/tabs/cluster/logs/LogViewerModel.ts +++ b/admin/tabs/cluster/logs/LogViewerModel.ts @@ -23,7 +23,7 @@ import {LogDisplayModel} from './LogDisplayModel'; export class LogViewerModel extends BaseInstanceModel { @observable file: string = null; - viewRef = createRef(); + viewRef = createRef(); @managed logDisplayModel = new LogDisplayModel(this); diff --git a/admin/tabs/cluster/websocket/WebSocketModel.ts b/admin/tabs/cluster/websocket/WebSocketModel.ts index b8786cb889..992182d71f 100644 --- a/admin/tabs/cluster/websocket/WebSocketModel.ts +++ b/admin/tabs/cluster/websocket/WebSocketModel.ts @@ -23,7 +23,7 @@ import {RecordActionSpec} from '@xh/hoist/data'; import {AppModel} from '@xh/hoist/admin/AppModel'; export class WebSocketModel extends BaseInstanceModel { - viewRef = createRef(); + viewRef = createRef(); @observable lastRefresh: number; diff --git a/cmp/ag-grid/AgGrid.ts b/cmp/ag-grid/AgGrid.ts index e81b0b2dc2..6af8ffe85d 100644 --- a/cmp/ag-grid/AgGrid.ts +++ b/cmp/ag-grid/AgGrid.ts @@ -26,7 +26,7 @@ import './AgGrid.scss'; import {AgGridModel} from './AgGridModel'; export interface AgGridProps - extends HoistProps, + extends HoistProps, GridOptions, LayoutProps, TestSupportProps {} diff --git a/cmp/badge/Badge.ts b/cmp/badge/Badge.ts index 1a9dc09671..1d5019eb71 100644 --- a/cmp/badge/Badge.ts +++ b/cmp/badge/Badge.ts @@ -5,13 +5,13 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {div} from '@xh/hoist/cmp/layout'; -import {BoxProps, hoistCmp, HoistProps, Intent} from '@xh/hoist/core'; +import {BoxProps, hoistCmp, HoistPropsWithRef, Intent} from '@xh/hoist/core'; import {TEST_ID, mergeDeep} from '@xh/hoist/utils/js'; import {splitLayoutProps} from '@xh/hoist/utils/react'; import classNames from 'classnames'; import './Badge.scss'; -export interface BadgeProps extends HoistProps, BoxProps { +export interface BadgeProps extends HoistPropsWithRef, BoxProps { /** Sets fontsize to half that of parent element (default false). */ compact?: boolean; diff --git a/cmp/chart/Chart.ts b/cmp/chart/Chart.ts index 26283f09b7..2aa0fd75cf 100644 --- a/cmp/chart/Chart.ts +++ b/cmp/chart/Chart.ts @@ -41,7 +41,10 @@ import {LightTheme} from './theme/Light'; installZoomoutGesture(Highcharts); installCopyToClipboard(Highcharts); -export interface ChartProps extends HoistProps, LayoutProps, TestSupportProps { +export interface ChartProps + extends HoistProps, + LayoutProps, + TestSupportProps { /** * Ratio of width-to-height of displayed chart. If defined and greater than 0, the chart will * respect this ratio within the available space. Otherwise, the chart will stretch on both diff --git a/cmp/clock/Clock.ts b/cmp/clock/Clock.ts index 0ac03f692f..151987882e 100644 --- a/cmp/clock/Clock.ts +++ b/cmp/clock/Clock.ts @@ -9,7 +9,7 @@ import { BoxProps, hoistCmp, HoistModel, - HoistProps, + HoistPropsWithRef, managed, useLocalModel, XH @@ -21,7 +21,7 @@ import {MINUTES, ONE_SECOND} from '@xh/hoist/utils/datetime'; import {isNumber} from 'lodash'; import {getLayoutProps} from '../../utils/react'; -export interface ClockProps extends HoistProps, BoxProps { +export interface ClockProps extends HoistPropsWithRef, BoxProps { /** String to display if the timezone is invalid or an offset cannot be fetched. */ errorString?: string; diff --git a/cmp/dataview/DataView.ts b/cmp/dataview/DataView.ts index 096bcaaf79..b0cb2478c2 100644 --- a/cmp/dataview/DataView.ts +++ b/cmp/dataview/DataView.ts @@ -24,7 +24,10 @@ import './DataView.scss'; import {DataViewModel} from './DataViewModel'; import {mergeDeep} from '@xh/hoist/utils/js'; -export interface DataViewProps extends HoistProps, LayoutProps, TestSupportProps { +export interface DataViewProps + extends HoistProps, + LayoutProps, + TestSupportProps { /** * Options for ag-Grid's API. * diff --git a/cmp/filter/FilterChooserModel.ts b/cmp/filter/FilterChooserModel.ts index 4c1f811116..49b38e3d3a 100644 --- a/cmp/filter/FilterChooserModel.ts +++ b/cmp/filter/FilterChooserModel.ts @@ -4,6 +4,7 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ +import {HoistInputModel} from '@xh/hoist/cmp/input'; import { HoistModel, managed, @@ -145,7 +146,7 @@ export class FilterChooserModel extends HoistModel { @observable.ref selectValue: string[]; @observable favoritesIsOpen = false; @observable unsupportedFilter = false; - inputRef = createObservableRef(); + inputRef = createObservableRef>(); constructor({ fieldSpecs, diff --git a/cmp/form/BaseFormFieldProps.ts b/cmp/form/BaseFormFieldProps.ts index 9cf9cdd55f..44b41cf1de 100644 --- a/cmp/form/BaseFormFieldProps.ts +++ b/cmp/form/BaseFormFieldProps.ts @@ -9,7 +9,7 @@ import {BoxProps, HoistProps} from '@xh/hoist/core'; import {ReactNode} from 'react'; import {FieldModel} from './field/FieldModel'; -export interface BaseFormFieldProps extends HoistProps, BoxProps { +export interface BaseFormFieldProps extends HoistProps, BoxProps { /** * CommitOnChange property for underlying HoistInput (for inputs that support). * Defaulted from containing Form. diff --git a/cmp/form/Form.ts b/cmp/form/Form.ts index e3455e3862..f1b7c94c17 100644 --- a/cmp/form/Form.ts +++ b/cmp/form/Form.ts @@ -5,10 +5,10 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import { - DefaultHoistProps, elementFactory, hoistCmp, HoistProps, + PlainObject, TestSupportProps, uses } from '@xh/hoist/core'; @@ -21,7 +21,7 @@ import {FormModel} from './FormModel'; /** @internal */ export interface FormContextType { /** Defaults props to be applied to contained fields. */ - fieldDefaults?: Partial & DefaultHoistProps; + fieldDefaults?: Partial & PlainObject; /** Reference to associated FormModel. */ model?: FormModel; @@ -44,7 +44,7 @@ export interface FormProps extends HoistProps, TestSupportProps { * Defaults for certain props on child/nested FormFields. * @see FormField (note there are both desktop and mobile implementations). */ - fieldDefaults?: Partial & DefaultHoistProps; + fieldDefaults?: Partial & PlainObject; } /** diff --git a/cmp/grid/Grid.ts b/cmp/grid/Grid.ts index 76b69477ab..5a1d88b033 100644 --- a/cmp/grid/Grid.ts +++ b/cmp/grid/Grid.ts @@ -51,7 +51,10 @@ import {columnGroupHeader} from './impl/ColumnGroupHeader'; import {columnHeader} from './impl/ColumnHeader'; import {RowKeyNavSupport} from './impl/RowKeyNavSupport'; -export interface GridProps extends HoistProps, LayoutProps, TestSupportProps { +export interface GridProps + extends HoistProps, + LayoutProps, + TestSupportProps { /** * Options for ag-Grid's API. * @@ -145,7 +148,7 @@ export class GridLocalModel extends HoistModel { @lookup(GridModel) private model: GridModel; agOptions: GridOptions; - viewRef = createObservableRef(); + viewRef = createObservableRef(); private rowKeyNavSupport: RowKeyNavSupport; private prevRs: RecordSet; diff --git a/cmp/grid/filter/GridFilterFieldSpec.ts b/cmp/grid/filter/GridFilterFieldSpec.ts index b5901c26ef..c67cc22e95 100644 --- a/cmp/grid/filter/GridFilterFieldSpec.ts +++ b/cmp/grid/filter/GridFilterFieldSpec.ts @@ -29,7 +29,7 @@ export interface GridFilterFieldSpecConfig extends BaseFilterFieldSpecConfig { * Props to pass through to the HoistInput components used on the custom filter tab. * Note that the HoistInput component used is decided by fieldType. */ - inputProps?: HoistInputProps; + inputProps?: HoistInputProps; /** Default operator displayed in custom filter tab. */ defaultOp?: FieldFilterOperator; diff --git a/cmp/grid/helpers/GridCountLabel.ts b/cmp/grid/helpers/GridCountLabel.ts index ce4dae36ab..4c7caa24c3 100644 --- a/cmp/grid/helpers/GridCountLabel.ts +++ b/cmp/grid/helpers/GridCountLabel.ts @@ -5,12 +5,12 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {box} from '@xh/hoist/cmp/layout'; -import {BoxProps, hoistCmp, HoistProps, useContextModel} from '@xh/hoist/core'; +import {BoxProps, hoistCmp, HoistPropsWithRef, useContextModel} from '@xh/hoist/core'; import {fmtNumber} from '@xh/hoist/format'; import {logError, pluralize, singularize, withDefault} from '@xh/hoist/utils/js'; import {GridModel} from '../GridModel'; -export interface GridCountLabelProps extends HoistProps, BoxProps { +export interface GridCountLabelProps extends HoistPropsWithRef, BoxProps { /** GridModel to which this component should bind. */ gridModel?: GridModel; @@ -39,13 +39,16 @@ export const [GridCountLabel, gridCountLabel] = hoistCmp.withFactory extends HoistModel { /** Does this input have the focus? */ @observable hasFocus: boolean = false; @@ -102,7 +105,7 @@ export class HoistInputModel extends HoistModel { // Implementation State //------------------------ @observable.ref internalValue: any = null; // Cached internal value - inputRef = createObservableRef(); // ref to internal element, if any + inputRef = createObservableRef(); // ref to internal element, if any domRef = createObservableRef(); // ref to outermost element, or class Component. isDirty: boolean = false; @@ -326,13 +329,13 @@ export class HoistInputModel extends HoistModel { * @param ref - forwardRef passed to containing component * @param modelSpec - specify to use particular subclass of HoistInputModel */ -export function useHoistInputModel( +export function useHoistInputModel( component: any, - props: DefaultHoistProps, - ref: ForwardedRef, - modelSpec?: HoistModelClass + props: PlainObject, + ref: ForwardedRef>, + modelSpec?: HoistModelClass> ): ReactElement { - const inputModel = useLocalModel(modelSpec ?? HoistInputModel); + const inputModel = useLocalModel>(modelSpec ?? HoistInputModel); useImperativeHandle(ref, () => inputModel); diff --git a/cmp/input/HoistInputProps.ts b/cmp/input/HoistInputProps.ts index e2ed72d286..52bcb3a542 100644 --- a/cmp/input/HoistInputProps.ts +++ b/cmp/input/HoistInputProps.ts @@ -5,9 +5,17 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {TestSupportProps} from '@xh/hoist/core'; +import {HoistInputModel} from '@xh/hoist/cmp/input/HoistInputModel'; +import {HoistModel, HoistProps, TestSupportProps} from '@xh/hoist/core'; +import {CSSProperties} from 'react'; -export interface HoistInputProps extends TestSupportProps { +/** + * Props for HoistInput components. + * @typeparam R - the type of HoistInputModel.inputRef (if any) for this component. + */ +export interface HoistInputProps + extends TestSupportProps, + HoistProps> { /** * Field or model property name from which this component should read and write its value * in controlled mode. Can be set by parent FormField. @@ -26,6 +34,9 @@ export interface HoistInputProps extends TestSupportProps { /** Called when value is committed to backing model - passed new and prior values. */ onCommit?: (value: any, oldValue: any) => void; + /** CSS style attributes for the input element. */ + style?: CSSProperties; + /** Tab order for focus control, or -1 to skip. If unset, browser layout-based order. */ tabIndex?: number; diff --git a/cmp/layout/Box.ts b/cmp/layout/Box.ts index 30bca96422..22bf31f039 100644 --- a/cmp/layout/Box.ts +++ b/cmp/layout/Box.ts @@ -4,12 +4,12 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {BoxProps, hoistCmp, HoistProps} from '@xh/hoist/core'; +import {BoxProps, hoistCmp, HoistPropsWithRef} from '@xh/hoist/core'; import {TEST_ID, mergeDeep} from '@xh/hoist/utils/js'; import {splitLayoutProps} from '@xh/hoist/utils/react'; import {div} from './Tags'; -export interface BoxComponentProps extends HoistProps, BoxProps {} +export interface BoxComponentProps extends HoistPropsWithRef, BoxProps {} /** * A Component that supports flexbox-based layout of its contents. diff --git a/cmp/layout/Frame.ts b/cmp/layout/Frame.ts index dddb331dd3..bf8b18bdbb 100644 --- a/cmp/layout/Frame.ts +++ b/cmp/layout/Frame.ts @@ -4,10 +4,10 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {hoistCmp, BoxProps, HoistProps} from '@xh/hoist/core'; +import {hoistCmp, BoxProps, HoistPropsWithRef} from '@xh/hoist/core'; import {box} from './Box'; -export interface FrameProps extends HoistProps, BoxProps {} +export interface FrameProps extends HoistPropsWithRef, BoxProps {} /** * A Box class that flexes to grow and stretch within its *own* parent via flex:'auto', useful for diff --git a/cmp/layout/Placeholder.ts b/cmp/layout/Placeholder.ts index 6d2e9bf8bd..cc81248bda 100644 --- a/cmp/layout/Placeholder.ts +++ b/cmp/layout/Placeholder.ts @@ -4,11 +4,11 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {hoistCmp, BoxProps, setCmpErrorDisplay, HoistProps} from '@xh/hoist/core'; +import {hoistCmp, BoxProps, setCmpErrorDisplay, HoistPropsWithRef} from '@xh/hoist/core'; import {box} from '@xh/hoist/cmp/layout'; import './Placeholder.scss'; -export interface PlaceholderProps extends HoistProps, BoxProps {} +export interface PlaceholderProps extends HoistPropsWithRef, BoxProps {} /** * A thin wrapper around `Box` with standardized, muted styling. diff --git a/cmp/layout/Spacer.ts b/cmp/layout/Spacer.ts index 492793b625..9844fc14c9 100644 --- a/cmp/layout/Spacer.ts +++ b/cmp/layout/Spacer.ts @@ -4,10 +4,10 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {hoistCmp, BoxProps, HoistProps} from '@xh/hoist/core'; +import {hoistCmp, BoxProps, HoistPropsWithRef, HoistProps} from '@xh/hoist/core'; import {box} from './Box'; -export interface SpacerProps extends HoistProps, BoxProps {} +export interface SpacerProps extends HoistPropsWithRef, BoxProps {} /** * A component for inserting a fixed-sized spacer along the main axis of its parent container. @@ -31,7 +31,7 @@ export const [Spacer, spacer] = hoistCmp.withFactory({ /** * A component that stretches to soak up space along the main axis of its parent container. */ -export const [Filler, filler] = hoistCmp.withFactory({ +export const [Filler, filler] = hoistCmp.withFactory({ displayName: 'Filler', model: false, observer: false, diff --git a/cmp/layout/TileFrame.ts b/cmp/layout/TileFrame.ts index 64b8fa2d0d..a6bc116588 100644 --- a/cmp/layout/TileFrame.ts +++ b/cmp/layout/TileFrame.ts @@ -4,7 +4,7 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {hoistCmp, useLocalModel, HoistModel, BoxProps, HoistProps} from '@xh/hoist/core'; +import {hoistCmp, useLocalModel, HoistModel, BoxProps, HoistPropsWithRef} from '@xh/hoist/core'; import {frame, box} from '@xh/hoist/cmp/layout'; import {useOnResize} from '@xh/hoist/utils/react'; import {useState, useLayoutEffect} from 'react'; @@ -14,7 +14,7 @@ import {Children} from 'react'; import './TileFrame.scss'; -export interface TileFrameProps extends HoistProps, BoxProps { +export interface TileFrameProps extends HoistPropsWithRef, BoxProps { /** * Desired tile width / height ratio (i.e. desiredRatio: 2 == twice as wide as tall). * The container will strive to meet this ratio, but the final ratio may vary. @@ -55,7 +55,7 @@ export interface TileFrameProps extends HoistProps, BoxProps { * stable layouts. These should be used judiciously, however, as each constraint limits the ability * of the TileFrame to fill its available space. */ -export const [TileFrame, tileFrame] = hoistCmp.withFactory({ +export const [TileFrame, tileFrame] = hoistCmp.withFactory({ displayName: 'TileFrame', memo: false, observer: false, diff --git a/cmp/layout/Viewport.ts b/cmp/layout/Viewport.ts index e8e98bfa7b..d6fce0adac 100644 --- a/cmp/layout/Viewport.ts +++ b/cmp/layout/Viewport.ts @@ -4,11 +4,11 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {hoistCmp, BoxProps, HoistProps} from '@xh/hoist/core'; +import {hoistCmp, BoxProps, HoistPropsWithRef} from '@xh/hoist/core'; import {box} from './Box'; import './Viewport.scss'; -export interface ViewportProps extends HoistProps, BoxProps {} +export interface ViewportProps extends HoistPropsWithRef, BoxProps {} /** * A container for the top level of the application. diff --git a/cmp/pinpad/PinPad.ts b/cmp/pinpad/PinPad.ts index 99df78e6c5..c7b6e2e546 100644 --- a/cmp/pinpad/PinPad.ts +++ b/cmp/pinpad/PinPad.ts @@ -4,12 +4,12 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {hoistCmp, uses, XH} from '@xh/hoist/core'; +import {hoistCmp, uses, HoistProps, XH, TestSupportProps} from '@xh/hoist/core'; import {pinPadImpl as desktopPinPadImpl} from '@xh/hoist/dynamics/desktop'; import {pinPadImpl as mobilePinPadImpl} from '@xh/hoist/dynamics/mobile'; - import {PinPadModel} from './PinPadModel'; +export interface PinPadProps extends HoistProps, TestSupportProps {} /** * Displays a prompt used to get obtain a PIN from the user. * @@ -17,7 +17,7 @@ import {PinPadModel} from './PinPadModel'; * * @see PinPadModel */ -export const [PinPad, pinPad] = hoistCmp.withFactory({ +export const [PinPad, pinPad] = hoistCmp.withFactory({ displayName: 'PinPad', model: uses(PinPadModel), className: 'xh-pinpad', diff --git a/cmp/pinpad/PinPadModel.ts b/cmp/pinpad/PinPadModel.ts index 352be9a97e..80d59fa9ed 100644 --- a/cmp/pinpad/PinPadModel.ts +++ b/cmp/pinpad/PinPadModel.ts @@ -27,7 +27,7 @@ export class PinPadModel extends HoistModel { @bindable subHeaderText: string; @bindable errorText: string; - ref = createObservableRef(); + ref = createObservableRef(); @observable private _enteredDigits: number[]; diff --git a/cmp/relativetimestamp/RelativeTimestamp.ts b/cmp/relativetimestamp/RelativeTimestamp.ts index 8aa74fd054..efd0f336a3 100644 --- a/cmp/relativetimestamp/RelativeTimestamp.ts +++ b/cmp/relativetimestamp/RelativeTimestamp.ts @@ -22,7 +22,7 @@ import {Timer} from '@xh/hoist/utils/async'; import {DAYS, HOURS, LocalDate, SECONDS} from '@xh/hoist/utils/datetime'; import {logWarn, withDefault} from '@xh/hoist/utils/js'; -interface RelativeTimestampProps extends HoistProps, BoxProps { +interface RelativeTimestampProps extends HoistProps, BoxProps { /** * Property on context model containing timestamp. * Specify as an alternative to direct `timestamp` prop (and minimize parent re-renders). @@ -91,7 +91,7 @@ export const [RelativeTimestamp, relativeTimestamp] = hoistCmp.withFactory, BoxProps { /** Store to which this component should bind. */ store?: Store; diff --git a/cmp/store/StoreFilterField.ts b/cmp/store/StoreFilterField.ts index 531dfb59aa..dd444b99bc 100644 --- a/cmp/store/StoreFilterField.ts +++ b/cmp/store/StoreFilterField.ts @@ -11,7 +11,8 @@ import {storeFilterFieldImpl as desktopStoreFilterFieldImpl} from '@xh/hoist/dyn import {storeFilterFieldImpl as mobileStoreFilterFieldImpl} from '@xh/hoist/dynamics/mobile'; import {StoreFilterFieldImplModel} from './impl/StoreFilterFieldImplModel'; -export interface StoreFilterFieldProps extends DefaultHoistProps { +export interface StoreFilterFieldProps + extends DefaultHoistProps { /** * Automatically apply the filter to bound store (default true). Applications that need to * combine the filter generated by this component with other filters or run any other custom @@ -57,7 +58,7 @@ export interface StoreFilterFieldProps extends DefaultHoistProps { matchMode?: 'start' | 'startWord' | 'any'; /** Optional model for raw value binding - see comments on the `bind` prop for details. */ - model?: HoistModel; + model?: M; /** * Callback to receive an updated Filter. Typically used in conjunction with `autoApply: false` diff --git a/cmp/tab/TabModel.ts b/cmp/tab/TabModel.ts index 10b90ba720..fae88aa117 100644 --- a/cmp/tab/TabModel.ts +++ b/cmp/tab/TabModel.ts @@ -19,6 +19,7 @@ import {action, computed, observable, makeObservable, bindable} from '@xh/hoist/ import {throwIf} from '@xh/hoist/utils/js'; import {startCase} from 'lodash'; import {TabContainerModel} from '@xh/hoist/cmp/tab/TabContainerModel'; +import {JSX} from 'react'; import {ReactElement, ReactNode} from 'react'; export interface TabConfig { @@ -38,7 +39,7 @@ export interface TabConfig { icon?: ReactElement; /** Tooltip for the Tab in the container's TabSwitcher. */ - tooltip?: ReactNode; + tooltip?: JSX.Element | string; /** True to disable this tab in the TabSwitcher and block routing. */ disabled?: boolean; @@ -85,7 +86,7 @@ export class TabModel extends HoistModel { id: string; @bindable.ref title: ReactNode; @bindable.ref icon: ReactElement; - @bindable.ref tooltip: ReactNode; + @bindable.ref tooltip: JSX.Element | string; @observable disabled: boolean; @bindable excludeFromSwitcher: boolean; showRemoveAction: boolean; diff --git a/cmp/tab/TabSwitcherProps.ts b/cmp/tab/TabSwitcherProps.ts index 15fdb61743..93bdf663ce 100644 --- a/cmp/tab/TabSwitcherProps.ts +++ b/cmp/tab/TabSwitcherProps.ts @@ -7,7 +7,7 @@ import {BoxProps, HoistProps, Side} from '@xh/hoist/core'; import {TabContainerModel} from './TabContainerModel'; -export interface TabSwitcherProps extends HoistProps, BoxProps { +export interface TabSwitcherProps extends HoistProps, BoxProps { /** Relative position within the parent TabContainer. Defaults to 'top'. */ orientation?: Side; diff --git a/cmp/zoneGrid/ZoneGrid.ts b/cmp/zoneGrid/ZoneGrid.ts index b363b61536..96875f1976 100644 --- a/cmp/zoneGrid/ZoneGrid.ts +++ b/cmp/zoneGrid/ZoneGrid.ts @@ -14,7 +14,10 @@ import {splitLayoutProps} from '@xh/hoist/utils/react'; import {ZoneGridModel} from './ZoneGridModel'; import './ZoneGrid.scss'; -export interface ZoneGridProps extends HoistProps, LayoutProps, TestSupportProps { +export interface ZoneGridProps + extends HoistProps, + LayoutProps, + TestSupportProps { /** * Options for ag-Grid's API. * diff --git a/core/AppSpec.ts b/core/AppSpec.ts index 17960ba0ef..7e5b1d7081 100644 --- a/core/AppSpec.ts +++ b/core/AppSpec.ts @@ -4,10 +4,17 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {XH, HoistAppModel, HoistAuthModel, ElementFactory, HoistProps} from '@xh/hoist/core'; +import { + XH, + HoistAppModel, + HoistAuthModel, + ElementFactory, + HoistProps, + HoistPropsWithModel +} from '@xh/hoist/core'; import {throwIf} from '@xh/hoist/utils/js'; import {isFunction, isNil, isString} from 'lodash'; -import {Component, ComponentClass, FunctionComponent} from 'react'; +import {Component, ComponentType, FunctionComponent} from 'react'; /** * Specification for a client-side Hoist application. @@ -46,14 +53,14 @@ export class AppSpec { * Root HoistComponent for the application. Despite the name, functional components are fully * supported and expected. */ - componentClass: ComponentClass | FunctionComponent; + componentClass: ComponentType>; /** * Container component to be used to host this application. * This class determines the platform used by Hoist. The value should be imported from * either `@xh/hoist/desktop/AppContainer` or `@xh/hoist/mobile/AppContainer`. */ - containerClass: ComponentClass | FunctionComponent; + containerClass: ComponentType; /** True if the app should use the Hoist mobile toolkit.*/ isMobileApp: boolean; diff --git a/core/HoistComponent.ts b/core/HoistComponent.ts index c5dd3247ab..a3a1693a85 100644 --- a/core/HoistComponent.ts +++ b/core/HoistComponent.ts @@ -12,7 +12,11 @@ import { DefaultHoistProps, elementFactory, ElementFactory, - TestSupportProps + TestSupportProps, + ModelTypeOf, + RefTypeOf, + PlainObject, + HoistPropsWithModel } from './'; import { useModelLinker, @@ -52,26 +56,26 @@ import { * `ref` is passed as the second argument to the render function. */ -export type RenderPropsOf

= P & { - /** Pre-processed by HoistComponent internals into a mounted model. Never passed to render. */ - modelConfig: never; - - /** Pre-processed by HoistComponent internals and attached to model. Never passed to render. */ - modelRef: never; - - /** Pre-processed by HoistComponent internals and passed as second argument to render. */ - ref: never; -}; +export type RenderPropsOf

, RefTypeOf

>> = Omit< + P, + 'modelConfig' | 'modelRef' | 'ref' +>; /** * Configuration for creating a Component. May be specified either as a render function, * or an object containing a render function and associated metadata. */ -export type ComponentConfig

= - | ((props: RenderPropsOf

, ref?: ForwardedRef) => ReactNode) +export type ComponentConfig< + /** HoistProps used to infer model and ref types */ + P extends HoistProps, RefTypeOf

>, + /** Additional props that may be passed to the render function */ + D extends PlainObject = {}, + R = RefTypeOf

extends never ? unknown : RefTypeOf

+> = + | ((props: RenderPropsOf

& D, ref?: ForwardedRef) => ReactNode) | { /** Render function defining the component. */ - render(props: RenderPropsOf

, ref?: ForwardedRef): ReactNode; + render(props: RenderPropsOf

& D, ref?: ForwardedRef): ReactNode; /** * Spec defining the model to be rendered by this component. @@ -79,7 +83,7 @@ export type ComponentConfig

= * return of {@link uses} or {@link creates} - these factory functions will create a spec for * either externally-provided or internally-created models. Defaults to `uses('*')`. */ - model?: ModelSpec | false; + model?: ModelTypeOf

extends never ? false : ModelSpec>; /** * Base CSS class for this component. Will be combined with any className @@ -137,11 +141,13 @@ let cmpIndex = 0; // index for anonymous component dispay names * - `hoistCmp.withFactory` - return a 2-element list containing both the newly * defined Component and an elementFactory for it. */ -export function hoistCmp( - config: ComponentConfig> +export function hoistCmp( + config: ComponentConfig, PlainObject> // Infer model, but accept all props ): FC>; -export function hoistCmp

(config: ComponentConfig

): FC

; -export function hoistCmp

(config: ComponentConfig

): FC

{ +export function hoistCmp

, RefTypeOf

>>( + config: ComponentConfig

+): FC

; +export function hoistCmp(config) { // 0) Pre-process/parse args. if (isFunction(config)) config = {render: config, displayName: config.name}; @@ -202,10 +208,10 @@ export const hoistComponent = hoistCmp; * * Most typically used by application, this provides a simple element factory. */ -export function hoistCmpFactory( - config: ComponentConfig> +export function hoistCmpFactory( + config: ComponentConfig, PlainObject> // Infer model, but accept all props ): ElementFactory>; -export function hoistCmpFactory

( +export function hoistCmpFactory

, RefTypeOf

>>( config: ComponentConfig

): ElementFactory

; export function hoistCmpFactory(config) { @@ -219,13 +225,13 @@ hoistCmp.factory = hoistCmpFactory; * * Not typically used by applications. */ -export function hoistCmpWithFactory( - config: ComponentConfig> +export function hoistCmpWithFactory( + config: ComponentConfig, PlainObject> // Infer model, but accept all props ): [FC>, ElementFactory>]; -export function hoistCmpWithFactory

( +export function hoistCmpWithFactory

, RefTypeOf

>>( config: ComponentConfig

): [FC

, ElementFactory

]; -export function hoistCmpWithFactory(config) { +export function hoistCmpWithFactory(config: ComponentConfig>) { const cmp = hoistCmp(config); return [cmp, elementFactory(cmp)]; } @@ -238,7 +244,10 @@ hoistCmp.withFactory = hoistCmpWithFactory; //---------------------------------- // internal types and core wrappers //---------------------------------- -type RenderFn = (props: HoistProps & TestSupportProps, ref?: ForwardedRef) => ReactNode; +type RenderFn = ( + props: HoistPropsWithModel & TestSupportProps, + ref?: ForwardedRef +) => ReactNode; interface Config { displayName: string; @@ -353,7 +362,11 @@ function wrapWithModel(render: RenderFn, cfg: Config): RenderFn { //------------------------------------------------------------------------- // Support to resolve/create model at render-time. Used by wrappers above. //------------------------------------------------------------------------- -function useResolvedModel(props: HoistProps, modelLookup: ModelLookup, cfg: Config): ResolvedModel { +function useResolvedModel( + props: HoistProps, + modelLookup: ModelLookup, + cfg: Config +): ResolvedModel { let ref = useRef(null), resolvedModel = ref.current; @@ -393,7 +406,11 @@ function createModel(spec: CreatesSpec): ResolvedModel { return {model, isLinked: true, fromContext: false}; } -function lookupModel(props: HoistProps, modelLookup: ModelLookup, cfg: Config): ResolvedModel { +function lookupModel( + props: HoistPropsWithModel, + modelLookup: ModelLookup, + cfg: Config +): ResolvedModel { let {model, modelConfig} = props, spec = cfg.modelSpec as UsesSpec, selector = spec.selector as any; diff --git a/core/HoistProps.ts b/core/HoistProps.ts index 50d50a7e09..6656e777d4 100644 --- a/core/HoistProps.ts +++ b/core/HoistProps.ts @@ -9,15 +9,17 @@ import {Property} from 'csstype'; import {CSSProperties, HTMLAttributes, LegacyRef, ReactNode, Ref} from 'react'; /** - * Props interface for Hoist Components. + * Props interfaces for Hoist Components. * - * This interface brings in additional properties that are added to the props + * These interfaces bring in additional properties that are added to the props * collection by HoistComponent. */ -export interface HoistProps { +export type HoistPropsWithModel = HoistProps; +export type HoistPropsWithRef = HoistProps; +export interface HoistProps { /** * Associated HoistModel for this Component. Depending on the component, may be specified as - * an instance of a HoistModel, or a configuration object to create one, or left undefined. + * an instance of a HoistModel or left undefined. * HoistComponent will resolve (i.e. lookup in context or create if needed) a concrete Model * instance and provide it to the Render method of the component. */ @@ -28,12 +30,12 @@ export interface HoistProps { * when first mounted. Should be used only on a component that specifies the 'uses()' directive * with the `createFromConfig` set as true. See the `uses()` directive for more information. */ - modelConfig?: M extends null ? never : M['config']; + modelConfig?: M extends never ? never : M['config']; /** * Used for gaining a reference to the model of a HoistComponent. */ - modelRef?: M extends null ? never : Ref; + modelRef?: M extends never ? never : Ref; /** * ClassName for the component. Includes the classname as provided in props, enhanced with @@ -45,9 +47,26 @@ export interface HoistProps { children?: ReactNode; /** React Ref for this component. */ - ref?: LegacyRef; + ref?: R extends never ? never : LegacyRef; } +/** Infer the Model type from a HoistProps type. */ +export type ModelTypeOf> = T extends never + ? never + : T extends HoistProps + ? M + : never; + +/** Infer the Ref type from a HoistProps type. */ +export type RefTypeOf> = + T extends HoistProps ? R : never; + +/** Extract all non-model and non-ref props from a HoistProps type. */ +export type WithoutModelAndRef> = Omit< + T, + 'model' | 'modelRef' | 'modelConfig' | 'ref' +>; + /** * A version of Hoist props that allows dynamic keys/properties. This is the interface that * Hoist uses for components that do not explicitly specify the type of props they expect. @@ -56,7 +75,7 @@ export interface HoistProps { * props API. */ -export interface DefaultHoistProps extends HoistProps { +export interface DefaultHoistProps extends HoistProps { [x: string]: any; } diff --git a/core/model/HoistModel.ts b/core/model/HoistModel.ts index 1e4fdb4d51..7f4eb81f17 100644 --- a/core/model/HoistModel.ts +++ b/core/model/HoistModel.ts @@ -7,7 +7,7 @@ import {action, makeObservable, observable} from '@xh/hoist/mobx'; import {warnIf} from '@xh/hoist/utils/js'; import {forOwn, has, isFunction} from 'lodash'; -import {DefaultHoistProps, HoistBase, LoadSpecConfig, managed, PlainObject} from '../'; +import {HoistBase, LoadSpecConfig, managed, PlainObject} from '../'; import {instanceManager} from '../impl/InstanceManager'; import {Loadable, LoadSpec, LoadSupport} from '../load'; import {ModelSelector} from './'; @@ -135,7 +135,7 @@ export abstract class HoistModel extends HoistBase implements Loadable { * Observability is based on a shallow computation for each prop (i.e. a reference * change in any particular prop will trigger observers to be notified). */ - get componentProps(): DefaultHoistProps { + get componentProps(): PlainObject { return this._componentProps; } diff --git a/desktop/cmp/button/AppMenuButton.ts b/desktop/cmp/button/AppMenuButton.ts index 6217536ac1..bd651f2be1 100644 --- a/desktop/cmp/button/AppMenuButton.ts +++ b/desktop/cmp/button/AppMenuButton.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {MenuItemProps} from '@blueprintjs/core'; -import {hoistCmp, MenuItemLike, MenuItem, XH} from '@xh/hoist/core'; +import {hoistCmp, ElementSpec, MenuItemLike, MenuItem, XH} from '@xh/hoist/core'; import {ButtonProps, button} from '@xh/hoist/desktop/cmp/button'; import '@xh/hoist/desktop/register'; import {Icon} from '@xh/hoist/icon'; @@ -197,7 +197,7 @@ function parseMenuItems(items: MenuItemLike[]): ReactNode[] { const {actionFn} = item; // Create menuItem from config - const cfg: MenuItemProps = { + const cfg: ElementSpec = { text: item.text, icon: item.icon, intent: item.intent, diff --git a/desktop/cmp/button/Button.ts b/desktop/cmp/button/Button.ts index 80b9f14116..f05a65129a 100644 --- a/desktop/cmp/button/Button.ts +++ b/desktop/cmp/button/Button.ts @@ -7,8 +7,7 @@ import {ButtonProps as BpButtonProps} from '@blueprintjs/core'; import { hoistCmp, - HoistModel, - HoistProps, + HoistPropsWithRef, Intent, LayoutProps, StyleProps, @@ -22,8 +21,8 @@ import classNames from 'classnames'; import {ReactElement, ReactNode} from 'react'; import './Button.scss'; -export interface ButtonProps - extends HoistProps, +export interface ButtonProps + extends HoistPropsWithRef, StyleProps, LayoutProps, TestSupportProps, diff --git a/desktop/cmp/button/ButtonGroup.ts b/desktop/cmp/button/ButtonGroup.ts index 415bf75d96..b9b1f9f127 100644 --- a/desktop/cmp/button/ButtonGroup.ts +++ b/desktop/cmp/button/ButtonGroup.ts @@ -19,8 +19,8 @@ import {TEST_ID} from '@xh/hoist/utils/js'; import {splitLayoutProps} from '@xh/hoist/utils/react'; import {SetOptional} from 'type-fest'; -export interface ButtonGroupProps - extends HoistProps, +export interface ButtonGroupProps + extends HoistProps, LayoutProps, StyleProps, TestSupportProps, diff --git a/desktop/cmp/button/ColChooserButton.ts b/desktop/cmp/button/ColChooserButton.ts index 5b04354a70..0d84948dee 100644 --- a/desktop/cmp/button/ColChooserButton.ts +++ b/desktop/cmp/button/ColChooserButton.ts @@ -13,9 +13,12 @@ import '@xh/hoist/desktop/register'; import {Icon} from '@xh/hoist/icon'; import {popover, Position} from '@xh/hoist/kit/blueprint'; import {logError, stopPropagation, withDefault} from '@xh/hoist/utils/js'; +import {RefAttributes} from 'react'; import {button, ButtonProps} from './Button'; -export interface ColChooserButtonProps extends ButtonProps { +export interface ColChooserButtonProps + extends Omit, + RefAttributes { /** GridModel of the grid for which this button should show a chooser. */ gridModel?: GridModel; diff --git a/desktop/cmp/button/RefreshButton.ts b/desktop/cmp/button/RefreshButton.ts index 859bad807e..2300cb22c1 100644 --- a/desktop/cmp/button/RefreshButton.ts +++ b/desktop/cmp/button/RefreshButton.ts @@ -4,30 +4,40 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {hoistCmp, HoistModel, RefreshContextModel, useContextModel} from '@xh/hoist/core'; +import { + hoistCmp, + HoistModel, + WithoutModelAndRef, + RefreshContextModel, + useContextModel, + HoistPropsWithRef +} from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {Icon} from '@xh/hoist/icon'; import {errorIf, withDefault} from '@xh/hoist/utils/js'; import {button, ButtonProps} from './Button'; -export type RefreshButtonProps = ButtonProps; +export interface RefreshButtonProps + extends WithoutModelAndRef, + HoistPropsWithRef { + target?: HoistModel; +} /** * Convenience Button preconfigured for use as a trigger for a refresh operation. * * If an onClick handler is provided it will be used. Otherwise this button will - * be linked to any model in props with LoadSupport enabled, or the contextual + * be linked to any target in props with LoadSupport enabled, or the contextual * See {@link RefreshContextModel}. */ export const [RefreshButton, refreshButton] = hoistCmp.withFactory({ displayName: 'RefreshButton', - model: false, // For consistency with all other buttons -- the model prop here could be replaced by 'target' + model: false, - render({model, onClick, ...props}, ref) { + render({target, onClick, ...props}, ref) { const refreshContextModel = useContextModel(RefreshContextModel); if (!onClick) { - let target: HoistModel = model; errorIf( target && !target.loadSupport, 'Models provided to RefreshButton must enable LoadSupport.' diff --git a/desktop/cmp/button/ZoneMapperButton.ts b/desktop/cmp/button/ZoneMapperButton.ts index 9abdc339a7..043a792113 100644 --- a/desktop/cmp/button/ZoneMapperButton.ts +++ b/desktop/cmp/button/ZoneMapperButton.ts @@ -13,9 +13,12 @@ import {zoneMapper} from '@xh/hoist/desktop/cmp/zoneGrid/impl/ZoneMapper'; import {Icon} from '@xh/hoist/icon'; import {popover, Position} from '@xh/hoist/kit/blueprint'; import {logError, stopPropagation, withDefault} from '@xh/hoist/utils/js'; +import {RefAttributes} from 'react'; import {button, ButtonProps} from './Button'; -export interface ZoneMapperButtonProps extends ButtonProps { +export interface ZoneMapperButtonProps + extends Omit, + RefAttributes { /** ZoneGridModel of the grid for which this button should show a chooser. */ zoneGridModel?: ZoneGridModel; diff --git a/desktop/cmp/dash/canvas/DashCanvas.ts b/desktop/cmp/dash/canvas/DashCanvas.ts index 52c50a8ccc..35cb477936 100644 --- a/desktop/cmp/dash/canvas/DashCanvas.ts +++ b/desktop/cmp/dash/canvas/DashCanvas.ts @@ -29,7 +29,9 @@ import {dashCanvasView} from './impl/DashCanvasView'; import 'react-grid-layout/css/styles.css'; import './DashCanvas.scss'; -export type DashCanvasProps = HoistProps & TestSupportProps; +export interface DashCanvasProps + extends HoistProps, + TestSupportProps {} /** * Dashboard-style container that allows users to drag-and-drop child widgets into flexible layouts. diff --git a/desktop/cmp/dash/container/DashContainer.ts b/desktop/cmp/dash/container/DashContainer.ts index b86f34d3cd..c468616521 100644 --- a/desktop/cmp/dash/container/DashContainer.ts +++ b/desktop/cmp/dash/container/DashContainer.ts @@ -22,7 +22,9 @@ import './DashContainer.scss'; import {DashContainerModel} from './DashContainerModel'; import {dashContainerAddViewButton} from './impl/DashContainerContextMenu'; -export type DashContainerProps = HoistProps & TestSupportProps; +export interface DashContainerProps + extends HoistProps, + TestSupportProps {} /** * Display a set of child components in accordance with a DashContainerModel. diff --git a/desktop/cmp/dock/impl/DockContainer.ts b/desktop/cmp/dock/impl/DockContainer.ts index 48648de5bc..68a7fb533f 100644 --- a/desktop/cmp/dock/impl/DockContainer.ts +++ b/desktop/cmp/dock/impl/DockContainer.ts @@ -17,7 +17,7 @@ import {DockContainerProps} from '../DockContainer'; * @internal */ export function dockContainerImpl( - {model, className, compactHeaders, ...props}: DockContainerProps, + {model, modelRef, modelConfig, className, compactHeaders, ...props}: DockContainerProps, ref ) { return hbox({ diff --git a/desktop/cmp/error/ErrorMessage.ts b/desktop/cmp/error/ErrorMessage.ts index f5ad681d96..010d0da272 100644 --- a/desktop/cmp/error/ErrorMessage.ts +++ b/desktop/cmp/error/ErrorMessage.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {div, filler, frame, hbox, p} from '@xh/hoist/cmp/layout'; -import {BoxProps, hoistCmp, HoistProps} from '@xh/hoist/core'; +import {BoxProps, hoistCmp, HoistModel, HoistProps} from '@xh/hoist/core'; import {button, ButtonProps} from '@xh/hoist/desktop/cmp/button'; import '@xh/hoist/desktop/register'; import {isNil, isString} from 'lodash'; @@ -13,7 +13,9 @@ import {isValidElement, ReactNode} from 'react'; import './ErrorMessage.scss'; import {Icon} from '@xh/hoist/icon'; -export interface ErrorMessageProps extends HoistProps, Omit { +export interface ErrorMessageProps + extends HoistProps, + Omit { /** * If provided, will render a "Retry" button that calls this function. * Use `actionButtonProps` for further control over this button. diff --git a/desktop/cmp/filechooser/FileChooser.ts b/desktop/cmp/filechooser/FileChooser.ts index 44801ae2c6..1f128ad792 100644 --- a/desktop/cmp/filechooser/FileChooser.ts +++ b/desktop/cmp/filechooser/FileChooser.ts @@ -14,7 +14,7 @@ import {ReactNode} from 'react'; import './FileChooser.scss'; import {FileChooserModel} from './FileChooserModel'; -export interface FileChooserProps extends HoistProps, BoxProps { +export interface FileChooserProps extends HoistProps, BoxProps { /** File type(s) to accept (e.g. `['.doc', '.docx', '.pdf']`). */ accept?: Some; diff --git a/desktop/cmp/filter/FilterChooser.ts b/desktop/cmp/filter/FilterChooser.ts index ea06a4805d..63cc9d31ee 100644 --- a/desktop/cmp/filter/FilterChooser.ts +++ b/desktop/cmp/filter/FilterChooser.ts @@ -19,7 +19,9 @@ import {isEmpty, sortBy} from 'lodash'; import {ReactElement} from 'react'; import './FilterChooser.scss'; -export interface FilterChooserProps extends HoistProps, LayoutProps { +export interface FilterChooserProps + extends HoistProps, + LayoutProps { /** True to focus the control on render. */ autoFocus?: boolean; /** True to disable user interaction. */ diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index d1f7ae8249..a9e75b4469 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -8,15 +8,7 @@ import {PopoverPosition, PopperBoundary} from '@blueprintjs/core'; import composeRefs from '@seznam/compose-react-refs/composeRefs'; import {BaseFormFieldProps, FieldModel, FormContext, FormContextType} from '@xh/hoist/cmp/form'; import {box, div, label as labelEl, li, span, ul} from '@xh/hoist/cmp/layout'; -import { - DefaultHoistProps, - hoistCmp, - HoistProps, - HSide, - TestSupportProps, - uses, - XH -} from '@xh/hoist/core'; +import {hoistCmp, HoistProps, HSide, PlainObject, TestSupportProps, uses, XH} from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {instanceManager} from '@xh/hoist/core/impl/InstanceManager'; import {fmtDate, fmtDateTime, fmtJson, fmtNumber} from '@xh/hoist/format'; @@ -179,12 +171,12 @@ export const [FormField, formField] = hoistCmp.withFactory({ let childEl: ReactElement = !child || readonly ? readonlyChild({ - model, + fieldModel: model, readonlyRenderer, testId: getTestId(testId, 'readonly-display') }) : editableChild({ - model, + fieldModel: model, child, childIsSizeable, childId, @@ -252,28 +244,29 @@ export const [FormField, formField] = hoistCmp.withFactory({ } }); -interface ReadonlyChildProps extends HoistProps, TestSupportProps { +interface ReadonlyChildProps extends HoistProps, TestSupportProps { + fieldModel: FieldModel; readonlyRenderer: (v: any, model: FieldModel) => ReactNode; } const readonlyChild = hoistCmp.factory({ model: false, - render({model, readonlyRenderer, testId}) { - const value = model ? model['value'] : null; + render({fieldModel, readonlyRenderer, testId}) { + const value = fieldModel ? fieldModel['value'] : null; return div({ className: 'xh-form-field-readonly-display', [TEST_ID]: testId, - item: readonlyRenderer(value, model) + item: readonlyRenderer(value, fieldModel) }); } }); -const editableChild = hoistCmp.factory({ +const editableChild = hoistCmp.factory({ model: false, render({ - model, + fieldModel, child, childIsSizeable, childId, @@ -286,12 +279,12 @@ const editableChild = hoistCmp.factory({ const {props} = child; // Overrides -- be sure not to clobber selected properties on child - const overrides: DefaultHoistProps = { - model, + const overrides: PlainObject = { + model: fieldModel, bind: 'value', id: childId, disabled: props.disabled || disabled, - ref: composeRefs(model?.boundInputRef, child.ref), + ref: composeRefs(fieldModel?.boundInputRef, child.ref), testId: props.testId ?? testId }; diff --git a/desktop/cmp/grid/editors/EditorProps.ts b/desktop/cmp/grid/editors/EditorProps.ts index 4eb645e02b..7d9da8d3ed 100644 --- a/desktop/cmp/grid/editors/EditorProps.ts +++ b/desktop/cmp/grid/editors/EditorProps.ts @@ -11,7 +11,7 @@ import {HoistProps} from '@xh/hoist/core'; import {StoreRecord} from '@xh/hoist/data'; import '@xh/hoist/desktop/register'; -export interface EditorProps extends HoistProps { +export interface EditorProps> extends HoistProps { /** Column in StoreRecord being edited. */ column: Column; diff --git a/desktop/cmp/grid/editors/impl/InlineEditorModel.ts b/desktop/cmp/grid/editors/impl/InlineEditorModel.ts index 4da3042678..b692cd725c 100644 --- a/desktop/cmp/grid/editors/impl/InlineEditorModel.ts +++ b/desktop/cmp/grid/editors/impl/InlineEditorModel.ts @@ -68,7 +68,7 @@ class InlineEditorModel extends HoistModel { @bindable value; - ref = createObservableRef(); + ref = createObservableRef>(); agParams: CustomCellEditorProps; diff --git a/desktop/cmp/grouping/GroupingChooser.ts b/desktop/cmp/grouping/GroupingChooser.ts index b089d32424..eac1d27f0b 100644 --- a/desktop/cmp/grouping/GroupingChooser.ts +++ b/desktop/cmp/grouping/GroupingChooser.ts @@ -6,7 +6,7 @@ */ import {GroupingChooserModel} from '@xh/hoist/cmp/grouping'; import {box, div, filler, fragment, hbox, vbox} from '@xh/hoist/cmp/layout'; -import {hoistCmp, uses} from '@xh/hoist/core'; +import {hoistCmp, HoistProps, uses, WithoutModelAndRef} from '@xh/hoist/core'; import {button, ButtonProps} from '@xh/hoist/desktop/cmp/button'; import {select} from '@xh/hoist/desktop/cmp/input'; import {panel} from '@xh/hoist/desktop/cmp/panel'; @@ -19,8 +19,12 @@ import {splitLayoutProps} from '@xh/hoist/utils/react'; import classNames from 'classnames'; import {compact, isEmpty, sortBy} from 'lodash'; import './GroupingChooser.scss'; +import {RefAttributes} from 'react'; -export interface GroupingChooserProps extends ButtonProps { +export interface GroupingChooserProps + extends WithoutModelAndRef, + HoistProps, + RefAttributes { /** Text to represent empty state (i.e. value = null or []) */ emptyText?: string; diff --git a/desktop/cmp/input/ButtonGroupInput.ts b/desktop/cmp/input/ButtonGroupInput.ts index d93d74c70f..710edcafac 100644 --- a/desktop/cmp/input/ButtonGroupInput.ts +++ b/desktop/cmp/input/ButtonGroupInput.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp, HoistModel, Intent, XH} from '@xh/hoist/core'; +import {DefaultHoistProps, hoistCmp, Intent, WithoutModelAndRef, XH} from '@xh/hoist/core'; import {Button, buttonGroup, ButtonGroupProps, ButtonProps} from '@xh/hoist/desktop/cmp/button'; import '@xh/hoist/desktop/register'; import {throwIf, warnIf, withDefault} from '@xh/hoist/utils/js'; @@ -14,8 +14,8 @@ import {castArray, filter, isEmpty, without} from 'lodash'; import {Children, cloneElement, isValidElement} from 'react'; export interface ButtonGroupInputProps - extends Omit, 'onChange'>, - HoistInputProps { + extends Omit, 'onChange'>, + HoistInputProps { /** * True to allow buttons to be unselected (aka inactivated). Defaults to false. * Does not apply when enableMulti: true. @@ -55,7 +55,7 @@ export const [ButtonGroupInput, buttonGroupInput] = hoistCmp.withFactory { override xhImpl = true; get enableMulti(): boolean { @@ -100,68 +100,70 @@ class ButtonGroupInputModel extends HoistInputModel { } } -const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { - const { - children, - // HoistInput Props - bind, - disabled, - onChange, - onCommit, - tabIndex, - value, - // FormField Props - commitOnChange, - // ButtonGroupInput Props - enableClear, - enableMulti, - // Button props applied to each child button - intent, - minimal, - outlined, - // ...and ButtonGroup gets all the rest - ...buttonGroupProps - } = getNonLayoutProps(props); - - const buttons = Children.map(children, button => { - if (!button) return null; - - if (!isValidElement(button) || button.type !== Button) { - throw XH.exception('ButtonGroupInput child must be a Button.'); - } - - const props = button.props as ButtonProps, - {value, intent: btnIntent} = props, - btnDisabled = disabled || props.disabled; - - throwIf( - (enableClear || enableMulti) && value == null, - 'ButtonGroupInput child must declare a non-null value when enableClear or enableMulti are true' - ); - - const isActive = model.isActive(value); - - return cloneElement(button, { - active: isActive, - intent: btnIntent ?? intent, - minimal: withDefault(minimal, false), - outlined: withDefault(outlined, false), - disabled: withDefault(btnDisabled, false), - onClick: () => model.onButtonClick(value), - // Workaround for https://github.com/palantir/blueprint/issues/3971 - key: `${isActive} ${value}`, - autoFocus: isActive && model.hasFocus - } as ButtonGroupProps); - }); - - return buttonGroup({ - items: buttons, - ...(buttonGroupProps as ButtonGroupProps), - minimal: withDefault(minimal, outlined, false), - ...getLayoutProps(props), - onBlur: model.onBlur, - onFocus: model.onFocus, - className, - ref - }); -}); +const cmp = hoistCmp.factory>( + ({model, className, ...props}, ref) => { + const { + children, + // HoistInput Props + bind, + disabled, + onChange, + onCommit, + tabIndex, + value, + // FormField Props + commitOnChange, + // ButtonGroupInput Props + enableClear, + enableMulti, + // Button props applied to each child button + intent, + minimal, + outlined, + // ...and ButtonGroup gets all the rest + ...buttonGroupProps + } = getNonLayoutProps(props); + + const buttons = Children.map(children, button => { + if (!button) return null; + + if (!isValidElement(button) || button.type !== Button) { + throw XH.exception('ButtonGroupInput child must be a Button.'); + } + + const props = button.props as ButtonProps, + {value, intent: btnIntent} = props, + btnDisabled = disabled || props.disabled; + + throwIf( + (enableClear || enableMulti) && value == null, + 'ButtonGroupInput child must declare a non-null value when enableClear or enableMulti are true' + ); + + const isActive = model.isActive(value); + + return cloneElement(button, { + active: isActive, + intent: btnIntent ?? intent, + minimal: withDefault(minimal, false), + outlined: withDefault(outlined, false), + disabled: withDefault(btnDisabled, false), + onClick: () => model.onButtonClick(value), + // Workaround for https://github.com/palantir/blueprint/issues/3971 + key: `${isActive} ${value}`, + autoFocus: isActive && model.hasFocus + } as ButtonGroupProps); + }); + + return buttonGroup({ + items: buttons, + ...(buttonGroupProps as ButtonGroupProps), + minimal: withDefault(minimal, outlined, false), + ...getLayoutProps(props), + onBlur: model.onBlur, + onFocus: model.onFocus, + className, + ref + }); + } +); diff --git a/desktop/cmp/input/Checkbox.ts b/desktop/cmp/input/Checkbox.ts index 179be3c117..af57741e82 100644 --- a/desktop/cmp/input/Checkbox.ts +++ b/desktop/cmp/input/Checkbox.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp, HoistProps, HSide, StyleProps} from '@xh/hoist/core'; +import {DefaultHoistProps, hoistCmp, HSide, StyleProps} from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {checkbox as bpCheckbox} from '@xh/hoist/kit/blueprint'; import {TEST_ID, withDefault} from '@xh/hoist/utils/js'; @@ -14,7 +14,7 @@ import {ReactNode} from 'react'; import './Checkbox.scss'; -export interface CheckboxProps extends HoistProps, HoistInputProps, StyleProps { +export interface CheckboxProps extends HoistInputProps, StyleProps { value?: boolean; /** True to focus the control on render. */ @@ -54,34 +54,36 @@ export const [Checkbox, checkbox] = hoistCmp.withFactory({ //---------------------------------- // Implementation //---------------------------------- -class CheckboxInputModel extends HoistInputModel { +class CheckboxInputModel extends HoistInputModel { override xhImpl = true; } -const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { - const {renderValue} = model, - labelSide = withDefault(props.labelSide, 'right'), - displayUnsetState = withDefault(props.displayUnsetState, false), - valueIsUnset = isNil(renderValue); +const cmp = hoistCmp.factory>( + ({model, className, ...props}, ref) => { + const {renderValue} = model, + labelSide = withDefault(props.labelSide, 'right'), + displayUnsetState = withDefault(props.displayUnsetState, false), + valueIsUnset = isNil(renderValue); - return bpCheckbox({ - autoFocus: props.autoFocus, - checked: !!renderValue, - indeterminate: valueIsUnset && displayUnsetState, - alignIndicator: labelSide === 'left' ? 'right' : 'left', - disabled: props.disabled, - inline: withDefault(props.inline, true), - label: props.label, - tabIndex: props.tabIndex, - id: props.id, - [TEST_ID]: props.testId, - className, - style: props.style, + return bpCheckbox({ + autoFocus: props.autoFocus, + checked: !!renderValue, + indeterminate: valueIsUnset && displayUnsetState, + alignIndicator: labelSide === 'left' ? 'right' : 'left', + disabled: props.disabled, + inline: withDefault(props.inline, true), + label: props.label, + tabIndex: props.tabIndex, + id: props.id, + [TEST_ID]: props.testId, + className, + style: props.style, - onBlur: model.onBlur, - onFocus: model.onFocus, - onChange: e => model.noteValueChange(e.target.checked), - inputRef: model.inputRef, - ref - }); -}); + onBlur: model.onBlur, + onFocus: model.onFocus, + onChange: e => model.noteValueChange(e.target.checked), + inputRef: model.inputRef, + ref + }); + } +); diff --git a/desktop/cmp/input/CodeInput.ts b/desktop/cmp/input/CodeInput.ts index 92b1a89ec1..c587b88b23 100644 --- a/desktop/cmp/input/CodeInput.ts +++ b/desktop/cmp/input/CodeInput.ts @@ -6,7 +6,15 @@ */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {box, div, filler, fragment, frame, hbox, label, span, vbox} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, LayoutProps, managed, PlainObject, XH} from '@xh/hoist/core'; +import { + BoxProps, + hoistCmp, + HoistProps, + LayoutProps, + managed, + PlainObject, + XH +} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; import {clipboardButton} from '@xh/hoist/desktop/cmp/clipboard'; import {textInput} from '@xh/hoist/desktop/cmp/input/TextInput'; @@ -39,7 +47,7 @@ import {ReactElement} from 'react'; import {findDOMNode} from 'react-dom'; import './CodeInput.scss'; -export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps { +export interface CodeInputProps extends HoistInputProps, LayoutProps { /** True to focus the control on render. */ autoFocus?: boolean; @@ -125,7 +133,7 @@ export const [CodeInput, codeInput] = hoistCmp.withFactory({ //------------------------------ // Implementation //------------------------------ -class CodeInputModel extends HoistInputModel { +class CodeInputModel extends HoistInputModel { override xhImpl = true; @managed @@ -446,44 +454,47 @@ class CodeInputModel extends HoistInputModel { } } -const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { - return box({ - className: 'xh-code-input__outer-wrapper', - width: 300, - height: 100, - ...getLayoutProps(props), - item: modalSupport({ - model: model.modalSupportModel, - item: inputCmp({ - testId: props.testId, - width: '100%', - height: '100%', - className, - ref, - model +const cmp = hoistCmp.factory & BoxProps>( + ({model, className, ...props}, ref) => { + return box({ + className: 'xh-code-input__outer-wrapper', + width: 300, + height: 100, + ...getLayoutProps(props), + item: modalSupport({ + model: model.modalSupportModel, + item: inputCmp({ + testId: props.testId, + width: '100%', + height: '100%', + className, + ref, + model + }) }) - }) - }); -}); + }); + } +); -const inputCmp = hoistCmp.factory(({model, ...props}, ref) => - vbox({ - items: [ - div({ - className: 'xh-code-input__inner-wrapper', - item: textArea({ - value: model.renderValue || '', - inputRef: model.manageCodeEditor, - onChange: model.onChange - }) - }), - model.showToolbar ? toolbarCmp() : actionButtonsCmp() - ], - onBlur: model.onBlur, - onFocus: model.onFocus, - ...props, - ref - }) +const inputCmp = hoistCmp.factory & BoxProps>( + ({model, ...props}, ref) => + vbox({ + items: [ + div({ + className: 'xh-code-input__inner-wrapper', + item: textArea({ + value: model.renderValue || '', + inputRef: model.manageCodeEditor, + onChange: model.onChange + }) + }), + model.showToolbar ? toolbarCmp() : actionButtonsCmp() + ], + onBlur: model.onBlur, + onFocus: model.onFocus, + ...props, + ref + }) ); const toolbarCmp = hoistCmp.factory(({model}) => { diff --git a/desktop/cmp/input/DateInput.ts b/desktop/cmp/input/DateInput.ts index b921462876..af17cc5f15 100644 --- a/desktop/cmp/input/DateInput.ts +++ b/desktop/cmp/input/DateInput.ts @@ -9,7 +9,7 @@ import {TimePickerProps} from '@blueprintjs/datetime'; import {ReactDayPickerSingleProps} from '@blueprintjs/datetime2/src/common/reactDayPickerProps'; import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {div} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, HSide, LayoutProps, Some} from '@xh/hoist/core'; +import {WithoutModelAndRef, hoistCmp, HoistProps, HSide, LayoutProps, Some} from '@xh/hoist/core'; import {button, buttonGroup} from '@xh/hoist/desktop/cmp/button'; import {textInput, TextInputModel} from '@xh/hoist/desktop/cmp/input'; import '@xh/hoist/desktop/register'; @@ -27,7 +27,7 @@ import moment from 'moment'; import {createRef, ReactElement, ReactNode} from 'react'; import './DateInput.scss'; -export interface DateInputProps extends HoistProps, LayoutProps, HoistInputProps { +export interface DateInputProps extends LayoutProps, HoistInputProps { value?: Date | LocalDate; /** Props passed to ReactDayPicker component, as per DayPicker docs. */ @@ -170,7 +170,7 @@ export const [DateInput, dateInput] = hoistCmp.withFactory({ //--------------------------------- // Implementation //--------------------------------- -class DateInputModel extends HoistInputModel { +class DateInputModel extends HoistInputModel { override xhImpl = true; @bindable popoverOpen: boolean = false; @@ -373,128 +373,128 @@ class DateInputModel extends HoistInputModel { } } -const cmp = hoistCmp.factory( - ({model, className, ...props}, ref) => { - warnIf( - (props.enableClear || props.enablePicker) && props.rightElement, - 'Cannot specify enableClear or enablePicker along with custom rightElement - built-in clear/picker button will not be shown.' - ); - - const enablePicker = props.enablePicker ?? true, - enableTextInput = props.enableTextInput ?? true, - enableClear = props.enableClear ?? false, - disabled = props.disabled ?? false, - isClearable = model.internalValue !== null, - isOpen = enablePicker && model.popoverOpen && !disabled; - - const buttons = buttonGroup({ - padding: 0, - items: [ - button({ - className: 'xh-date-input__clear-icon', - omit: !enableClear || !isClearable || disabled, - icon: Icon.cross(), - tabIndex: -1, - onClick: model.onClearBtnClick, - testId: getTestId(props, 'clear') - }), - button({ - className: classNames( - 'xh-date-input__picker-icon', - enablePicker ? null : 'xh-date-input__picker-icon--disabled' - ), - icon: Icon.calendar(), - tabIndex: enableTextInput || disabled ? -1 : undefined, - ref: model.buttonRef, - onClick: enablePicker && !disabled ? model.onOpenPopoverClick : null, - testId: getTestId(props, 'picker') - }) - ] - }); - const rightElement = withDefault(props.rightElement, buttons); - - let {minDate, maxDate, initialMonth, renderValue} = model; +const cmp = hoistCmp.factory< + HoistProps & WithoutModelAndRef +>(({model, className, ...props}, ref) => { + warnIf( + (props.enableClear || props.enablePicker) && props.rightElement, + 'Cannot specify enableClear or enablePicker along with custom rightElement - built-in clear/picker button will not be shown.' + ); + + const enablePicker = props.enablePicker ?? true, + enableTextInput = props.enableTextInput ?? true, + enableClear = props.enableClear ?? false, + disabled = props.disabled ?? false, + isClearable = model.internalValue !== null, + isOpen = enablePicker && model.popoverOpen && !disabled; + + const buttons = buttonGroup({ + padding: 0, + items: [ + button({ + className: 'xh-date-input__clear-icon', + omit: !enableClear || !isClearable || disabled, + icon: Icon.cross(), + tabIndex: -1, + onClick: model.onClearBtnClick, + testId: getTestId(props, 'clear') + }), + button({ + className: classNames( + 'xh-date-input__picker-icon', + enablePicker ? null : 'xh-date-input__picker-icon--disabled' + ), + icon: Icon.calendar(), + tabIndex: enableTextInput || disabled ? -1 : undefined, + ref: model.buttonRef, + onClick: enablePicker && !disabled ? model.onOpenPopoverClick : null, + testId: getTestId(props, 'picker') + }) + ] + }); + const rightElement = withDefault(props.rightElement, buttons); + + let {minDate, maxDate, initialMonth, renderValue} = model; + + // If app has set an out-of-range date, we render it -- these bounds govern *manual* entry + // But need to relax constraints on the picker, to prevent BP from breaking badly + if (renderValue) { + if (minDate && renderValue < minDate) minDate = renderValue; + if (maxDate && renderValue > maxDate) maxDate = renderValue; + } - // If app has set an out-of-range date, we render it -- these bounds govern *manual* entry - // But need to relax constraints on the picker, to prevent BP from breaking badly - if (renderValue) { - if (minDate && renderValue < minDate) minDate = renderValue; - if (maxDate && renderValue > maxDate) maxDate = renderValue; - } + // BP chooses annoying mid-point if forced to guess initial month. Use closest bound instead + if (!initialMonth && !renderValue) { + const today = new Date(); + if (minDate && today < minDate) initialMonth = minDate; + if (maxDate && today > maxDate) initialMonth = maxDate; + } - // BP chooses annoying mid-point if forced to guess initial month. Use closest bound instead - if (!initialMonth && !renderValue) { - const today = new Date(); - if (minDate && today < minDate) initialMonth = minDate; - if (maxDate && today > maxDate) initialMonth = maxDate; - } + return div({ + className: 'xh-date-input__wrapper', + item: popover({ + isOpen, + minimal: true, + usePortal: true, + autoFocus: false, + enforceFocus: false, + modifiers: props.popoverModifiers, + position: props.popoverPosition ?? 'auto', + boundary: props.popoverBoundary ?? 'clippingParents', + portalContainer: props.portalContainer ?? document.body, + popoverRef: model.popoverRef, + onClose: model.onPopoverClose, + onInteraction: nextOpenState => { + if (props.showPickerOnFocus) { + model.popoverOpen = nextOpenState; + } else if (!nextOpenState) { + model.popoverOpen = false; + } + }, + + content: bpDatePicker({ + value: renderValue, + onChange: model.onDatePickerChange, + maxDate, + minDate, + initialMonth, + showActionsBar: props.showActionsBar, + dayPickerProps: assign({fixedWeeks: true}, props.dayPickerProps), + timePrecision: model.timePrecision, + timePickerProps: model.timePrecision + ? assign({selectAllOnFocus: true}, props.timePickerProps) + : undefined + }), - return div({ - className: 'xh-date-input__wrapper', - item: popover({ - isOpen, - minimal: true, - usePortal: true, - autoFocus: false, - enforceFocus: false, - modifiers: props.popoverModifiers, - position: props.popoverPosition ?? 'auto', - boundary: props.popoverBoundary ?? 'clippingParents', - portalContainer: props.portalContainer ?? document.body, - popoverRef: model.popoverRef, - onClose: model.onPopoverClose, - onInteraction: nextOpenState => { - if (props.showPickerOnFocus) { - model.popoverOpen = nextOpenState; - } else if (!nextOpenState) { - model.popoverOpen = false; - } - }, - - content: bpDatePicker({ - value: renderValue, - onChange: model.onDatePickerChange, - maxDate, - minDate, - initialMonth, - showActionsBar: props.showActionsBar, - dayPickerProps: assign({fixedWeeks: true}, props.dayPickerProps), - timePrecision: model.timePrecision, - timePickerProps: model.timePrecision - ? assign({selectAllOnFocus: true}, props.timePickerProps) - : undefined + item: div({ + item: textInput({ + value: model.formatDate(renderValue) as string, + className: classNames( + className, + !enableTextInput && !disabled ? 'xh-date-input--picker-only' : null + ), + onCommit: model.onInputCommit, + onChange: model.onInputChange, + onKeyDown: model.onInputKeyDown, + rightElement: rightElement as ReactElement, + disabled: disabled || !enableTextInput, + leftIcon: props.leftIcon, + tabIndex: props.tabIndex, + placeholder: props.placeholder, + textAlign: props.textAlign, + selectOnFocus: props.selectOnFocus, + inputRef: model.inputRef, + ref: model.textInputRef, + testId: getTestId(props), + ...getLayoutProps(props) }), - - item: div({ - item: textInput({ - value: model.formatDate(renderValue) as string, - className: classNames( - className, - !enableTextInput && !disabled ? 'xh-date-input--picker-only' : null - ), - onCommit: model.onInputCommit, - onChange: model.onInputChange, - onKeyDown: model.onInputKeyDown, - rightElement: rightElement as ReactElement, - disabled: disabled || !enableTextInput, - leftIcon: props.leftIcon, - tabIndex: props.tabIndex, - placeholder: props.placeholder, - textAlign: props.textAlign, - selectOnFocus: props.selectOnFocus, - inputRef: model.inputRef, - ref: model.textInputRef, - testId: getTestId(props), - ...getLayoutProps(props) - }), - className: 'xh-date-input__click-target', - onClick: !enableTextInput && !disabled ? model.onOpenPopoverClick : null - }) - }), - onBlur: model.onBlur, - onFocus: model.onFocus, - onKeyDown: model.onKeyDown, - ref - }); - } -); + className: 'xh-date-input__click-target', + onClick: !enableTextInput && !disabled ? model.onOpenPopoverClick : null + }) + }), + onBlur: model.onBlur, + onFocus: model.onFocus, + onKeyDown: model.onKeyDown, + ref + }); +}); diff --git a/desktop/cmp/input/NumberInput.ts b/desktop/cmp/input/NumberInput.ts index 3db5bfa2c4..1ef1dafb0e 100644 --- a/desktop/cmp/input/NumberInput.ts +++ b/desktop/cmp/input/NumberInput.ts @@ -6,7 +6,7 @@ */ import composeRefs from '@seznam/compose-react-refs'; import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp, HoistProps, HSide, LayoutProps, StyleProps} from '@xh/hoist/core'; +import {hoistCmp, HSide, LayoutProps, StyleProps} from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {fmtNumber, parseNumber, Precision, ZeroPad} from '@xh/hoist/format'; import {numericInput} from '@xh/hoist/kit/blueprint'; @@ -16,7 +16,10 @@ import {getLayoutProps} from '@xh/hoist/utils/react'; import {debounce, isNaN, isNil, isNumber, round} from 'lodash'; import {KeyboardEventHandler, ReactElement, ReactNode, Ref, useLayoutEffect} from 'react'; -export interface NumberInputProps extends HoistProps, LayoutProps, StyleProps, HoistInputProps { +export interface NumberInputProps + extends LayoutProps, + StyleProps, + HoistInputProps { value?: number; /** True to focus the control on render. */ @@ -121,7 +124,7 @@ export const [NumberInput, numberInput] = hoistCmp.withFactory //----------------------- // Implementation //----------------------- -class NumberInputModel extends HoistInputModel { +class NumberInputModel extends HoistInputModel { override xhImpl = true; constructor() { diff --git a/desktop/cmp/input/RadioInput.ts b/desktop/cmp/input/RadioInput.ts index 92c3a0786e..c1d4a0edd7 100644 --- a/desktop/cmp/input/RadioInput.ts +++ b/desktop/cmp/input/RadioInput.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp, HoistProps, HSide} from '@xh/hoist/core'; +import {hoistCmp, HSide} from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {radio, radioGroup} from '@xh/hoist/kit/blueprint'; import {computed, makeObservable} from '@xh/hoist/mobx'; @@ -13,7 +13,7 @@ import {getTestId, TEST_ID, withDefault} from '@xh/hoist/utils/js'; import {filter, isObject} from 'lodash'; import './RadioInput.scss'; -export interface RadioInputProps extends HoistProps, HoistInputProps { +export interface RadioInputProps extends HoistInputProps { /** True to display each radio button inline with each other. */ inline?: boolean; @@ -43,7 +43,7 @@ export const [RadioInput, radioInput] = hoistCmp.withFactory({ //----------------------- // Implementation //----------------------- -class RadioInputModel extends HoistInputModel { +class RadioInputModel extends HoistInputModel { override xhImpl = true; get enabledInputs(): HTMLInputElement[] { diff --git a/desktop/cmp/input/Select.ts b/desktop/cmp/input/Select.ts index 89090f7cca..537d976358 100644 --- a/desktop/cmp/input/Select.ts +++ b/desktop/cmp/input/Select.ts @@ -9,8 +9,8 @@ import {box, div, fragment, hbox, span} from '@xh/hoist/cmp/layout'; import { Awaitable, createElement, + DefaultHoistProps, hoistCmp, - HoistProps, LayoutProps, PlainObject, SelectOption, @@ -39,7 +39,7 @@ import './Select.scss'; export const MENU_PORTAL_ID = 'xh-select-input-portal'; -export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps { +export interface SelectProps extends HoistInputProps, LayoutProps { /** True to focus the control on render. */ autoFocus?: boolean; @@ -212,7 +212,7 @@ export const [Select, select] = hoistCmp.withFactory({ //----------------------- // Implementation //----------------------- -class SelectInputModel extends HoistInputModel { +class SelectInputModel extends HoistInputModel { override xhImpl = true; // Normalized collection of selectable options. Passed directly to synchronous select. @@ -683,116 +683,118 @@ class SelectInputModel extends HoistInputModel { } } -const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { - const {width, height, ...layoutProps} = getLayoutProps(props), - rsProps: PlainObject = { - value: model.renderValue, - - autoFocus: props.autoFocus, - formatOptionLabel: model.formatOptionLabel, - isDisabled: props.disabled, - isMulti: props.enableMulti, - closeMenuOnSelect: props.closeMenuOnSelect, - hideSelectedOptions: model.hideSelectedOptions, - maxMenuHeight: props.maxMenuHeight, - - // Explicit false ensures consistent default for single and multi-value instances. - isClearable: withDefault(props.enableClear, false), - menuPlacement: withDefault(props.menuPlacement, 'auto'), - noOptionsMessage: model.noOptionsMessageFn, - openMenuOnFocus: props.openMenuOnFocus, - placeholder: withDefault(props.placeholder, 'Select...'), - tabIndex: props.tabIndex, - - // Minimize (or hide) bulky dropdown - components: { - DropdownIndicator: model.getDropdownIndicatorCmp(), - ClearIndicator: model.getClearIndicatorCmp(), - Menu: model.getMenuCmp(), - IndicatorSeparator: () => null, - ValueContainer: model.getValueContainerCmp(), - MultiValueLabel: model.getMultiValueLabelCmp(), - SingleValue: model.getSingleValueCmp() - }, - - // A shared div is created lazily here as needed, appended to the body, and assigned - // a high z-index to ensure options menus render over dialogs or other modals. - menuPortalTarget: model.getOrCreatePortalDiv(), - - inputId: props.id, - classNamePrefix: 'xh-select', - theme: model.getThemeConfig(), - - onBlur: model.onBlur, - onChange: model.onSelectChange, - onFocus: model.onFocus, - filterOption: model.filterOption, +const cmp = hoistCmp.factory>( + ({model, className, ...props}, ref) => { + const {width, height, ...layoutProps} = getLayoutProps(props), + rsProps: PlainObject = { + value: model.renderValue, + + autoFocus: props.autoFocus, + formatOptionLabel: model.formatOptionLabel, + isDisabled: props.disabled, + isMulti: props.enableMulti, + closeMenuOnSelect: props.closeMenuOnSelect, + hideSelectedOptions: model.hideSelectedOptions, + maxMenuHeight: props.maxMenuHeight, + + // Explicit false ensures consistent default for single and multi-value instances. + isClearable: withDefault(props.enableClear, false), + menuPlacement: withDefault(props.menuPlacement, 'auto'), + noOptionsMessage: model.noOptionsMessageFn, + openMenuOnFocus: props.openMenuOnFocus, + placeholder: withDefault(props.placeholder, 'Select...'), + tabIndex: props.tabIndex, + + // Minimize (or hide) bulky dropdown + components: { + DropdownIndicator: model.getDropdownIndicatorCmp(), + ClearIndicator: model.getClearIndicatorCmp(), + Menu: model.getMenuCmp(), + IndicatorSeparator: () => null, + ValueContainer: model.getValueContainerCmp(), + MultiValueLabel: model.getMultiValueLabelCmp(), + SingleValue: model.getSingleValueCmp() + }, + + // A shared div is created lazily here as needed, appended to the body, and assigned + // a high z-index to ensure options menus render over dialogs or other modals. + menuPortalTarget: model.getOrCreatePortalDiv(), + + inputId: props.id, + classNamePrefix: 'xh-select', + theme: model.getThemeConfig(), + + onBlur: model.onBlur, + onChange: model.onSelectChange, + onFocus: model.onFocus, + filterOption: model.filterOption, + + ref: model.reactSelectRef + }; - ref: model.reactSelectRef - }; + if (model.manageInputValue) { + rsProps.inputValue = model.inputValue || ''; + rsProps.onInputChange = model.onInputChange; + rsProps.controlShouldRenderValue = !model.hasFocus; + rsProps.onMenuOpen = () => { + wait().then(() => { + const selectedEl = document.getElementsByClassName( + 'xh-select__option--is-selected' + )[0]; + selectedEl?.scrollIntoView({block: 'end'}); + }); + }; + } - if (model.manageInputValue) { - rsProps.inputValue = model.inputValue || ''; - rsProps.onInputChange = model.onInputChange; - rsProps.controlShouldRenderValue = !model.hasFocus; - rsProps.onMenuOpen = () => { - wait().then(() => { - const selectedEl = document.getElementsByClassName( - 'xh-select__option--is-selected' - )[0]; - selectedEl?.scrollIntoView({block: 'end'}); - }); - }; - } + if (model.asyncMode) { + rsProps.loadOptions = model.doQueryAsync; + rsProps.loadingMessage = model.loadingMessageFn; + if (model.renderValue) rsProps.defaultOptions = [model.renderValue]; + } else { + rsProps.options = model.internalOptions; + rsProps.isSearchable = model.filterMode; + } - if (model.asyncMode) { - rsProps.loadOptions = model.doQueryAsync; - rsProps.loadingMessage = model.loadingMessageFn; - if (model.renderValue) rsProps.defaultOptions = [model.renderValue]; - } else { - rsProps.options = model.internalOptions; - rsProps.isSearchable = model.filterMode; - } + if (model.creatableMode) { + rsProps.formatCreateLabel = model.createMessageFn; + } - if (model.creatableMode) { - rsProps.formatCreateLabel = model.createMessageFn; - } + if (props.menuWidth) { + rsProps.styles = { + menu: provided => ({...provided, width: `${props.menuWidth}px`}), + ...props.rsOptions?.styles + }; + } - if (props.menuWidth) { - rsProps.styles = { - menu: provided => ({...provided, width: `${props.menuWidth}px`}), - ...props.rsOptions?.styles - }; + const factory = model.getSelectFactory(); + mergeDeep(rsProps, props.rsOptions); + + return box({ + item: factory(rsProps), + className: classNames(className, height ? 'xh-select--has-height' : null), + onKeyDown: e => { + // Esc. and Enter can be listened for by parents -- stop the keydown event + // propagation only if react-select already likely to have used for menu management. + // note: menuIsOpen will be undefined on AsyncSelect due to a react-select bug. + const menuIsOpen = model.reactSelect?.state?.menuIsOpen; + if (menuIsOpen && (e.key === 'Escape' || e.key === 'Enter')) { + e.stopPropagation(); + } + }, + onMouseDown: e => { + // Some internal elements, like the dropdown indicator and the rendered single value, + // fire 'mousedown' events. These can bubble and inadvertently close Popovers that + // contain Selects. + const target = e?.target as HTMLElement; + if (target && elemWithin(target, 'bp5-popover')) { + e.stopPropagation(); + } + }, + testId: props.testId, + ...layoutProps, + width: withDefault(width, 200), + height: height, + ref + }); } - - const factory = model.getSelectFactory(); - mergeDeep(rsProps, props.rsOptions); - - return box({ - item: factory(rsProps), - className: classNames(className, height ? 'xh-select--has-height' : null), - onKeyDown: e => { - // Esc. and Enter can be listened for by parents -- stop the keydown event - // propagation only if react-select already likely to have used for menu management. - // note: menuIsOpen will be undefined on AsyncSelect due to a react-select bug. - const menuIsOpen = model.reactSelect?.state?.menuIsOpen; - if (menuIsOpen && (e.key === 'Escape' || e.key === 'Enter')) { - e.stopPropagation(); - } - }, - onMouseDown: e => { - // Some internal elements, like the dropdown indicator and the rendered single value, - // fire 'mousedown' events. These can bubble and inadvertently close Popovers that - // contain Selects. - const target = e?.target as HTMLElement; - if (target && elemWithin(target, 'bp5-popover')) { - e.stopPropagation(); - } - }, - testId: props.testId, - ...layoutProps, - width: withDefault(width, 200), - height: height, - ref - }); -}); +); diff --git a/desktop/cmp/input/Slider.ts b/desktop/cmp/input/Slider.ts index 61560ce0cc..e6b72cc4ff 100644 --- a/desktop/cmp/input/Slider.ts +++ b/desktop/cmp/input/Slider.ts @@ -10,7 +10,7 @@ import { } from '@blueprintjs/core'; import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {box} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, LayoutProps, Some} from '@xh/hoist/core'; +import {DefaultHoistProps, hoistCmp, LayoutProps, Some} from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {rangeSlider as bpRangeSlider, slider as bpSlider} from '@xh/hoist/kit/blueprint'; import {throwIf, withDefault} from '@xh/hoist/utils/js'; @@ -19,7 +19,7 @@ import {isArray} from 'lodash'; import {ReactNode} from 'react'; import './Slider.scss'; -export interface SliderProps extends HoistProps, HoistInputProps, LayoutProps { +export interface SliderProps extends Omit, 'tabIndex'>, LayoutProps { value?: Some; /** Maximum value */ @@ -66,7 +66,7 @@ export const [Slider, slider] = hoistCmp.withFactory({ //----------------------- // Implementation //----------------------- -class SliderInputModel extends HoistInputModel { +class SliderInputModel extends HoistInputModel { override xhImpl = true; get sliderHandle(): HTMLElement { @@ -82,41 +82,46 @@ class SliderInputModel extends HoistInputModel { } } -const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { - const {width, ...layoutProps} = getLayoutProps(props); - - throwIf(props.labelStepSize <= 0, 'Error in Slider: labelStepSize must be greater than zero.'); - - // Set default left / right padding - if (!layoutProps.padding && !layoutProps.paddingLeft) layoutProps.paddingLeft = 20; - if (!layoutProps.padding && !layoutProps.paddingRight) layoutProps.paddingRight = 20; - - const sliderProps: BpRangeSliderProps | BpSliderProps = { - value: model.renderValue, - - disabled: props.disabled, - labelRenderer: props.labelRenderer, - labelStepSize: props.labelStepSize, - max: props.max, - min: props.min, - showTrackFill: props.showTrackFill, - stepSize: props.stepSize, - vertical: props.vertical, - - onChange: val => model.noteValueChange(val) - }; - - return box({ - item: isArray(model.renderValue) - ? bpRangeSlider(sliderProps as BpRangeSliderProps) - : bpSlider(sliderProps as BpSliderProps), - - ...layoutProps, - width: withDefault(width, 200), - className, - - onBlur: model.onBlur, - onFocus: model.onFocus, - ref - }); -}); +const cmp = hoistCmp.factory>( + ({model, className, ...props}, ref) => { + const {width, ...layoutProps} = getLayoutProps(props); + + throwIf( + props.labelStepSize <= 0, + 'Error in Slider: labelStepSize must be greater than zero.' + ); + + // Set default left / right padding + if (!layoutProps.padding && !layoutProps.paddingLeft) layoutProps.paddingLeft = 20; + if (!layoutProps.padding && !layoutProps.paddingRight) layoutProps.paddingRight = 20; + + const sliderProps: BpRangeSliderProps | BpSliderProps = { + value: model.renderValue, + + disabled: props.disabled, + labelRenderer: props.labelRenderer, + labelStepSize: props.labelStepSize, + max: props.max, + min: props.min, + showTrackFill: props.showTrackFill, + stepSize: props.stepSize, + vertical: props.vertical, + + onChange: val => model.noteValueChange(val) + }; + + return box({ + item: isArray(model.renderValue) + ? bpRangeSlider(sliderProps as BpRangeSliderProps) + : bpSlider(sliderProps as BpSliderProps), + + ...layoutProps, + width: withDefault(width, 200), + className, + + onBlur: model.onBlur, + onFocus: model.onFocus, + ref + }); + } +); diff --git a/desktop/cmp/input/SwitchInput.ts b/desktop/cmp/input/SwitchInput.ts index 8bba00a602..4c608f13a9 100644 --- a/desktop/cmp/input/SwitchInput.ts +++ b/desktop/cmp/input/SwitchInput.ts @@ -5,14 +5,14 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp, HoistProps, HSide, StyleProps} from '@xh/hoist/core'; +import {DefaultHoistProps, hoistCmp, HSide, StyleProps} from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {switchControl} from '@xh/hoist/kit/blueprint'; import {TEST_ID, withDefault} from '@xh/hoist/utils/js'; import {ReactNode} from 'react'; import './SwitchInput.scss'; -export interface SwitchInputProps extends HoistProps, HoistInputProps, StyleProps { +export interface SwitchInputProps extends HoistInputProps, StyleProps { value?: boolean; /** True if the control should appear as an inline element (defaults to true). */ @@ -42,31 +42,33 @@ export const [SwitchInput, switchInput] = hoistCmp.withFactory //----------------------- // Implementation //----------------------- -class SwitchInputModel extends HoistInputModel { +class SwitchInputModel extends HoistInputModel { override xhImpl = true; } -const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { - const labelSide = withDefault(props.labelSide, 'right'); +const cmp = hoistCmp.factory>( + ({model, className, ...props}, ref) => { + const labelSide = withDefault(props.labelSide, 'right'); - return switchControl({ - checked: !!model.renderValue, + return switchControl({ + checked: !!model.renderValue, - alignIndicator: labelSide === 'left' ? 'right' : 'left', - disabled: props.disabled, - inline: withDefault(props.inline, true), - label: props.label, - style: props.style, - tabIndex: props.tabIndex, + alignIndicator: labelSide === 'left' ? 'right' : 'left', + disabled: props.disabled, + inline: withDefault(props.inline, true), + label: props.label, + style: props.style, + tabIndex: props.tabIndex, - id: props.id, - className, + id: props.id, + className, - [TEST_ID]: props.testId, - onBlur: model.onBlur, - onFocus: model.onFocus, - onChange: e => model.noteValueChange(e.target.checked), - inputRef: model.inputRef, - ref - }); -}); + [TEST_ID]: props.testId, + onBlur: model.onBlur, + onFocus: model.onFocus, + onChange: e => model.noteValueChange(e.target.checked), + inputRef: model.inputRef, + ref + }); + } +); diff --git a/desktop/cmp/input/TextArea.ts b/desktop/cmp/input/TextArea.ts index 19de356e42..064db163db 100644 --- a/desktop/cmp/input/TextArea.ts +++ b/desktop/cmp/input/TextArea.ts @@ -6,7 +6,7 @@ */ import composeRefs from '@seznam/compose-react-refs'; import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp, HoistProps, LayoutProps, StyleProps} from '@xh/hoist/core'; +import {hoistCmp, LayoutProps, StyleProps} from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {textArea as bpTextarea} from '@xh/hoist/kit/blueprint'; import {TEST_ID, withDefault} from '@xh/hoist/utils/js'; @@ -14,7 +14,10 @@ import {getLayoutProps} from '@xh/hoist/utils/react'; import {Ref} from 'react'; import './TextArea.scss'; -export interface TextAreaProps extends HoistProps, HoistInputProps, LayoutProps, StyleProps { +export interface TextAreaProps + extends HoistInputProps, + LayoutProps, + StyleProps { value?: string; /** True to focus the control on render. */ @@ -54,7 +57,7 @@ export const [TextArea, textArea] = hoistCmp.withFactory({ //----------------------- // Implementation //----------------------- -class TextAreaInputModel extends HoistInputModel { +class TextAreaInputModel extends HoistInputModel { override xhImpl = true; override get commitOnChange() { diff --git a/desktop/cmp/input/TextInput.ts b/desktop/cmp/input/TextInput.ts index b7ff198b9c..342674a90a 100644 --- a/desktop/cmp/input/TextInput.ts +++ b/desktop/cmp/input/TextInput.ts @@ -7,7 +7,14 @@ import composeRefs from '@seznam/compose-react-refs'; import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {div} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, HSide, LayoutProps, StyleProps} from '@xh/hoist/core'; +import { + WithoutModelAndRef, + hoistCmp, + HoistProps, + HSide, + LayoutProps, + StyleProps +} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; import '@xh/hoist/desktop/register'; import {Icon} from '@xh/hoist/icon'; @@ -17,7 +24,7 @@ import {getLayoutProps} from '@xh/hoist/utils/react'; import {isEmpty} from 'lodash'; import {FocusEvent, KeyboardEventHandler, ReactElement, ReactNode, Ref} from 'react'; -export interface TextInputProps extends HoistProps, HoistInputProps, LayoutProps, StyleProps { +export interface TextInputProps extends HoistInputProps, LayoutProps, StyleProps { value?: string; /** @@ -87,7 +94,7 @@ export const [TextInput, textInput] = hoistCmp.withFactory({ //----------------------- // Implementation //----------------------- -export class TextInputModel extends HoistInputModel { +export class TextInputModel extends HoistInputModel { override xhImpl = true; override get commitOnChange() { @@ -113,57 +120,56 @@ export class TextInputModel extends HoistInputModel { this.noteFocused(); }; } - -const cmp = hoistCmp.factory( - ({model, className, ...props}, ref) => { - const {width, flex, ...layoutProps} = getLayoutProps(props); - - const isClearable = !isEmpty(model.internalValue); - - return div({ - item: inputGroup({ - value: model.renderValue || '', - - autoComplete: withDefault( - props.autoComplete, - props.type === 'password' ? 'new-password' : 'off' - ), - autoFocus: props.autoFocus, - disabled: props.disabled, - inputRef: composeRefs(model.inputRef as Ref, props.inputRef), - leftIcon: props.leftIcon, - placeholder: props.placeholder, - rightElement: - (props.rightElement as ReactElement) || - (props.enableClear && !props.disabled && isClearable ? clearButton() : null), - round: withDefault(props.round, false), - spellCheck: withDefault(props.spellCheck, false), - tabIndex: props.tabIndex, - type: props.type, - - id: props.id, - style: { - ...props.style, - ...layoutProps, - textAlign: withDefault(props.textAlign, 'left') - }, - [TEST_ID]: props.testId, - onChange: model.onChange, - onKeyDown: model.onKeyDown - }), - - className, +const cmp = hoistCmp.factory< + HoistProps & WithoutModelAndRef +>(({model, className, ...props}, ref) => { + const {width, flex, ...layoutProps} = getLayoutProps(props); + + const isClearable = !isEmpty(model.internalValue); + + return div({ + item: inputGroup({ + value: model.renderValue || '', + + autoComplete: withDefault( + props.autoComplete, + props.type === 'password' ? 'new-password' : 'off' + ), + autoFocus: props.autoFocus, + disabled: props.disabled, + inputRef: composeRefs(model.inputRef, props.inputRef), + leftIcon: props.leftIcon, + placeholder: props.placeholder, + rightElement: + (props.rightElement as ReactElement) || + (props.enableClear && !props.disabled && isClearable ? clearButton() : null), + round: withDefault(props.round, false), + spellCheck: withDefault(props.spellCheck, false), + tabIndex: props.tabIndex, + type: props.type, + + id: props.id, style: { - width: withDefault(width, 200), - flex: withDefault(flex, null) + ...props.style, + ...layoutProps, + textAlign: withDefault(props.textAlign, 'left') }, - - onBlur: model.onBlur, - onFocus: model.onFocus, - ref - }); - } -); + [TEST_ID]: props.testId, + onChange: model.onChange, + onKeyDown: model.onKeyDown + }), + + className, + style: { + width: withDefault(width, 200), + flex: withDefault(flex, null) + }, + + onBlur: model.onBlur, + onFocus: model.onFocus, + ref + }); +}); const clearButton = hoistCmp.factory(({model}) => button({ diff --git a/desktop/cmp/leftrightchooser/LeftRightChooser.ts b/desktop/cmp/leftrightchooser/LeftRightChooser.ts index 7217135592..5c81c86cdc 100644 --- a/desktop/cmp/leftrightchooser/LeftRightChooser.ts +++ b/desktop/cmp/leftrightchooser/LeftRightChooser.ts @@ -14,7 +14,9 @@ import './LeftRightChooser.scss'; import {LeftRightChooserModel} from './LeftRightChooserModel'; import {cloneDeep} from 'lodash'; -export interface LeftRightChooserProps extends HoistProps, BoxProps {} +export interface LeftRightChooserProps + extends HoistProps, + BoxProps {} /** * A component for moving a list of items between two arbitrary groups. By convention, the left diff --git a/desktop/cmp/panel/Panel.ts b/desktop/cmp/panel/Panel.ts index f554bd8fbb..6013d98905 100644 --- a/desktop/cmp/panel/Panel.ts +++ b/desktop/cmp/panel/Panel.ts @@ -34,7 +34,9 @@ import {resizeContainer} from './impl/ResizeContainer'; import './Panel.scss'; import {PanelModel} from './PanelModel'; -export interface PanelProps extends HoistProps, Omit { +export interface PanelProps + extends HoistProps, + Omit { /** True to style panel header (if displayed) with reduced padding and font-size. */ compactHeader?: boolean; @@ -201,7 +203,7 @@ export const [Panel, panel] = hoistCmp.withFactory({ } // 3) Prepare core layout with header above core. This is what layout props are trampolined to - let item = vbox({ + let item: ReactElement = vbox({ className: 'xh-panel__content', items: [ panelHeader({ diff --git a/desktop/cmp/panel/impl/ResizeContainer.ts b/desktop/cmp/panel/impl/ResizeContainer.ts index 06db1a5990..938d1e1665 100644 --- a/desktop/cmp/panel/impl/ResizeContainer.ts +++ b/desktop/cmp/panel/impl/ResizeContainer.ts @@ -6,14 +6,16 @@ */ import composeRefs from '@seznam/compose-react-refs'; import {box, hbox, vbox} from '@xh/hoist/cmp/layout'; -import {hoistCmp, useContextModel} from '@xh/hoist/core'; +import {hoistCmp, HoistPropsWithRef, TestSupportProps, useContextModel} from '@xh/hoist/core'; import {isString} from 'lodash'; -import {Children} from 'react'; +import {Children, ReactElement} from 'react'; import {PanelModel} from '../PanelModel'; import {dragger} from './dragger/Dragger'; import {splitter} from './Splitter'; -export const resizeContainer = hoistCmp.factory({ +export const resizeContainer = hoistCmp.factory< + HoistPropsWithRef & TestSupportProps +>({ displayName: 'ResizeContainer', model: false, className: 'xh-resizable', @@ -27,7 +29,7 @@ export const resizeContainer = hoistCmp.factory({ sizeIsPct = isString(size) && size.endsWith('%'); const boxSize = sizeIsPct ? `calc(100% - ${dragBarWidth})` : size; - let items = [collapsed ? box(child) : box({item: child, [dim]: boxSize})]; + let items: ReactElement[] = [collapsed ? box(child) : box({item: child, [dim]: boxSize})]; if (showSplitter) { const splitterCmp = splitter(); diff --git a/desktop/cmp/pinpad/impl/PinPad.ts b/desktop/cmp/pinpad/impl/PinPad.ts index dd9b4c02d7..1b07bdc6f7 100644 --- a/desktop/cmp/pinpad/impl/PinPad.ts +++ b/desktop/cmp/pinpad/impl/PinPad.ts @@ -6,8 +6,8 @@ */ import composeRefs from '@seznam/compose-react-refs'; import {div, frame, h1, hbox, p, span, vbox, vframe} from '@xh/hoist/cmp/layout'; -import {PinPadModel} from '@xh/hoist/cmp/pinpad'; -import {hoistCmp} from '@xh/hoist/core'; +import {PinPadModel, PinPadProps} from '@xh/hoist/cmp/pinpad'; +import {hoistCmp, uses} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; import '@xh/hoist/desktop/register'; import {Icon} from '@xh/hoist/icon/Icon'; @@ -20,16 +20,19 @@ import './PinPad.scss'; * * @internal */ -export function pinPadImpl({model, testId}, ref) { - return frame({ - ref: composeRefs(model.ref, ref), - item: vframe({ - className: 'xh-pinpad__frame', - items: [header(), display(), errorDisplay(), keypad()], - testId - }) - }); -} +export const pinPadImpl = hoistCmp.factory({ + model: uses(PinPadModel), + render({model, testId}, ref) { + return frame({ + ref: composeRefs(model.ref, ref), + item: vframe({ + className: 'xh-pinpad__frame', + items: [header(), display(), errorDisplay(), keypad()], + testId + }) + }); + } +}); const header = hoistCmp.factory(({model}) => div({ diff --git a/desktop/cmp/rest/RestGrid.ts b/desktop/cmp/rest/RestGrid.ts index 46b6d145dc..cddb07a90e 100644 --- a/desktop/cmp/rest/RestGrid.ts +++ b/desktop/cmp/rest/RestGrid.ts @@ -7,20 +7,19 @@ import {grid} from '@xh/hoist/cmp/grid'; import {fragment} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, PlainObject, Some, uses} from '@xh/hoist/core'; +import {hoistCmp, HoistProps, PlainObject, Some, uses, WithoutModelAndRef} from '@xh/hoist/core'; import {MaskProps} from '@xh/hoist/desktop/cmp/mask'; import {panel, PanelProps} from '@xh/hoist/desktop/cmp/panel'; import '@xh/hoist/desktop/register'; import {getTestId} from '@xh/hoist/utils/js'; import {cloneElement, isValidElement, ReactElement, ReactNode} from 'react'; - import {restForm} from './impl/RestForm'; import {restGridToolbar} from './impl/RestGridToolbar'; import {RestGridModel} from './RestGridModel'; export interface RestGridProps - extends HoistProps, - Omit { + extends HoistProps, + WithoutModelAndRef { /** * This constitutes an 'escape hatch' for applications that need to get to the underlying * ag-Grid API. It should be used with care. Settings made here might be overwritten and/or diff --git a/desktop/cmp/rest/impl/RestFormModel.ts b/desktop/cmp/rest/impl/RestFormModel.ts index ddd017acdb..49c5e2acf2 100644 --- a/desktop/cmp/rest/impl/RestFormModel.ts +++ b/desktop/cmp/rest/impl/RestFormModel.ts @@ -33,7 +33,7 @@ export class RestFormModel extends HoistModel { @observable types: PlainObject = {}; - dialogRef = createRef(); + dialogRef = createRef(); get unit() { return this.parent.unit; diff --git a/desktop/cmp/toolbar/Toolbar.ts b/desktop/cmp/toolbar/Toolbar.ts index c87cbebc44..340f4bbd29 100644 --- a/desktop/cmp/toolbar/Toolbar.ts +++ b/desktop/cmp/toolbar/Toolbar.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {filler, fragment, hbox, vbox} from '@xh/hoist/cmp/layout'; -import {BoxProps, hoistCmp, HoistProps} from '@xh/hoist/core'; +import {BoxProps, hoistCmp, HoistPropsWithRef} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; import '@xh/hoist/desktop/register'; import {Icon} from '@xh/hoist/icon'; @@ -18,7 +18,7 @@ import {Children, ReactNode} from 'react'; import './Toolbar.scss'; import {toolbarSeparator} from './ToolbarSep'; -export interface ToolbarProps extends HoistProps, BoxProps { +export interface ToolbarProps extends HoistPropsWithRef, BoxProps { /** Set to true to style toolbar with reduced height and font-size. */ compact?: boolean; @@ -49,6 +49,7 @@ export interface ToolbarProps extends HoistProps, BoxProps { * A toolbar with built-in styling and padding. * In horizontal toolbars, items which overflow can be collapsed into a drop-down menu. */ + export const [Toolbar, toolbar] = hoistCmp.withFactory({ displayName: 'Toolbar', model: false, diff --git a/desktop/cmp/treemap/SplitTreeMap.ts b/desktop/cmp/treemap/SplitTreeMap.ts index dbd5bf7eaf..6469cbc363 100644 --- a/desktop/cmp/treemap/SplitTreeMap.ts +++ b/desktop/cmp/treemap/SplitTreeMap.ts @@ -18,7 +18,9 @@ import './SplitTreeMap.scss'; import {SplitTreeMapModel} from './SplitTreeMapModel'; import {treeMap} from './TreeMap'; -export interface SplitTreeMapProps extends HoistProps, BoxProps {} +export interface SplitTreeMapProps + extends HoistProps, + BoxProps {} /** * A component which divides data across two TreeMaps. diff --git a/desktop/cmp/treemap/TreeMap.ts b/desktop/cmp/treemap/TreeMap.ts index 946995e9aa..4fd3061933 100644 --- a/desktop/cmp/treemap/TreeMap.ts +++ b/desktop/cmp/treemap/TreeMap.ts @@ -37,7 +37,10 @@ import {mergeDeep} from '@xh/hoist/utils/js'; import './TreeMap.scss'; import {TreeMapModel} from './TreeMapModel'; -export interface TreeMapProps extends HoistProps, LayoutProps, TestSupportProps {} +export interface TreeMapProps + extends HoistProps, + LayoutProps, + TestSupportProps {} /** * Component for rendering a TreeMap. diff --git a/icon/Icon.ts b/icon/Icon.ts index 57264416ae..56344bc33b 100644 --- a/icon/Icon.ts +++ b/icon/Icon.ts @@ -12,9 +12,11 @@ import {last, pickBy, split, toLower} from 'lodash'; import {iconCmp} from './impl/IconCmp'; import {enhanceFaClasses, iconHtml} from './impl/IconHtml'; import {ReactElement} from 'react'; -import {HoistProps, Intent, Thunkable} from '@xh/hoist/core'; +import {HoistProps, Intent, Thunkable, WithoutModelAndRef} from '@xh/hoist/core'; -export interface IconProps extends HoistProps, Partial> { +export interface IconProps + extends WithoutModelAndRef, + Partial> { /** Name of the icon in FontAwesome. */ iconName?: string; diff --git a/mobile/cmp/button/Button.ts b/mobile/cmp/button/Button.ts index 8d7b2af334..9a341f6b3a 100644 --- a/mobile/cmp/button/Button.ts +++ b/mobile/cmp/button/Button.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {hspacer} from '@xh/hoist/cmp/layout'; -import {LayoutProps, StyleProps, hoistCmp, HoistModel, HoistProps, Intent} from '@xh/hoist/core'; +import {LayoutProps, StyleProps, hoistCmp, Intent, HoistPropsWithRef} from '@xh/hoist/core'; import {button as onsenButton} from '@xh/hoist/kit/onsen'; import '@xh/hoist/mobile/register'; import {splitLayoutProps} from '@xh/hoist/utils/react'; @@ -13,10 +13,7 @@ import classNames from 'classnames'; import {ReactNode, ReactElement, MouseEvent} from 'react'; import './Button.scss'; -export interface ButtonProps - extends HoistProps, - LayoutProps, - StyleProps { +export interface ButtonProps extends HoistPropsWithRef, LayoutProps, StyleProps { active?: boolean; disabled?: boolean; icon?: ReactElement; diff --git a/mobile/cmp/button/ButtonGroup.ts b/mobile/cmp/button/ButtonGroup.ts index fe0648baf8..1af9951d4d 100644 --- a/mobile/cmp/button/ButtonGroup.ts +++ b/mobile/cmp/button/ButtonGroup.ts @@ -5,13 +5,13 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {hbox} from '@xh/hoist/cmp/layout'; -import {BoxProps, hoistCmp, HoistProps, Intent, XH} from '@xh/hoist/core'; +import {BoxProps, hoistCmp, HoistPropsWithRef, Intent, XH} from '@xh/hoist/core'; import {Button, ButtonProps} from '@xh/hoist/mobile/cmp/button'; import '@xh/hoist/mobile/register'; import {Children, cloneElement, isValidElement} from 'react'; import './ButtonGroup.scss'; -export interface ButtonGroupProps extends HoistProps, BoxProps { +export interface ButtonGroupProps extends HoistPropsWithRef, BoxProps { intent?: Intent; minimal?: boolean; outlined?: boolean; diff --git a/mobile/cmp/button/RefreshButton.ts b/mobile/cmp/button/RefreshButton.ts index a9f66130e2..0d98c147cc 100644 --- a/mobile/cmp/button/RefreshButton.ts +++ b/mobile/cmp/button/RefreshButton.ts @@ -4,34 +4,45 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {hoistCmp, HoistModel, RefreshContextModel, useContextModel} from '@xh/hoist/core'; +import { + hoistCmp, + HoistModel, + HoistPropsWithRef, + RefreshContextModel, + useContextModel, + WithoutModelAndRef +} from '@xh/hoist/core'; import {Icon} from '@xh/hoist/icon'; import {button, ButtonProps} from '@xh/hoist/mobile/cmp/button'; import '@xh/hoist/mobile/register'; import {errorIf} from '@xh/hoist/utils/js'; -export type RefreshButtonProps = ButtonProps; +export interface RefreshButtonProps + extends HoistPropsWithRef, + WithoutModelAndRef { + target?: HoistModel; +} /** * Convenience Button preconfigured for use as a trigger for a refresh operation. * - * If a model is provided it will be directly refreshed. Alternatively an onClick handler + * If a target is provided it will be directly refreshed. Alternatively an onClick handler * may be provided. If neither of these props are provided, the contextual RefreshContextModel * for this button will be used. */ export const [RefreshButton, refreshButton] = hoistCmp.withFactory({ displayName: 'RefreshButton', - model: false, // For consistency with all other buttons -- the model prop here could be replaced by 'target' + model: false, - render({model, icon = Icon.sync(), onClick, ...props}) { + render({target, icon = Icon.sync(), onClick, ...props}) { const refreshContextModel = useContextModel(RefreshContextModel); if (!onClick) { errorIf( - model && !model.loadSupport, + target && !target.loadSupport, 'Models provided to RefreshButton must enable LoadSupport.' ); - model = model ?? refreshContextModel; + const model = target ?? refreshContextModel; onClick = model ? () => model.refreshAsync() : null; } diff --git a/mobile/cmp/error/ErrorMessage.ts b/mobile/cmp/error/ErrorMessage.ts index 1db540a42f..7b53e24ec0 100644 --- a/mobile/cmp/error/ErrorMessage.ts +++ b/mobile/cmp/error/ErrorMessage.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {div, filler, frame, hbox, p} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps} from '@xh/hoist/core'; +import {hoistCmp, HoistModel, HoistProps} from '@xh/hoist/core'; import {button, ButtonProps} from '@xh/hoist/mobile/cmp/button'; import '@xh/hoist/mobile/register'; import {isNil, isString} from 'lodash'; @@ -14,7 +14,7 @@ import {isValidElement, ReactNode, MouseEvent} from 'react'; import './ErrorMessage.scss'; import {Icon} from '@xh/hoist/icon'; -export interface ErrorMessageProps extends HoistProps { +export interface ErrorMessageProps extends HoistProps { /** * If provided, will render a "Retry" button that calls this function. * Use `actionButtonProps` for further control over this button. diff --git a/mobile/cmp/form/FormField.ts b/mobile/cmp/form/FormField.ts index ab56fba8e6..933e40c5f1 100644 --- a/mobile/cmp/form/FormField.ts +++ b/mobile/cmp/form/FormField.ts @@ -7,7 +7,7 @@ import composeRefs from '@seznam/compose-react-refs/composeRefs'; import {FieldModel, FormContext, FormContextType, BaseFormFieldProps} from '@xh/hoist/cmp/form'; import {box, div, span} from '@xh/hoist/cmp/layout'; -import {DefaultHoistProps, hoistCmp, HoistProps, TestSupportProps, uses, XH} from '@xh/hoist/core'; +import {hoistCmp, HoistProps, PlainObject, TestSupportProps, uses, XH} from '@xh/hoist/core'; import {fmtDate, fmtDateTime, fmtNumber} from '@xh/hoist/format'; import {label as labelCmp} from '@xh/hoist/mobile/cmp/input'; import '@xh/hoist/mobile/register'; @@ -105,9 +105,9 @@ export const [FormField, formField] = hoistCmp.withFactory({ let childEl = readonly || !child - ? readonlyChild({model, readonlyRenderer}) + ? readonlyChild({fieldModel: model, readonlyRenderer}) : editableChild({ - model, + fieldModel: model, child, childIsSizeable, disabled, @@ -156,34 +156,35 @@ export const [FormField, formField] = hoistCmp.withFactory({ } }); -interface ReadonlyChildProps extends HoistProps, TestSupportProps { +interface ReadonlyChildProps extends HoistProps, TestSupportProps { + fieldModel: FieldModel; readonlyRenderer: (v: any, model: FieldModel) => ReactNode; } const readonlyChild = hoistCmp.factory({ model: false, - render({model, readonlyRenderer}) { - const value = model ? model['value'] : null; + render({fieldModel, readonlyRenderer}) { + const value = fieldModel ? fieldModel['value'] : null; return div({ className: 'xh-form-field-readonly-display', - item: readonlyRenderer(value, model) + item: readonlyRenderer(value, fieldModel) }); } }); -const editableChild = hoistCmp.factory({ +const editableChild = hoistCmp.factory({ model: false, - render({model, child, childIsSizeable, disabled, commitOnChange, width, height, flex}) { + render({child, childIsSizeable, disabled, commitOnChange, fieldModel, width, height, flex}) { const {props} = child; // Overrides -- be sure not to clobber selected properties on child - const overrides: DefaultHoistProps = { - model, + const overrides: PlainObject = { + model: fieldModel, bind: 'value', disabled: props.disabled || disabled, - ref: composeRefs(model?.boundInputRef, child.ref) + ref: composeRefs(fieldModel?.boundInputRef, child.ref) }; // If FormField is sized and item doesn't specify its own dimensions, diff --git a/mobile/cmp/grid/impl/ColChooser.ts b/mobile/cmp/grid/impl/ColChooser.ts index 3281a96274..ae9d56e5f1 100644 --- a/mobile/cmp/grid/impl/ColChooser.ts +++ b/mobile/cmp/grid/impl/ColChooser.ts @@ -4,8 +4,17 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ +import {Column} from '@xh/hoist/cmp/grid'; import {div, filler, placeholder as placeholderCmp} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistModel, HoistProps, lookup, useLocalModel, uses} from '@xh/hoist/core'; +import { + DefaultHoistProps, + hoistCmp, + HoistModel, + HoistProps, + lookup, + useLocalModel, + uses +} from '@xh/hoist/core'; import {Icon} from '@xh/hoist/icon'; import {dragDropContext, draggable, droppable} from '@xh/hoist/kit/react-beautiful-dnd'; import {button} from '@xh/hoist/mobile/cmp/button'; @@ -15,6 +24,7 @@ import '@xh/hoist/mobile/register'; import classNames from 'classnames'; import './ColChooser.scss'; import {isEmpty} from 'lodash'; +import {ReactNode} from 'react'; import {ColChooserModel} from './ColChooserModel'; export interface ColChooserProps extends HoistProps {} @@ -133,7 +143,12 @@ export const [ColChooser, colChooser] = hoistCmp.withFactory({ //------------------------ // Implementation //------------------------ -const columnList = hoistCmp.factory({ +interface ColumnListProps extends HoistProps { + cols: Column[]; + placeholder: ReactNode; +} + +const columnList = hoistCmp.factory({ render({cols, placeholder, className, ...props}, ref) { return div({ className: classNames('xh-col-chooser__list', className), @@ -168,7 +183,7 @@ const draggableRow = hoistCmp.factory({ } }); -const row = hoistCmp.factory({ +const row = hoistCmp.factory>({ render({model, col, isDragging, ...props}, ref) { if (!col) return null; diff --git a/mobile/cmp/grouping/GroupingChooser.ts b/mobile/cmp/grouping/GroupingChooser.ts index 636f677ebc..c43fce3225 100644 --- a/mobile/cmp/grouping/GroupingChooser.ts +++ b/mobile/cmp/grouping/GroupingChooser.ts @@ -6,7 +6,7 @@ */ import {GroupingChooserModel} from '@xh/hoist/cmp/grouping'; import {box, div, filler, hbox, placeholder, span, vbox, vframe} from '@xh/hoist/cmp/layout'; -import {hoistCmp, uses} from '@xh/hoist/core'; +import {hoistCmp, HoistProps, uses, WithoutModelAndRef} from '@xh/hoist/core'; import {Icon} from '@xh/hoist/icon'; import {dragDropContext, draggable, droppable} from '@xh/hoist/kit/react-beautiful-dnd'; import {button, ButtonProps} from '@xh/hoist/mobile/cmp/button'; @@ -19,7 +19,9 @@ import {compact, isEmpty, sortBy} from 'lodash'; import './GroupingChooser.scss'; -export interface GroupingChooserProps extends ButtonProps { +export interface GroupingChooserProps + extends WithoutModelAndRef, + HoistProps { /** Text to represent empty state (i.e. value = null or [])*/ emptyText?: string; /** Title for popover (default "GROUP BY") or null to suppress. */ diff --git a/mobile/cmp/header/AppBar.ts b/mobile/cmp/header/AppBar.ts index 2ae2322522..91dec15658 100644 --- a/mobile/cmp/header/AppBar.ts +++ b/mobile/cmp/header/AppBar.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {div} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, HSide, useContextModel, XH} from '@xh/hoist/core'; +import {hoistCmp, HoistPropsWithRef, HSide, useContextModel, XH} from '@xh/hoist/core'; import { button, navigatorBackButton, @@ -20,7 +20,7 @@ import {ReactElement, ReactNode} from 'react'; import './AppBar.scss'; import {appMenuButton, AppMenuButtonProps} from './AppMenuButton'; -export interface AppBarProps extends HoistProps { +export interface AppBarProps extends HoistPropsWithRef { /** App icon to display to the left of the title. */ icon?: ReactElement; diff --git a/mobile/cmp/input/ButtonGroupInput.ts b/mobile/cmp/input/ButtonGroupInput.ts index 44613c75ca..57ff8c9061 100644 --- a/mobile/cmp/input/ButtonGroupInput.ts +++ b/mobile/cmp/input/ButtonGroupInput.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp, XH, HoistProps} from '@xh/hoist/core'; +import {DefaultHoistProps, hoistCmp, WithoutModelAndRef, XH} from '@xh/hoist/core'; import {Button, buttonGroup, ButtonGroupProps, ButtonProps} from '@xh/hoist/mobile/cmp/button'; import '@xh/hoist/mobile/register'; import {throwIf, warnIf, withDefault} from '@xh/hoist/utils/js'; @@ -14,7 +14,9 @@ import {castArray, isEmpty, without} from 'lodash'; import {Children, cloneElement, isValidElement, ReactNode} from 'react'; import './ButtonGroupInput.scss'; -export interface ButtonGroupInputProps extends HoistProps, HoistInputProps, ButtonGroupProps { +export interface ButtonGroupInputProps + extends HoistInputProps, + WithoutModelAndRef { /** * True to allow buttons to be unselected (aka inactivated). Used when enableMulti is false. * Defaults to false. @@ -48,7 +50,7 @@ export const [ButtonGroupInput, buttonGroupInput] = hoistCmp.withFactory { override xhImpl = true; get enableMulti() { @@ -84,45 +86,47 @@ class ButtonGroupInputModel extends HoistInputModel { } } -const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { - const { - children, - disabled, - enableClear, - enableMulti, - tabIndex = 0, - ...rest - } = getNonLayoutProps(props); - - const buttons = Children.map(children as ReactNode[], button => { - if (!button) return null; - - if (!isValidElement(button) || button.type !== Button) { - throw XH.exception('ButtonGroupInput child must be a Button.'); - } - - const {value} = button.props, - btnDisabled = disabled || button.props.disabled; - - throwIf(value == null, 'ButtonGroupInput child must declare a non-null value'); - - const isActive = model.isActive(value); - - return cloneElement(button, { - active: isActive, - disabled: withDefault(btnDisabled, false), - onClick: () => model.onButtonClick(value) - } as ButtonProps); - }); - - return buttonGroup({ - items: buttons, - tabIndex, - onBlur: model.onBlur, - onFocus: model.onFocus, - ...rest, - ...getLayoutProps(props), - className, - ref - }); -}); +const cmp = hoistCmp.factory>( + ({model, className, ...props}, ref) => { + const { + children, + disabled, + enableClear, + enableMulti, + tabIndex = 0, + ...rest + } = getNonLayoutProps(props); + + const buttons = Children.map(children as ReactNode[], button => { + if (!button) return null; + + if (!isValidElement(button) || button.type !== Button) { + throw XH.exception('ButtonGroupInput child must be a Button.'); + } + + const {value} = button.props, + btnDisabled = disabled || button.props.disabled; + + throwIf(value == null, 'ButtonGroupInput child must declare a non-null value'); + + const isActive = model.isActive(value); + + return cloneElement(button, { + active: isActive, + disabled: withDefault(btnDisabled, false), + onClick: () => model.onButtonClick(value) + } as ButtonProps); + }); + + return buttonGroup({ + items: buttons, + tabIndex, + onBlur: model.onBlur, + onFocus: model.onFocus, + ...rest, + ...getLayoutProps(props), + className, + ref + }); + } +); diff --git a/mobile/cmp/input/Checkbox.ts b/mobile/cmp/input/Checkbox.ts index 38a7ac17d1..ca445a6e13 100644 --- a/mobile/cmp/input/Checkbox.ts +++ b/mobile/cmp/input/Checkbox.ts @@ -5,12 +5,12 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp, HoistProps} from '@xh/hoist/core'; +import {hoistCmp} from '@xh/hoist/core'; import {checkbox as onsenCheckbox} from '@xh/hoist/kit/onsen'; import '@xh/hoist/mobile/register'; import './Checkbox.scss'; -export interface CheckboxProps extends HoistProps, HoistInputProps { +export interface CheckboxProps extends HoistInputProps { value?: boolean; /** Onsen modifier string */ @@ -28,7 +28,7 @@ export const [Checkbox, checkbox] = hoistCmp.withFactory({ } }); -class CheckboxInputModel extends HoistInputModel { +class CheckboxInputModel extends HoistInputModel { override xhImpl = true; } diff --git a/mobile/cmp/input/CheckboxButton.ts b/mobile/cmp/input/CheckboxButton.ts index 13ef248323..9af765d6e4 100644 --- a/mobile/cmp/input/CheckboxButton.ts +++ b/mobile/cmp/input/CheckboxButton.ts @@ -6,13 +6,15 @@ */ import '@xh/hoist/mobile/register'; import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp} from '@xh/hoist/core'; +import {hoistCmp, HoistProps, WithoutModelAndRef} from '@xh/hoist/core'; import {Icon} from '@xh/hoist/icon'; import './CheckboxButton.scss'; import {button, ButtonProps} from '@xh/hoist/mobile/cmp/button'; import {withDefault} from '@xh/hoist/utils/js'; -export interface CheckboxButtonProps extends ButtonProps, HoistInputProps { +export interface CheckboxButtonProps + extends WithoutModelAndRef, + HoistInputProps { value?: boolean; } @@ -29,14 +31,17 @@ export const [CheckboxButton, checkboxButton] = hoistCmp.withFactory { override xhImpl = true; } //---------------------------------- // Implementation //---------------------------------- -const cmp = hoistCmp.factory(({model, text, ...props}, ref) => { +const cmp = hoistCmp.factory< + HoistProps & + WithoutModelAndRef +>(({model, text, ...props}, ref) => { const checked = !!model.renderValue; return button({ text: withDefault(text, model.getField()?.displayName), diff --git a/mobile/cmp/input/DateInput.ts b/mobile/cmp/input/DateInput.ts index 3af9e85de5..2075faac40 100644 --- a/mobile/cmp/input/DateInput.ts +++ b/mobile/cmp/input/DateInput.ts @@ -6,7 +6,14 @@ */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {div} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, StyleProps, LayoutProps, HSide, PlainObject} from '@xh/hoist/core'; +import { + hoistCmp, + StyleProps, + LayoutProps, + HSide, + PlainObject, + DefaultHoistProps +} from '@xh/hoist/core'; import {fmtDate} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {singleDatePicker} from '@xh/hoist/kit/react-dates'; @@ -19,7 +26,7 @@ import moment from 'moment'; import './DateInput.scss'; import {ReactElement} from 'react'; -export interface DateInputProps extends HoistProps, HoistInputProps, StyleProps, LayoutProps { +export interface DateInputProps extends HoistInputProps, StyleProps, LayoutProps { value?: Date | LocalDate; /** True to show a "clear" button aligned to the right of the control. Default false. */ @@ -90,7 +97,7 @@ export const [DateInput, dateInput] = hoistCmp.withFactory({ //--------------------------------- // Implementation //--------------------------------- -class DateInputModel extends HoistInputModel { +class DateInputModel extends HoistInputModel { override xhImpl = true; @observable popoverOpen = false; @@ -180,43 +187,45 @@ class DateInputModel extends HoistInputModel { } } -const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { - const layoutProps = getLayoutProps(props), - {renderValue} = model, - value = renderValue ? moment(renderValue) : null, - enableClear = withDefault(props.enableClear, false), - textAlign = withDefault(props.textAlign, 'left'), - leftIcon = withDefault(props.leftIcon, null), - rightIcon = withDefault(props.rightIcon, Icon.calendar()), - isOpen = model.popoverOpen && !props.disabled; - - return div({ - className, - items: [ - leftIcon, - singleDatePicker({ - date: value, - focused: isOpen, - onFocusChange: ({focused}) => model.setPopoverOpen(focused), - onDateChange: date => model.onDateChange(date), - initialVisibleMonth: () => model.initialMonth, - isOutsideRange: date => model.isOutsideRange(date), - withPortal: true, - noBorder: true, - numberOfMonths: 1, - displayFormat: model.getFormat(), - showClearDate: enableClear, - placeholder: props.placeholder, - - ...props.singleDatePickerProps - }), - rightIcon - ], - style: { - ...props.style, - ...layoutProps, - textAlign - }, - ref - }); -}); +const cmp = hoistCmp.factory>( + ({model, className, ...props}, ref) => { + const layoutProps = getLayoutProps(props), + {renderValue} = model, + value = renderValue ? moment(renderValue) : null, + enableClear = withDefault(props.enableClear, false), + textAlign = withDefault(props.textAlign, 'left'), + leftIcon = withDefault(props.leftIcon, null), + rightIcon = withDefault(props.rightIcon, Icon.calendar()), + isOpen = model.popoverOpen && !props.disabled; + + return div({ + className, + items: [ + leftIcon, + singleDatePicker({ + date: value, + focused: isOpen, + onFocusChange: ({focused}) => model.setPopoverOpen(focused), + onDateChange: date => model.onDateChange(date), + initialVisibleMonth: () => model.initialMonth, + isOutsideRange: date => model.isOutsideRange(date), + withPortal: true, + noBorder: true, + numberOfMonths: 1, + displayFormat: model.getFormat(), + showClearDate: enableClear, + placeholder: props.placeholder, + + ...props.singleDatePickerProps + }), + rightIcon + ], + style: { + ...props.style, + ...layoutProps, + textAlign + }, + ref + }); + } +); diff --git a/mobile/cmp/input/Label.ts b/mobile/cmp/input/Label.ts index a94a63bdc0..53d339a131 100644 --- a/mobile/cmp/input/Label.ts +++ b/mobile/cmp/input/Label.ts @@ -6,11 +6,11 @@ */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {div} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, StyleProps} from '@xh/hoist/core'; +import {DefaultHoistProps, hoistCmp, StyleProps} from '@xh/hoist/core'; import '@xh/hoist/mobile/register'; import './Label.scss'; -export interface LabelProps extends HoistProps, HoistInputProps, StyleProps {} +export interface LabelProps extends HoistInputProps, StyleProps {} /** * A simple label for a form. @@ -23,18 +23,20 @@ export const [Label, label] = hoistCmp.withFactory({ } }); -class LabelInputModel extends HoistInputModel { +class LabelInputModel extends HoistInputModel { override xhImpl = true; } //----------------------- // Implementation //----------------------- -const cmp = hoistCmp.factory(({model, className, style, width, children}, ref) => { - return div({ - className, - style: {...style, whiteSpace: 'nowrap', width}, - items: children, - ref - }); -}); +const cmp = hoistCmp.factory>( + ({className, style, width, children}, ref) => { + return div({ + className, + style: {...style, whiteSpace: 'nowrap', width}, + items: children, + ref + }); + } +); diff --git a/mobile/cmp/input/NumberInput.ts b/mobile/cmp/input/NumberInput.ts index cac8922ca0..af8b572ecf 100644 --- a/mobile/cmp/input/NumberInput.ts +++ b/mobile/cmp/input/NumberInput.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp, HoistProps, HSide, LayoutProps, StyleProps} from '@xh/hoist/core'; +import {hoistCmp, HSide, LayoutProps, StyleProps} from '@xh/hoist/core'; import {fmtNumber, Precision, ZeroPad} from '@xh/hoist/format'; import {input} from '@xh/hoist/kit/onsen'; import '@xh/hoist/mobile/register'; @@ -15,7 +15,7 @@ import {getLayoutProps} from '@xh/hoist/utils/react'; import {debounce, isNaN, isNil, isNumber, round} from 'lodash'; import './NumberInput.scss'; -export interface NumberInputProps extends HoistProps, HoistInputProps, StyleProps, LayoutProps { +export interface NumberInputProps extends HoistInputProps, StyleProps, LayoutProps { value?: number; /** True to commit on every change/keystroke, default false. */ @@ -87,7 +87,7 @@ export const [NumberInput, numberInput] = hoistCmp.withFactory //----------------------- // Implementation //----------------------- -class NumberInputModel extends HoistInputModel { +class NumberInputModel extends HoistInputModel { override xhImpl = true; static shorthandValidator = /((\.\d+)|(\d+(\.\d+)?))([kmb])\b/i; diff --git a/mobile/cmp/input/SearchInput.ts b/mobile/cmp/input/SearchInput.ts index b618bc2981..d84ee80f40 100644 --- a/mobile/cmp/input/SearchInput.ts +++ b/mobile/cmp/input/SearchInput.ts @@ -5,14 +5,14 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp, HoistProps, HSide} from '@xh/hoist/core'; +import {hoistCmp, HSide} from '@xh/hoist/core'; import {searchInput as onsenSearchInput} from '@xh/hoist/kit/onsen'; import '@xh/hoist/mobile/register'; import {withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps} from '@xh/hoist/utils/react'; import './SearchInput.scss'; -export interface SearchInputProps extends HoistProps, HoistInputProps { +export interface SearchInputProps extends HoistInputProps { value?: string; /** True to commit on every change/keystroke, default false. */ @@ -49,7 +49,7 @@ export const [SearchInput, searchInput] = hoistCmp.withFactory //----------------------- // Implementation //----------------------- -class SearchInputModel extends HoistInputModel { +class SearchInputModel extends HoistInputModel { override xhImpl = true; override get commitOnChange() { diff --git a/mobile/cmp/input/Select.ts b/mobile/cmp/input/Select.ts index d001e99a16..efb661dc28 100644 --- a/mobile/cmp/input/Select.ts +++ b/mobile/cmp/input/Select.ts @@ -6,7 +6,7 @@ */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {box, div, hbox, span} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, LayoutProps, PlainObject, SelectOption} from '@xh/hoist/core'; +import {DefaultHoistProps, hoistCmp, LayoutProps, PlainObject, SelectOption} from '@xh/hoist/core'; import {Icon} from '@xh/hoist/icon'; import { reactAsyncCreatableSelect, @@ -27,7 +27,7 @@ import {Children, ReactNode, ReactPortal} from 'react'; import ReactDom from 'react-dom'; import './Select.scss'; -export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps { +export interface SelectProps extends HoistInputProps, LayoutProps { /** * Function to return a "create a new option" string prompt. Requires `allowCreate` true. * Passed current query input. @@ -186,7 +186,7 @@ export const [Select, select] = hoistCmp.withFactory({ //----------------------- // Implementation //----------------------- -class SelectInputModel extends HoistInputModel { +class SelectInputModel extends HoistInputModel { override xhImpl = true; // Normalized collection of selectable options. Passed directly to synchronous select. @@ -587,97 +587,99 @@ class SelectInputModel extends HoistInputModel { } } -const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { - const {width, ...layoutProps} = getLayoutProps(props), - rsProps: PlainObject = { - value: model.renderValue, - - formatOptionLabel: model.formatOptionLabel, - isDisabled: props.disabled, - closeMenuOnSelect: props.closeMenuOnSelect, - hideSelectedOptions: model.hideSelectedOptions, - menuPlacement: withDefault(props.menuPlacement, 'auto'), - maxMenuHeight: props.maxMenuHeight, - noOptionsMessage: model.noOptionsMessageFn, - openMenuOnFocus: props.openMenuOnFocus || model.fullscreen, - placeholder: withDefault(props.placeholder, 'Select...'), - tabIndex: props.tabIndex, - menuShouldBlockScroll: true, - - // Minimize (or hide) bulky dropdown - components: { - DropdownIndicator: model.getDropdownIndicatorCmp(), - IndicatorSeparator: () => null - }, - - // A shared div is created lazily here as needed, appended to the body, and assigned - // a high z-index to ensure options menus render over dialogs or other modals. - menuPortalTarget: model.getOrCreatePortalDiv(), - - inputId: props.id, - classNamePrefix: 'xh-select', - theme: model.getThemeConfig(), - - onBlur: model.onBlur, - onChange: model.onSelectChange, - onFocus: model.onFocus, - filterOption: model.filterOption, - - ref: model.reactSelectRef - }; - - if (model.manageInputValue) { - rsProps.inputValue = model.inputValue || ''; - rsProps.onInputChange = model.onInputChange; - rsProps.controlShouldRenderValue = !model.hasFocus; - } +const cmp = hoistCmp.factory>( + ({model, className, ...props}, ref) => { + const {width, ...layoutProps} = getLayoutProps(props), + rsProps: PlainObject = { + value: model.renderValue, + + formatOptionLabel: model.formatOptionLabel, + isDisabled: props.disabled, + closeMenuOnSelect: props.closeMenuOnSelect, + hideSelectedOptions: model.hideSelectedOptions, + menuPlacement: withDefault(props.menuPlacement, 'auto'), + maxMenuHeight: props.maxMenuHeight, + noOptionsMessage: model.noOptionsMessageFn, + openMenuOnFocus: props.openMenuOnFocus || model.fullscreen, + placeholder: withDefault(props.placeholder, 'Select...'), + tabIndex: props.tabIndex, + menuShouldBlockScroll: true, + + // Minimize (or hide) bulky dropdown + components: { + DropdownIndicator: model.getDropdownIndicatorCmp(), + IndicatorSeparator: () => null + }, + + // A shared div is created lazily here as needed, appended to the body, and assigned + // a high z-index to ensure options menus render over dialogs or other modals. + menuPortalTarget: model.getOrCreatePortalDiv(), + + inputId: props.id, + classNamePrefix: 'xh-select', + theme: model.getThemeConfig(), + + onBlur: model.onBlur, + onChange: model.onSelectChange, + onFocus: model.onFocus, + filterOption: model.filterOption, + + ref: model.reactSelectRef + }; - if (model.asyncMode) { - rsProps.loadOptions = model.doQueryAsync; - rsProps.loadingMessage = model.loadingMessageFn; - if (model.renderValue) rsProps.defaultOptions = [model.renderValue]; - } else { - rsProps.options = model.internalOptions; - rsProps.isSearchable = model.filterMode; - } + if (model.manageInputValue) { + rsProps.inputValue = model.inputValue || ''; + rsProps.onInputChange = model.onInputChange; + rsProps.controlShouldRenderValue = !model.hasFocus; + } - if (model.creatableMode) { - rsProps.formatCreateLabel = model.createMessageFn; - } + if (model.asyncMode) { + rsProps.loadOptions = model.doQueryAsync; + rsProps.loadingMessage = model.loadingMessageFn; + if (model.renderValue) rsProps.defaultOptions = [model.renderValue]; + } else { + rsProps.options = model.internalOptions; + rsProps.isSearchable = model.filterMode; + } - if (props.menuWidth) { - rsProps.styles = { - menu: provided => ({...provided, width: `${props.menuWidth}px`}), - ...props.rsOptions?.styles - }; - } + if (model.creatableMode) { + rsProps.formatCreateLabel = model.createMessageFn; + } - const factory = model.getSelectFactory(); - mergeDeep(rsProps, props.rsOptions); + if (props.menuWidth) { + rsProps.styles = { + menu: provided => ({...provided, width: `${props.menuWidth}px`}), + ...props.rsOptions?.styles + }; + } - if (model.fullscreen) { - return ReactDom.createPortal( - fullscreenWrapper({ - model, - title: props.title, - item: box({ - item: factory(rsProps), - className, - ref - }) - }), - model.getOrCreateFullscreenPortalDiv() - ) as ReactPortal; - } else { - return box({ - item: factory(rsProps), - className, - ...layoutProps, - width: withDefault(width, null), - ref - }); + const factory = model.getSelectFactory(); + mergeDeep(rsProps, props.rsOptions); + + if (model.fullscreen) { + return ReactDom.createPortal( + fullscreenWrapper({ + model, + title: props.title, + item: box({ + item: factory(rsProps), + className, + ref + }) + }), + model.getOrCreateFullscreenPortalDiv() + ) as ReactPortal; + } else { + return box({ + item: factory(rsProps), + className, + ...layoutProps, + width: withDefault(width, null), + ref + }); + } } -}); +); const fullscreenWrapper = hoistCmp.factory(({model, title, children}) => { return div({ diff --git a/mobile/cmp/input/SwitchInput.ts b/mobile/cmp/input/SwitchInput.ts index 1b63129ab6..dba15a4e0b 100644 --- a/mobile/cmp/input/SwitchInput.ts +++ b/mobile/cmp/input/SwitchInput.ts @@ -5,12 +5,12 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {HoistInputProps, HoistInputModel, useHoistInputModel} from '@xh/hoist/cmp/input'; -import {hoistCmp, HoistProps, StyleProps} from '@xh/hoist/core'; +import {hoistCmp, StyleProps} from '@xh/hoist/core'; import {switchControl} from '@xh/hoist/kit/onsen'; import '@xh/hoist/mobile/register'; import './SwitchInput.scss'; -export interface SwitchInputProps extends HoistProps, HoistInputProps, StyleProps { +export interface SwitchInputProps extends HoistInputProps, StyleProps { value?: string; /** Onsen modifier string */ @@ -28,7 +28,7 @@ export const [SwitchInput, switchInput] = hoistCmp.withFactory } }); -class SwitchInputModel extends HoistInputModel { +class SwitchInputModel extends HoistInputModel { override xhImpl = true; } diff --git a/mobile/cmp/input/TextArea.ts b/mobile/cmp/input/TextArea.ts index c936bd9406..2ec46e52bd 100644 --- a/mobile/cmp/input/TextArea.ts +++ b/mobile/cmp/input/TextArea.ts @@ -6,13 +6,13 @@ */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {div, textarea as textareaTag} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, LayoutProps, StyleProps} from '@xh/hoist/core'; +import {DefaultHoistProps, hoistCmp, LayoutProps, StyleProps} from '@xh/hoist/core'; import '@xh/hoist/mobile/register'; import {withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps} from '@xh/hoist/utils/react'; import './TextArea.scss'; -export interface TextAreaProps extends HoistProps, HoistInputProps, StyleProps, LayoutProps { +export interface TextAreaProps extends HoistInputProps, StyleProps, LayoutProps { value?: string; /** True to commit on every change/keystroke, default false. */ @@ -46,7 +46,7 @@ export const [TextArea, textArea] = hoistCmp.withFactory({ //----------------------- // Implementation //----------------------- -class TextAreaInputModel extends HoistInputModel { +class TextAreaInputModel extends HoistInputModel { override xhImpl = true; override get commitOnChange() { @@ -73,31 +73,33 @@ class TextAreaInputModel extends HoistInputModel { }; } -const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { - const {width, height, ...layoutProps} = getLayoutProps(props); - - return div({ - item: textareaTag({ - value: model.renderValue || '', - - disabled: props.disabled, - placeholder: props.placeholder, - spellCheck: withDefault(props.spellCheck, false), - tabIndex: props.tabIndex, - - onChange: model.onChange, - onKeyDown: model.onKeyDown, - onBlur: model.onBlur, - onFocus: model.onFocus - }), - style: { - ...props.style, - ...layoutProps, - width: withDefault(width, null), - height: withDefault(height, 100) - }, - - className, - ref - }); -}); +const cmp = hoistCmp.factory>( + ({model, className, ...props}, ref) => { + const {width, height, ...layoutProps} = getLayoutProps(props); + + return div({ + item: textareaTag({ + value: model.renderValue || '', + + disabled: props.disabled, + placeholder: props.placeholder, + spellCheck: withDefault(props.spellCheck, false), + tabIndex: props.tabIndex, + + onChange: model.onChange, + onKeyDown: model.onKeyDown, + onBlur: model.onBlur, + onFocus: model.onFocus + }), + style: { + ...props.style, + ...layoutProps, + width: withDefault(width, null), + height: withDefault(height, 100) + }, + + className, + ref + }); + } +); diff --git a/mobile/cmp/input/TextInput.ts b/mobile/cmp/input/TextInput.ts index 0d0b0ad9a7..3e0a8fbac9 100644 --- a/mobile/cmp/input/TextInput.ts +++ b/mobile/cmp/input/TextInput.ts @@ -6,7 +6,7 @@ */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {hbox} from '@xh/hoist/cmp/layout'; -import {hoistCmp, HoistProps, HSide, LayoutProps, StyleProps} from '@xh/hoist/core'; +import {DefaultHoistProps, hoistCmp, HSide, LayoutProps, StyleProps} from '@xh/hoist/core'; import {Icon} from '@xh/hoist/icon'; import {input} from '@xh/hoist/kit/onsen'; import {button} from '@xh/hoist/mobile/cmp/button'; @@ -16,7 +16,7 @@ import {getLayoutProps} from '@xh/hoist/utils/react'; import {isEmpty} from 'lodash'; import './TextInput.scss'; -export interface TextInputProps extends HoistProps, HoistInputProps, StyleProps, LayoutProps { +export interface TextInputProps extends HoistInputProps, StyleProps, LayoutProps { value?: string; /** @@ -79,7 +79,7 @@ export const [TextInput, textInput] = hoistCmp.withFactory({ //----------------------- // Implementation //----------------------- -class TextInputModel extends HoistInputModel { +class TextInputModel extends HoistInputModel { override xhImpl = true; override get commitOnChange() { @@ -110,44 +110,46 @@ class TextInputModel extends HoistInputModel { }; } -const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { - const {width, ...layoutProps} = getLayoutProps(props); - - return hbox({ - ref, - className, - style: { - ...props.style, - ...layoutProps, - width: withDefault(width, null) - }, - items: [ - input({ - value: model.renderValue || '', - - autoCapitalize: props.autoCapitalize, - autoComplete: withDefault( - props.autoComplete, - props.type === 'password' ? 'new-password' : 'off' - ), - disabled: props.disabled, - modifier: props.modifier, - placeholder: props.placeholder, - spellCheck: withDefault(props.spellCheck, false), - tabIndex: props.tabIndex, - type: props.type, - className: 'xh-text-input__input', - style: {textAlign: withDefault(props.textAlign, 'left')}, - - onInput: model.onChange, - onKeyDown: model.onKeyDown, - onBlur: model.onBlur, - onFocus: model.onFocus - }), - clearButton() - ] - }); -}); +const cmp = hoistCmp.factory>( + ({model, className, ...props}, ref) => { + const {width, ...layoutProps} = getLayoutProps(props); + + return hbox({ + ref, + className, + style: { + ...props.style, + ...layoutProps, + width: withDefault(width, null) + }, + items: [ + input({ + value: model.renderValue || '', + + autoCapitalize: props.autoCapitalize, + autoComplete: withDefault( + props.autoComplete, + props.type === 'password' ? 'new-password' : 'off' + ), + disabled: props.disabled, + modifier: props.modifier, + placeholder: props.placeholder, + spellCheck: withDefault(props.spellCheck, false), + tabIndex: props.tabIndex, + type: props.type, + className: 'xh-text-input__input', + style: {textAlign: withDefault(props.textAlign, 'left')}, + + onInput: model.onChange, + onKeyDown: model.onKeyDown, + onBlur: model.onBlur, + onFocus: model.onFocus + }), + clearButton() + ] + }); + } +); const clearButton = hoistCmp.factory(({model}) => button({ diff --git a/mobile/cmp/loadingindicator/LoadingIndicator.ts b/mobile/cmp/loadingindicator/LoadingIndicator.ts index 16930144f7..d6fb49f640 100644 --- a/mobile/cmp/loadingindicator/LoadingIndicator.ts +++ b/mobile/cmp/loadingindicator/LoadingIndicator.ts @@ -7,14 +7,21 @@ import {hbox} from '@xh/hoist/cmp/layout'; import {div} from '@xh/hoist/cmp/layout/Tags'; import {spinner as spinnerCmp} from '@xh/hoist/cmp/spinner'; -import {hoistCmp, HoistModel, HoistProps, Some, TaskObserver, useLocalModel} from '@xh/hoist/core'; +import { + hoistCmp, + HoistModel, + HoistPropsWithRef, + Some, + TaskObserver, + useLocalModel +} from '@xh/hoist/core'; import '@xh/hoist/mobile/register'; import {withDefault} from '@xh/hoist/utils/js'; import classNames from 'classnames'; import {truncate} from 'lodash'; import './LoadingIndicator.scss'; -export interface LoadingIndicatorProps extends HoistProps { +export interface LoadingIndicatorProps extends HoistPropsWithRef { /** TaskObserver(s) that should be monitored to determine if the Indicator should be displayed. */ bind?: Some; /** Position of the indicator relative to its containing component. */ diff --git a/mobile/cmp/mask/Mask.ts b/mobile/cmp/mask/Mask.ts index fcee53925d..267aa6b6e0 100644 --- a/mobile/cmp/mask/Mask.ts +++ b/mobile/cmp/mask/Mask.ts @@ -6,13 +6,20 @@ */ import {box, div, vbox, vspacer} from '@xh/hoist/cmp/layout'; import {spinner as spinnerCmp} from '@xh/hoist/cmp/spinner'; -import {hoistCmp, HoistModel, HoistProps, Some, TaskObserver, useLocalModel} from '@xh/hoist/core'; +import { + hoistCmp, + HoistModel, + HoistPropsWithRef, + Some, + TaskObserver, + useLocalModel +} from '@xh/hoist/core'; import '@xh/hoist/mobile/register'; import {withDefault} from '@xh/hoist/utils/js'; import {ReactNode, MouseEvent} from 'react'; import './Mask.scss'; -export interface MaskProps extends HoistProps { +export interface MaskProps extends HoistPropsWithRef { /** Task(s) that should be monitored to determine if the mask should be displayed. */ bind?: Some; /** True to display the mask. */ diff --git a/mobile/cmp/menu/impl/Menu.ts b/mobile/cmp/menu/impl/Menu.ts index 2beb23fadd..99ec7ffb89 100644 --- a/mobile/cmp/menu/impl/Menu.ts +++ b/mobile/cmp/menu/impl/Menu.ts @@ -4,7 +4,7 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {div, hspacer, vbox} from '@xh/hoist/cmp/layout'; +import {BoxComponentProps, div, hspacer, vbox} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistModel, useLocalModel, MenuItem, MenuItemLike} from '@xh/hoist/core'; import {listItem} from '@xh/hoist/kit/onsen'; import {makeObservable, bindable} from '@xh/hoist/mobx'; @@ -24,7 +24,14 @@ import './Menu.scss'; * * @internal */ -export const menu = hoistCmp.factory({ + +interface MenuProps extends Omit { + menuItems: MenuItemLike[]; + onDismiss: () => void; + title: ReactNode; +} + +export const menu = hoistCmp.factory({ displayName: 'Menu', className: 'xh-menu', diff --git a/mobile/cmp/panel/DialogPanel.ts b/mobile/cmp/panel/DialogPanel.ts index d12718bd81..dd31463d41 100644 --- a/mobile/cmp/panel/DialogPanel.ts +++ b/mobile/cmp/panel/DialogPanel.ts @@ -4,13 +4,13 @@ * * Copyright © 2024 Extremely Heavy Industries Inc. */ -import {hoistCmp} from '@xh/hoist/core'; +import {hoistCmp, HoistProps, WithoutModelAndRef} from '@xh/hoist/core'; import {dialog} from '@xh/hoist/kit/onsen'; import '@xh/hoist/mobile/register'; import './DialogPanel.scss'; import {panel, PanelProps} from './Panel'; -export interface DialogPanelProps extends PanelProps { +export interface DialogPanelProps extends HoistProps, WithoutModelAndRef { /** Is the dialog panel shown. */ isOpen?: boolean; } diff --git a/mobile/cmp/panel/Panel.ts b/mobile/cmp/panel/Panel.ts index 62f0a88936..7f32061794 100644 --- a/mobile/cmp/panel/Panel.ts +++ b/mobile/cmp/panel/Panel.ts @@ -10,10 +10,10 @@ import { TaskObserver, useContextModel, Some, - HoistProps, ElementFactory, hoistCmp, - HoistModel + HoistModel, + HoistPropsWithRef } from '@xh/hoist/core'; import {loadingIndicator} from '@xh/hoist/mobile/cmp/loadingindicator'; import {mask} from '@xh/hoist/mobile/cmp/mask'; @@ -27,7 +27,7 @@ import {panelHeader} from './impl/PanelHeader'; import './Panel.scss'; import {logWarn} from '@xh/hoist/utils/js'; -export interface PanelProps extends HoistProps, Omit { +export interface PanelProps extends HoistPropsWithRef, Omit { /** A toolbar to be docked at the bottom of the panel. */ bbar?: Some; diff --git a/mobile/cmp/pinpad/impl/PinPad.ts b/mobile/cmp/pinpad/impl/PinPad.ts index a7392f173e..2abea359c7 100644 --- a/mobile/cmp/pinpad/impl/PinPad.ts +++ b/mobile/cmp/pinpad/impl/PinPad.ts @@ -6,7 +6,7 @@ */ import composeRefs from '@seznam/compose-react-refs'; import {div, frame, h1, hbox, p, span, vbox, vframe} from '@xh/hoist/cmp/layout'; -import {PinPadModel} from '@xh/hoist/cmp/pinpad'; +import {PinPadModel, PinPadProps} from '@xh/hoist/cmp/pinpad'; import {hoistCmp, uses} from '@xh/hoist/core'; import {Icon} from '@xh/hoist/icon/Icon'; import {button} from '@xh/hoist/mobile/cmp/button'; @@ -20,11 +20,11 @@ import './PinPad.scss'; * * @internal */ -export const pinPadImpl = hoistCmp.factory({ +export const pinPadImpl = hoistCmp.factory({ model: uses(PinPadModel), render({model}, ref) { return frame({ - ref: composeRefs(ref, model.ref), + ref: composeRefs(model.ref, ref), item: vframe({ className: 'xh-pinpad__frame', items: [header(), display(), errorDisplay(), keypad()] diff --git a/mobile/cmp/toolbar/Toolbar.ts b/mobile/cmp/toolbar/Toolbar.ts index 7cb388ca93..6ea2d4845e 100644 --- a/mobile/cmp/toolbar/Toolbar.ts +++ b/mobile/cmp/toolbar/Toolbar.ts @@ -5,7 +5,7 @@ * Copyright © 2024 Extremely Heavy Industries Inc. */ import {hbox, vbox} from '@xh/hoist/cmp/layout'; -import {BoxProps, hoistCmp, HoistProps} from '@xh/hoist/core'; +import {BoxProps, hoistCmp, HoistPropsWithRef} from '@xh/hoist/core'; import '@xh/hoist/mobile/register'; import {toolbarSeparator} from '@xh/hoist/mobile/cmp/toolbar'; import {filterConsecutiveToolbarSeparators} from '@xh/hoist/utils/impl'; @@ -13,7 +13,7 @@ import classNames from 'classnames'; import './Toolbar.scss'; import {Children} from 'react'; -export interface ToolbarProps extends HoistProps, BoxProps { +export interface ToolbarProps extends HoistPropsWithRef, BoxProps { /** Set to true to vertically align the items of this toolbar */ vertical?: boolean; }