diff --git a/cmp/card/Card.scss b/cmp/card/Card.scss new file mode 100644 index 000000000..621c15644 --- /dev/null +++ b/cmp/card/Card.scss @@ -0,0 +1,57 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +.xh-card { + border-color: var(--xh-border-color); + border-width: var(--xh-border-width-px); + + &--collapsed { + border-bottom: none; + border-left: none; + border-right: none; + } + + &--intent-primary { + border-color: var(--xh-card-primary-color); + } + + &--intent-success { + border-color: var(--xh-card-success-color); + } + + &--intent-warning { + border-color: var(--xh-card-warning-color); + } + + &--intent-danger { + border-color: var(--xh-card-danger-color); + } + + &__header { + align-items: center; + display: flex; + gap: 8px; + min-height: 30px; + padding: var(--xh-pad-half-px) var(--xh-pad-half-px); + + &--intent-primary { + color: var(--xh-card-primary-color); + } + + &--intent-success { + color: var(--xh-card-success-color); + } + + &--intent-warning { + color: var(--xh-card-warning-color); + } + + &--intent-danger { + color: var(--xh-card-danger-color); + } + } +} diff --git a/cmp/card/Card.ts b/cmp/card/Card.ts new file mode 100644 index 000000000..476b30965 --- /dev/null +++ b/cmp/card/Card.ts @@ -0,0 +1,167 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +import {box, div, fieldset, legend} from '@xh/hoist/cmp/layout'; +import { + BoxProps, + hoistCmp, + HoistProps, + Intent, + LayoutProps, + TestSupportProps, + uses, + XH +} from '@xh/hoist/core'; +import {collapseToggleButton as desktopCollapseToggleButtonImpl} from '@xh/hoist/dynamics/desktop'; +import {collapseToggleButton as mobileCollapseToggleButtonImpl} from '@xh/hoist/dynamics/mobile'; +import {tooltip as bpTooltip} from '@xh/hoist/kit/blueprint'; +import {mergeDeep, TEST_ID} from '@xh/hoist/utils/js'; +import {splitLayoutProps} from '@xh/hoist/utils/react'; +import classNames from 'classnames'; +import {castArray} from 'lodash'; +import {type ReactElement, type ReactNode, useRef} from 'react'; +import {CardModel} from './CardModel'; + +import './Card.scss'; + +export interface CardProps extends HoistProps, TestSupportProps, LayoutProps { + /** An icon placed left of the title. */ + icon?: ReactElement; + /** Intent to apply to the inline header and border. */ + intent?: Intent; + /** The title to display. */ + title?: ReactNode; + /** Tooltip to show when hovering over the inline header. */ + tooltip?: ReactElement | string; + /** Additional props to pass to the inner content box. */ + innerBoxProps?: BoxProps; +} + +/** + * A bounded container for grouping related content, with optional inline header and collapsibility. + * Children are arranged vertically in a flexbox container by default. innerBoxProps can be + * passed to control the flex direction and other layout aspects of the inner container. + */ +export const [Card, card] = hoistCmp.withFactory({ + displayName: 'Card', + model: uses(CardModel, { + fromContext: false, + publishMode: 'limited', + createDefault: true + }), + className: 'xh-card', + render({ + icon, + title, + tooltip, + intent, + children, + className, + innerBoxProps = {}, + model, + ...rest + }) { + const wasDisplayed = useRef(false), + {collapsible, collapsed, renderMode} = model; + + let [layoutProps, {testId, ...restProps}] = splitLayoutProps(rest); + + restProps = mergeDeep({style: layoutProps}, {[TEST_ID]: testId}, restProps); + + const items = castArray(children), + classes = []; + + if (collapsed) { + classes.push('xh-card--collapsed'); + } else { + classes.push('xh-card--expanded'); + } + + if (intent) { + classes.push(`xh-card--intent-${intent}`); + } else { + classes.push(`xh-card--intent-none`); + } + + const collapseToggleButton = XH.isMobileApp + ? mobileCollapseToggleButtonImpl + : desktopCollapseToggleButtonImpl; + + if (collapsed) { + classes.push('xh-card--collapsed'); + } else { + wasDisplayed.current = true; + } + + let content: ReactNode[]; + switch (renderMode) { + case 'always': + content = items; + classes.push('xh-card--render-mode-always'); + break; + + case 'lazy': + content = collapsed && !wasDisplayed.current ? [] : items; + classes.push('xh-card--render-mode-lazy'); + break; + + // unmountOnHide + default: + content = collapsed ? [] : items; + classes.push('xh-card--render-mode-unmount-on-hide'); + break; + } + + return fieldset({ + className: classNames(className, classes), + items: [ + collapsible + ? collapseToggleButton({ + icon, + text: title, + tooltip, + intent, + cardModel: model + }) + : cardTitle({icon, title, tooltip, intent}), + box({ + className: 'xh-card__inner', + items: content, + display: collapsed ? 'none' : 'flex', + flexDirection: 'column', + flexWrap: 'wrap', + ...innerBoxProps + }) + ], + ...restProps + }); + } +}); + +interface CardTitleProps extends HoistProps { + icon?: ReactElement; + intent?: Intent; + title?: ReactNode; + tooltip?: ReactElement | string; +} + +const cardTitle = hoistCmp.factory({ + displayName: 'CardTitle', + className: 'xh-card__header', + model: false, + + render({className, icon, intent, title, tooltip}) { + if (!icon && !title) return null; + + const header = div({ + className: classNames(className, `xh-card__header--intent-${intent ?? 'none'}`), + items: [icon, title] + }); + + return legend(tooltip ? bpTooltip({item: header, content: tooltip, intent}) : header); + } +}); diff --git a/cmp/card/CardModel.ts b/cmp/card/CardModel.ts new file mode 100644 index 000000000..00070b9b6 --- /dev/null +++ b/cmp/card/CardModel.ts @@ -0,0 +1,94 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +import { + HoistModel, + Persistable, + PersistableState, + PersistenceProvider, + PersistOptions, + RenderMode +} from '@xh/hoist/core'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; +import {isNil} from 'lodash'; + +export interface CardConfig { + /** Can card be collapsed? */ + collapsible?: boolean; + + /** Default collapsed state. */ + defaultCollapsed?: boolean; + + /** How should collapsed content be rendered? */ + renderMode?: RenderMode; + + /** Options governing persistence. */ + persistWith?: PersistOptions; +} + +export interface CardPersistState { + collapsed: boolean; +} + +/** + * CardModel supports configuration and state-management for user-driven expand/collapse, + * along with support for saving this state via a configured PersistenceProvider. + */ +export class CardModel extends HoistModel implements Persistable { + declare config: CardConfig; + + //----------------------- + // Immutable Properties + //----------------------- + readonly collapsible: boolean; + readonly defaultCollapsed: boolean; + readonly renderMode: RenderMode; + + //--------------------- + // Observable State + //--------------------- + @bindable + collapsed: boolean = false; + + constructor({ + collapsible = true, + defaultCollapsed = false, + renderMode = 'unmountOnHide', + persistWith = null + }: CardConfig = {}) { + super(); + makeObservable(this); + + this.collapsible = collapsible; + this.collapsed = this.defaultCollapsed = collapsible && defaultCollapsed; + this.renderMode = renderMode; + + if (persistWith) { + PersistenceProvider.create({ + persistOptions: { + path: 'card', + ...persistWith + }, + target: this + }); + } + } + + //--------------------- + // Persistable Interface + //--------------------- + getPersistableState(): PersistableState { + return new PersistableState({collapsed: this.collapsed}); + } + + setPersistableState(state: PersistableState) { + const {collapsed} = state.value; + if (!isNil(collapsed)) { + this.collapsed = collapsed; + } + } +} diff --git a/cmp/card/index.ts b/cmp/card/index.ts new file mode 100644 index 000000000..99d29d109 --- /dev/null +++ b/cmp/card/index.ts @@ -0,0 +1,7 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +export * from './Card'; diff --git a/cmp/layout/CollapsibleSet.scss b/cmp/layout/CollapsibleSet.scss deleted file mode 100644 index 7ae875db5..000000000 --- a/cmp/layout/CollapsibleSet.scss +++ /dev/null @@ -1,49 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2026 Extremely Heavy Industries Inc. - */ - -.xh-collapsible-set { - &--collapsed { - border-bottom: none; - border-left: none; - border-right: none; - - &--render-mode--always, - &--render-mode--lazy { - > *:not(:first-child) { - display: none; - } - } - } - - &.xh-collapsible-set--intent-primary { - border-color: var(--xh-intent-primary-trans2); - &.xh-collapsible-set--enabled { - border-color: var(--xh-intent-primary); - } - } - - &.xh-collapsible-set--intent-success { - border-color: var(--xh-intent-success-trans2); - &.xh-collapsible-set--enabled { - border-color: var(--xh-intent-success); - } - } - - &.xh-collapsible-set--intent-warning { - border-color: var(--xh-intent-warning-trans2); - &.xh-collapsible-set--enabled { - border-color: var(--xh-intent-warning); - } - } - - &.xh-collapsible-set--intent-danger { - border-color: var(--xh-intent-danger-trans2); - &.xh-collapsible-set--enabled { - border-color: var(--xh-intent-danger); - } - } -} diff --git a/cmp/layout/CollapsibleSet.ts b/cmp/layout/CollapsibleSet.ts deleted file mode 100644 index 5e8ba946a..000000000 --- a/cmp/layout/CollapsibleSet.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2026 Extremely Heavy Industries Inc. - */ - -import classNames from 'classnames'; -import {castArray} from 'lodash'; -import {type FieldsetHTMLAttributes, type ReactElement, type ReactNode, useState} from 'react'; -import {XH, hoistCmp} from '@xh/hoist/core'; -import type {HoistProps, Intent, LayoutProps, RenderMode, TestSupportProps} from '@xh/hoist/core'; -import {fieldset} from '@xh/hoist/cmp/layout'; -import {TEST_ID, mergeDeep} from '@xh/hoist/utils/js'; -import {splitLayoutProps} from '@xh/hoist/utils/react'; -import {collapsibleSetButton as desktopCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/desktop'; -import {collapsibleSetButton as mobileCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/mobile'; - -import './CollapsibleSet.scss'; - -export interface CollapsibleSetProps - extends FieldsetHTMLAttributes, HoistProps, TestSupportProps, LayoutProps { - icon?: ReactElement; - label: ReactNode; - tooltip?: JSX.Element | string; - intent?: Intent; - clickHandler?: () => void; - collapsed?: boolean; - hideItemCount?: boolean; - renderMode?: RenderMode; -} - -export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory({ - displayName: 'CollapsibleSet', - model: false, - className: 'xh-collapsible-set', - render({ - icon, - label, - tooltip, - intent, - collapsed, - children, - hideItemCount, - className, - disabled, - display = 'flex', - flexDirection = 'column', - flexWrap = 'wrap', - renderMode = 'unmountOnHide', - ...rest - }) { - // Note `model` destructured off of non-layout props to avoid setting - // model as a bogus DOM attribute. This low-level component may easily be passed one from - // a parent that has not properly managed its own props. - let [layoutProps, {model, testId, ...restProps}] = splitLayoutProps(rest); - - restProps = mergeDeep( - {style: {display, flexDirection, flexWrap, ...layoutProps}}, - {[TEST_ID]: testId}, - restProps - ); - - const [isCollapsed, setIsCollapsed] = useState(collapsed === true), - [expandCount, setExpandCount] = useState(!collapsed ? 1 : 0), - items = castArray(children), - itemCount = hideItemCount === true ? '' : ` (${items.length})`, - classes = []; - - if (isCollapsed) { - classes.push('xh-collapsible-set--collapsed'); - } else { - classes.push('xh-collapsible-set--expanded'); - } - - if (disabled) { - classes.push('xh-collapsible-set--disabled'); - } else { - classes.push('xh-collapsible-set--enabled'); - } - - if (intent) { - classes.push(`xh-collapsible-set--intent-${intent}`); - } else { - classes.push(`xh-collapsible-set--intent-none`); - } - - const btn = XH.isMobileApp - ? mobileCollapsibleSetButtonImpl - : desktopCollapsibleSetButtonImpl; - - let content; - switch (renderMode) { - case 'always': - content = items; - if (isCollapsed) { - classes.push('xh-collapsible-set--collapsed--render-mode--always'); - } - break; - - case 'lazy': - content = isCollapsed && !expandCount ? [] : items; - if (isCollapsed) { - classes.push('xh-collapsible-set--collapsed--render-mode--lazy'); - } - break; - - // unmountOnHide - default: - content = isCollapsed ? [] : items; - break; - } - - return fieldset({ - className: classNames(className, classes), - items: [ - btn({ - icon, - text: `${label}${itemCount}`, - tooltip, - intent, - clickHandler: val => { - setIsCollapsed(val); - setExpandCount(expandCount + 1); - }, - collapsed: isCollapsed, - disabled - }), - ...content - ], - disabled, - ...restProps - }); - } -}); diff --git a/desktop/appcontainer/AppContainer.ts b/desktop/appcontainer/AppContainer.ts index a825d2d7b..e86eb0643 100644 --- a/desktop/appcontainer/AppContainer.ts +++ b/desktop/appcontainer/AppContainer.ts @@ -27,7 +27,7 @@ import {zoneMapperDialog as zoneMapper} from '@xh/hoist/desktop/cmp/zoneGrid/imp import {useContextMenu, useHotkeys} from '@xh/hoist/desktop/hooks'; import {DynamicTabSwitcherModel, installDesktopImpls} from '@xh/hoist/dynamics/desktop'; import {inspectorPanel} from '@xh/hoist/inspector/InspectorPanel'; -import {collapsibleSetButton} from '@xh/hoist/desktop/cmp/button/CollapsibleSetButton'; +import {collapseToggleButton} from '@xh/hoist/desktop/cmp/button/card/CollapseToggleButton'; import {blueprintProvider} from '@xh/hoist/kit/blueprint'; import {consumeEvent} from '@xh/hoist/utils/js'; import {elementFromContent, useOnMount} from '@xh/hoist/utils/react'; @@ -47,7 +47,7 @@ import {toastSource} from './ToastSource'; import {versionBar} from './VersionBar'; installDesktopImpls({ - collapsibleSetButton, + collapseToggleButton, tabContainerImpl, dockContainerImpl, storeFilterFieldImpl, diff --git a/desktop/cmp/button/CollapsibleSetButton.ts b/desktop/cmp/button/CollapsibleSetButton.ts deleted file mode 100644 index c759be9ca..000000000 --- a/desktop/cmp/button/CollapsibleSetButton.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2026 Extremely Heavy Industries Inc. - */ - -import {type ReactElement, type ReactNode, type JSX, useState} from 'react'; -import {tooltip as bpTooltip} from '@xh/hoist/kit/blueprint'; -import {hoistCmp} from '@xh/hoist/core'; -import type {Intent, HoistProps} from '@xh/hoist/core'; -import {button} from '@xh/hoist/desktop/cmp/button'; -import {legend} from '@xh/hoist/cmp/layout'; -import {Icon} from '@xh/hoist/icon/Icon'; - -export interface CollapsibleSetButtonProps extends HoistProps { - icon?: ReactElement; - text: ReactNode; - tooltip?: JSX.Element | string; - clickHandler?: (boolean) => void; - intent?: Intent; - collapsed?: boolean; - disabled?: boolean; -} - -export const [CollapsibleSetButton, collapsibleSetButton] = - hoistCmp.withFactory({ - displayName: 'CollapsibleSetButton', - model: false, - render({icon, text, tooltip, intent, clickHandler, collapsed, disabled}) { - const [isCollapsed, setIsCollapsed] = useState(collapsed === true), - btn = button({ - text, - icon, - rightIcon: isCollapsed ? Icon.angleDown() : Icon.angleUp(), - outlined: isCollapsed && !intent, - minimal: !intent || (intent && !isCollapsed), - intent, - disabled, - onClick: () => { - const val = !isCollapsed; - setIsCollapsed(val); - clickHandler?.(val); - } - }); - - return legend( - tooltip - ? bpTooltip({ - item: btn, - content: tooltip, - intent - }) - : btn - ); - } - }); diff --git a/desktop/cmp/button/card/CollapseToggleButton.ts b/desktop/cmp/button/card/CollapseToggleButton.ts new file mode 100644 index 000000000..a55076dd6 --- /dev/null +++ b/desktop/cmp/button/card/CollapseToggleButton.ts @@ -0,0 +1,56 @@ +import {CardModel} from '@xh/hoist/cmp/card/CardModel'; +import {legend} from '@xh/hoist/cmp/layout'; +import {hoistCmp, useContextModel} from '@xh/hoist/core'; +import {button, type ButtonProps} from '@xh/hoist/desktop/cmp/button'; +import {Icon} from '@xh/hoist/icon'; +import {tooltip as bpTooltip} from '@xh/hoist/kit/blueprint'; +import {logError, withDefault} from '@xh/hoist/utils/js'; +import type {ReactElement} from 'react'; + +export interface CollapseToggleButtonProps extends Omit { + cardModel?: CardModel; + tooltip?: ReactElement | string; +} + +/** + * A convenience button to toggle a Card's expand / collapse state. + */ +export const [CollapseToggleButton, collapseToggleButton] = + hoistCmp.withFactory({ + displayName: 'CollapseToggleButton', + className: 'xh-collapse-toggle-button', + model: false, + + render({className, cardModel, disabled, intent, tooltip, ...rest}, ref) { + cardModel = withDefault(cardModel, useContextModel(CardModel)); + + if (!cardModel) { + logError( + 'No CardModel available - provide via `cardModel` prop or context - button will be disabled.', + CollapseToggleButton + ); + disabled = true; + } + + const {collapsed} = cardModel, + btn = button({ + ref, + rightIcon: collapsed ? Icon.angleDown() : Icon.angleUp(), + onClick: () => (cardModel.collapsed = !collapsed), + className, + disabled, + intent, + ...rest + }); + + return legend( + tooltip + ? bpTooltip({ + item: btn, + content: tooltip, + intent + }) + : btn + ); + } + }); diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts index 56d44ea82..c17f15005 100644 --- a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts +++ b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts @@ -12,7 +12,7 @@ import {div, frame} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp, HoistProps, TestSupportProps, uses} from '@xh/hoist/core'; import {DashCanvasModel, DashCanvasViewSpec} from '@xh/hoist/desktop/cmp/dash'; import {DashCanvasWidgetWellModel} from '@xh/hoist/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel'; -import {collapsibleSet} from '@xh/hoist/cmp/layout/CollapsibleSet'; +import {card} from '@xh/hoist/cmp/card/Card'; import './DashCanvasWidgetWell.scss'; @@ -113,7 +113,7 @@ function createDraggableItems(dashCanvasModel: DashCanvasModel, flexDirection): return [ ...Object.keys(groupedItems).map(group => { - const label = group, + const title = group, items = groupedItems[group], sameIcons = uniqBy<{item: ReactElement; icon: ReactElement}>( @@ -122,10 +122,9 @@ function createDraggableItems(dashCanvasModel: DashCanvasModel, flexDirection): ).length === 1, icon = sameIcons ? items[0].icon : null; - return collapsibleSet({ + return card({ icon, - collapsed: false, - label, + title, flexDirection, items: items.map(it => it.item) }); diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index 0bfd97327..47136b02f 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -14,12 +14,14 @@ import { HoistProps, HSide, TestSupportProps, + useContextModel, uses, XH } from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {instanceManager} from '@xh/hoist/core/impl/InstanceManager'; import {maxSeverity, ValidationResult} from '@xh/hoist/data'; +import {FormFieldSetModel} from '@xh/hoist/desktop/cmp/form/formfieldset/FormFieldSetModel'; import {fmtDate, fmtDateTime, fmtJson, fmtNumber} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {tooltip} from '@xh/hoist/kit/blueprint'; @@ -28,7 +30,15 @@ import {errorIf, getTestId, logWarn, TEST_ID, throwIf, withDefault} from '@xh/ho import {getLayoutProps, getReactElementName, useOnMount, useOnUnmount} from '@xh/hoist/utils/react'; import classNames from 'classnames'; import {first, isBoolean, isDate, isEmpty, isFinite, isNil, isUndefined, kebabCase} from 'lodash'; -import {Children, cloneElement, ReactElement, ReactNode, useContext, useState} from 'react'; +import { + Children, + cloneElement, + ReactElement, + ReactNode, + useContext, + useEffect, + useState +} from 'react'; import './FormField.scss'; export interface FormFieldProps extends BaseFormFieldProps { @@ -119,10 +129,19 @@ export const [FormField, formField] = hoistCmp.withFactory({ logWarn(`Unable to bind FormField to field "${field}" on backing FormModel`, FormField); } + // If within a FormFieldSet, register with its model for validation grouping + const fieldSetModel = useContextModel(FormFieldSetModel); + useEffect(() => { + if (fieldSetModel && model) { + fieldSetModel.registerChildFieldModel(model); + return () => fieldSetModel.unregisterChildFieldModel(model); + } + }, [fieldSetModel, model]); + // Model related props const isRequired = model?.isRequired || false, readonly = model?.readonly || false, - disabled = props.disabled || model?.disabled, + disabled = props.disabled || model?.disabled || fieldSetModel?.disabled, severityToDisplay = model?.validationDisplayed ? maxSeverity(model.validationResults) : null, diff --git a/desktop/cmp/form/formfieldset/FormFieldSet.scss b/desktop/cmp/form/formfieldset/FormFieldSet.scss new file mode 100644 index 000000000..070ffba00 --- /dev/null +++ b/desktop/cmp/form/formfieldset/FormFieldSet.scss @@ -0,0 +1,11 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +ul.xh-form-field-set-tooltip { + margin: 0; + padding: 0 1em 0 2em; +} diff --git a/desktop/cmp/form/formfieldset/FormFieldSet.ts b/desktop/cmp/form/formfieldset/FormFieldSet.ts new file mode 100644 index 000000000..958df8ee4 --- /dev/null +++ b/desktop/cmp/form/formfieldset/FormFieldSet.ts @@ -0,0 +1,96 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +import {li, ul} from '@xh/hoist/cmp/layout'; +import {card} from '@xh/hoist/cmp/card/Card'; +import { + BoxProps, + hoistCmp, + HoistProps, + Intent, + type LayoutProps, + type TestSupportProps, + useContextModel, + uses +} from '@xh/hoist/core'; +import {ValidationSeverity} from '@xh/hoist/data'; +import {FormFieldSetModel} from '@xh/hoist/desktop/cmp/form/formfieldset/FormFieldSetModel'; +import {runInAction} from 'mobx'; +import {ReactElement, type ReactNode, useEffect} from 'react'; +import './FormFieldSet.scss'; + +export interface FormFieldSetProps + extends HoistProps, TestSupportProps, LayoutProps { + /** An icon placed left of the title. */ + icon?: ReactElement; + /** The title to display. */ + title?: ReactNode; + /** Additional props to pass to the inner content box. */ + innerBoxProps?: BoxProps; +} + +export const [FormFieldSet, formFieldSet] = hoistCmp.withFactory({ + displayName: 'FormFieldSet', + model: uses(FormFieldSetModel, { + fromContext: false, + publishMode: 'limited', + createDefault: true + }), + render({model, ...props}) { + // Handle nested FormFieldSets + const parentModel = useContextModel(FormFieldSetModel); + useEffect(() => { + if (parentModel) { + runInAction(() => { + parentModel.registerChildFormFieldSetModel(model); + model.parent = parentModel; + }); + return () => + runInAction(() => { + parentModel.unregisterChildFormFieldSetModel(model); + model.parent = null; + }); + } + }, [parentModel, model]); + + const {displayedSeverity, displayedValidationMessages} = model; + + // Construct tooltip if there are validation messages to show + let tooltip: ReactElement | string; + if (displayedSeverity) { + tooltip = + displayedValidationMessages.length === 1 + ? displayedValidationMessages[0] + : ul({ + className: 'xh-form-field-set-tooltip', + items: displayedValidationMessages.map((it, idx) => + li({key: idx, item: it}) + ) + }); + } + + return card({ + intent: intentForSeverity(displayedSeverity), + tooltip, + model, + ...props + }); + } +}); + +function intentForSeverity(severity: ValidationSeverity): Intent { + switch (severity) { + case 'error': + return 'danger'; + case 'warning': + return 'warning'; + case 'info': + return 'primary'; + default: + return null; + } +} diff --git a/desktop/cmp/form/formfieldset/FormFieldSetModel.ts b/desktop/cmp/form/formfieldset/FormFieldSetModel.ts new file mode 100644 index 000000000..bf20134a1 --- /dev/null +++ b/desktop/cmp/form/formfieldset/FormFieldSetModel.ts @@ -0,0 +1,126 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ + +import {CardModel} from '@xh/hoist/cmp/card/CardModel'; +import {FieldModel} from '@xh/hoist/cmp/form'; +import {type PersistOptions} from '@xh/hoist/core'; +import {maxSeverity, ValidationSeverity} from '@xh/hoist/data'; +import {makeObservable} from '@xh/hoist/mobx'; +import {uniq} from 'lodash'; +import {action, computed, observable} from 'mobx'; + +export interface FormFieldSetConfig { + /** Can form field set be collapsed? */ + collapsible?: boolean; + + /** Default collapsed state. */ + defaultCollapsed?: boolean; + + /** True to disable all descendant fields. */ + disabled?: boolean; + + /** Options governing persistence. */ + persistWith?: PersistOptions; +} + +export class FormFieldSetModel extends CardModel { + declare config: FormFieldSetConfig; + + @observable.ref parent: FormFieldSetModel | null; + + //----------------- + // Implementation + //----------------- + @observable.ref private formFieldSetModelRegistry: FormFieldSetModel[] = []; + @observable.ref private fieldModelRegistry: FieldModel[] = []; + @observable private isDisabled: boolean; + + @computed + get disabled(): boolean { + return this.isDisabled || this.ancestors.some(it => it.isDisabled); + } + + @computed + get displayedSeverity(): ValidationSeverity { + return maxSeverity( + this.fieldModels + .filter(it => it.validationDisplayed) + .flatMap(it => it.validationResults) + ); + } + + @computed + get displayedValidationMessages(): string[] { + const ret: string[] = [], + {displayedSeverity} = this; + this.fieldModels.forEach(fieldModel => { + if (!fieldModel.validationDisplayed) return; + fieldModel.validationResults.forEach(validationResult => { + if (validationResult.severity === displayedSeverity) { + ret.push(validationResult.message); + } + }); + }); + return ret; + } + + constructor({disabled = false, ...rest}: FormFieldSetConfig = {}) { + super({...rest, renderMode: 'always'}); + makeObservable(this); + this.isDisabled = disabled; + } + + @action + setDisabled(disabled: boolean) { + this.isDisabled = disabled; + } + + //------------------------ + // Implementation + //------------------------ + @computed + private get ancestors(): FormFieldSetModel[] { + return this.parent ? [this.parent, ...this.parent.ancestors] : []; + } + + @computed + private get fieldModels(): FieldModel[] { + return [ + ...this.fieldModelRegistry, + ...this.formFieldSetModelRegistry.flatMap(it => it.fieldModels) + ]; + } + + /** @internal */ + @action + registerChildFieldModel(fieldModel: FieldModel) { + this.fieldModelRegistry = uniq([...this.fieldModelRegistry, fieldModel]); + } + + /** @internal */ + @action + unregisterChildFieldModel(fieldModel: FieldModel) { + this.fieldModelRegistry = this.fieldModelRegistry.filter(it => it !== fieldModel); + } + + /** @internal */ + @action + registerChildFormFieldSetModel(formFieldSetModel: FormFieldSetModel) { + this.formFieldSetModelRegistry = uniq([ + ...this.formFieldSetModelRegistry, + formFieldSetModel + ]); + } + + /** @internal */ + @action + unregisterChildFormFieldSetModel(formFieldSetModel: FormFieldSetModel) { + this.formFieldSetModelRegistry = this.formFieldSetModelRegistry.filter( + it => it !== formFieldSetModel + ); + } +} diff --git a/desktop/cmp/form/formfieldset/index.ts b/desktop/cmp/form/formfieldset/index.ts new file mode 100644 index 000000000..cab9aff07 --- /dev/null +++ b/desktop/cmp/form/formfieldset/index.ts @@ -0,0 +1,2 @@ +export * from './FormFieldSet'; +export * from './FormFieldSetModel'; diff --git a/desktop/cmp/form/index.ts b/desktop/cmp/form/index.ts index dfeb37488..8393a9cf6 100644 --- a/desktop/cmp/form/index.ts +++ b/desktop/cmp/form/index.ts @@ -1 +1,2 @@ export * from './FormField'; +export * from './formfieldset'; diff --git a/dynamics/desktop.ts b/dynamics/desktop.ts index aeb54478d..2efcb7711 100644 --- a/dynamics/desktop.ts +++ b/dynamics/desktop.ts @@ -15,7 +15,7 @@ * * See the platform specific AppContainer where these implementations are actually provided. */ -export let collapsibleSetButton = null; +export let collapseToggleButton = null; export let ColChooserModel = null; export let ColumnHeaderFilterModel = null; export let ModalSupportModel = null; @@ -38,7 +38,7 @@ export let DynamicTabSwitcherModel = null; * Not for Application use. */ export function installDesktopImpls(impls) { - collapsibleSetButton = impls.collapsibleSetButton; + collapseToggleButton = impls.collapseToggleButton; ColChooserModel = impls.ColChooserModel; ColumnHeaderFilterModel = impls.ColumnHeaderFilterModel; ModalSupportModel = impls.ModalSupportModel; diff --git a/dynamics/mobile.ts b/dynamics/mobile.ts index 24415ace9..cb2b6f0b7 100644 --- a/dynamics/mobile.ts +++ b/dynamics/mobile.ts @@ -15,7 +15,7 @@ * * See the platform specific AppContainer where these implementations are actually provided. */ -export let collapsibleSetButton = null; +export let collapseToggleButton = null; export let ColChooserModel = null; export let colChooser = null; export let zoneMapper = null; @@ -31,7 +31,7 @@ export let maskImpl = null; * Not for Application use. */ export function installMobileImpls(impls) { - collapsibleSetButton = impls.collapsibleSetButton; + collapseToggleButton = impls.collapseToggleButton; ColChooserModel = impls.ColChooserModel; colChooser = impls.colChooser; zoneMapper = impls.zoneMapper; diff --git a/mobile/appcontainer/AppContainer.ts b/mobile/appcontainer/AppContainer.ts index e25325a8a..a64e3123f 100644 --- a/mobile/appcontainer/AppContainer.ts +++ b/mobile/appcontainer/AppContainer.ts @@ -18,7 +18,7 @@ import {pinPadImpl} from '@xh/hoist/mobile/cmp/pinpad/impl/PinPad'; import {storeFilterFieldImpl} from '@xh/hoist/mobile/cmp/store/impl/StoreFilterField'; import {tabContainerImpl} from '@xh/hoist/mobile/cmp/tab/impl/TabContainer'; import {zoneMapper} from '@xh/hoist/mobile/cmp/zoneGrid/impl/ZoneMapper'; -import {collapsibleSetButton} from '@xh/hoist/mobile/cmp/button/CollapsibleSetButton'; +import {collapseToggleButton} from '@xh/hoist/mobile/cmp/button/card/CollapseToggleButton'; import {elementFromContent, useOnMount} from '@xh/hoist/utils/react'; import {isEmpty} from 'lodash'; import {aboutDialog} from './AboutDialog'; @@ -35,7 +35,7 @@ import {toastSource} from './ToastSource'; import {versionBar} from './VersionBar'; installMobileImpls({ - collapsibleSetButton, + collapseToggleButton, tabContainerImpl, storeFilterFieldImpl, pinPadImpl, diff --git a/mobile/cmp/button/CollapsibleSetButton.ts b/mobile/cmp/button/CollapsibleSetButton.ts deleted file mode 100644 index 33e6cbf0d..000000000 --- a/mobile/cmp/button/CollapsibleSetButton.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2026 Extremely Heavy Industries Inc. - */ - -import {type ReactElement, type ReactNode, type JSX, useState} from 'react'; -import {tooltip as bpTooltip} from '@xh/hoist/kit/blueprint'; -import {fragment} from '@xh/hoist/cmp/layout'; -import {hoistCmp} from '@xh/hoist/core'; -import type {Intent, HoistProps} from '@xh/hoist/core'; -import {button} from '@xh/hoist/mobile/cmp/button'; -import {legend} from '@xh/hoist/cmp/layout'; -import {Icon} from '@xh/hoist/icon/Icon'; - -export interface CollapsibleSetButtonProps extends HoistProps { - icon?: ReactElement; - text: ReactNode; - tooltip?: JSX.Element | string; - clickHandler?: (boolean) => void; - intent?: Intent; - collapsed?: boolean; - disabled?: boolean; -} - -export const [CollapsibleSetButton, collapsibleSetButton] = - hoistCmp.withFactory({ - displayName: 'CollapsibleSetButton', - model: false, - render({icon, text, tooltip, intent, clickHandler, collapsed, disabled}) { - const [isCollapsed, setIsCollapsed] = useState(collapsed === true), - btn = button({ - text: fragment(text, isCollapsed ? Icon.angleDown() : Icon.angleUp()), - icon, - outlined: isCollapsed && !intent, - minimal: !intent || (intent && !isCollapsed), - intent, - disabled, - onClick: () => { - const val = !isCollapsed; - setIsCollapsed(val); - clickHandler?.(val); - } - }); - - return legend( - tooltip - ? bpTooltip({ - item: btn, - content: tooltip, - intent - }) - : btn - ); - } - }); diff --git a/mobile/cmp/button/card/CollapseToggleButton.ts b/mobile/cmp/button/card/CollapseToggleButton.ts new file mode 100644 index 000000000..34e2ede9e --- /dev/null +++ b/mobile/cmp/button/card/CollapseToggleButton.ts @@ -0,0 +1,56 @@ +import {CardModel} from '@xh/hoist/cmp/card/CardModel'; +import {fragment, legend} from '@xh/hoist/cmp/layout'; +import {hoistCmp, useContextModel} from '@xh/hoist/core'; +import {button, type ButtonProps} from '@xh/hoist/mobile/cmp/button'; +import {Icon} from '@xh/hoist/icon'; +import {tooltip as bpTooltip} from '@xh/hoist/kit/blueprint'; +import {logError, withDefault} from '@xh/hoist/utils/js'; +import {type ReactElement} from 'react'; + +export interface CollapseToggleButtonProps extends Omit { + cardModel?: CardModel; + tooltip?: ReactElement | string; +} + +/** + * A convenience button to toggle a Card's expand / collapse state. + */ +export const [CollapseToggleButton, collapseToggleButton] = + hoistCmp.withFactory({ + displayName: 'CollapseToggleButton', + className: 'xh-collapse-toggle-button', + model: false, + + render({className, cardModel, disabled, intent, text, tooltip, ...rest}, ref) { + cardModel = withDefault(cardModel, useContextModel(CardModel)); + + if (!cardModel) { + logError( + 'No CardModel available - provide via `cardModel` prop or context - button will be disabled.', + CollapseToggleButton + ); + disabled = true; + } + + const {collapsed} = cardModel, + btn = button({ + ref, + text: fragment(text, collapsed ? Icon.angleDown() : Icon.angleUp()), + onClick: () => (cardModel.collapsed = !collapsed), + className, + disabled, + intent, + ...rest + }); + + return legend( + tooltip + ? bpTooltip({ + item: btn, + content: tooltip, + intent + }) + : btn + ); + } + }); diff --git a/styles/vars.scss b/styles/vars.scss index 1bc8c55ea..00bdd8111 100644 --- a/styles/vars.scss +++ b/styles/vars.scss @@ -353,6 +353,15 @@ body { } } + //------------------------ + // Cards + //------------------------ + --xh-card-danger-color: var(--card-danger-color, var(--xh-intent-danger-text-color)); + --xh-card-primary-color: var(--card-primary-color, var(--xh-intent-primary-text-color)); + --xh-card-success-color: var(--card-success-color, var(--xh-intent-success-text-color)); + --xh-card-warning-color: var(--card-warning-color, var(--xh-intent-warning-text-color)); + + //------------------------ // Menus //------------------------