From 205d6415f16be9f24cfca871d998205dfd865e6c Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Wed, 14 Jan 2026 11:42:28 -0500 Subject: [PATCH 1/5] Checkpoint: create new FieldSet component --- cmp/layout/CollapsibleSet.scss | 18 ++--- cmp/layout/CollapsibleSet.ts | 48 ++++++------ desktop/cmp/form/FormField.ts | 21 +++++- desktop/cmp/form/fieldset/FieldSet.scss | 11 +++ desktop/cmp/form/fieldset/FieldSet.ts | 86 ++++++++++++++++++++++ desktop/cmp/form/fieldset/FieldSetModel.ts | 75 +++++++++++++++++++ desktop/cmp/form/index.ts | 1 + 7 files changed, 225 insertions(+), 35 deletions(-) create mode 100644 desktop/cmp/form/fieldset/FieldSet.scss create mode 100644 desktop/cmp/form/fieldset/FieldSet.ts create mode 100644 desktop/cmp/form/fieldset/FieldSetModel.ts diff --git a/cmp/layout/CollapsibleSet.scss b/cmp/layout/CollapsibleSet.scss index 7ae875db55..4a42a47358 100644 --- a/cmp/layout/CollapsibleSet.scss +++ b/cmp/layout/CollapsibleSet.scss @@ -6,41 +6,37 @@ */ .xh-collapsible-set { + border-color: var(--xh-border-color); + border-width: var(--xh-border-width-px); + &--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 { + &--intent-primary { border-color: var(--xh-intent-primary-trans2); &.xh-collapsible-set--enabled { border-color: var(--xh-intent-primary); } } - &.xh-collapsible-set--intent-success { + &--intent-success { border-color: var(--xh-intent-success-trans2); &.xh-collapsible-set--enabled { border-color: var(--xh-intent-success); } } - &.xh-collapsible-set--intent-warning { + &--intent-warning { border-color: var(--xh-intent-warning-trans2); &.xh-collapsible-set--enabled { border-color: var(--xh-intent-warning); } } - &.xh-collapsible-set--intent-danger { + &--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 index 5e8ba946a5..4ea7d74fd2 100644 --- a/cmp/layout/CollapsibleSet.ts +++ b/cmp/layout/CollapsibleSet.ts @@ -5,16 +5,16 @@ * 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 {fieldset, vbox} from '@xh/hoist/cmp/layout'; 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 {BoxProps, hoistCmp, XH} from '@xh/hoist/core'; import {collapsibleSetButton as desktopCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/desktop'; import {collapsibleSetButton as mobileCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/mobile'; +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 FieldsetHTMLAttributes, type ReactElement, type ReactNode, useState} from 'react'; import './CollapsibleSet.scss'; @@ -22,12 +22,13 @@ export interface CollapsibleSetProps extends FieldsetHTMLAttributes, HoistProps, TestSupportProps, LayoutProps { icon?: ReactElement; label: ReactNode; - tooltip?: JSX.Element | string; + tooltip?: ReactElement | string; intent?: Intent; clickHandler?: () => void; collapsed?: boolean; hideItemCount?: boolean; renderMode?: RenderMode; + innerBoxProps?: BoxProps; } export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory({ @@ -44,10 +45,8 @@ export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory(collapsed === true), [expandCount, setExpandCount] = useState(!collapsed ? 1 : 0), @@ -89,25 +84,26 @@ export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory({ logWarn(`Unable to bind FormField to field "${field}" on backing FormModel`, FormField); } + // If within a FieldSet, register with its model for validation grouping + const fieldSetModel = useContextModel(FieldSetModel); + useEffect(() => { + if (fieldSetModel && model) { + fieldSetModel.addFieldModel(model); + return () => fieldSetModel.removeFieldModel(model); + } + }, [fieldSetModel, model]); + // Model related props const isRequired = model?.isRequired || false, readonly = model?.readonly || false, diff --git a/desktop/cmp/form/fieldset/FieldSet.scss b/desktop/cmp/form/fieldset/FieldSet.scss new file mode 100644 index 0000000000..de7f5f2d19 --- /dev/null +++ b/desktop/cmp/form/fieldset/FieldSet.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-field-set-tooltip { + margin: 0; + padding: 0 1em 0 2em; +} diff --git a/desktop/cmp/form/fieldset/FieldSet.ts b/desktop/cmp/form/fieldset/FieldSet.ts new file mode 100644 index 0000000000..35a5894d5e --- /dev/null +++ b/desktop/cmp/form/fieldset/FieldSet.ts @@ -0,0 +1,86 @@ +/* + * 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 {collapsibleSet} from '@xh/hoist/cmp/layout/CollapsibleSet'; +import { + creates, + hoistCmp, + HoistProps, + Intent, + type LayoutProps, + type TestSupportProps, + useContextModel +} from '@xh/hoist/core'; +import {ValidationSeverity} from '@xh/hoist/data'; +import {FieldSetModel} from '@xh/hoist/desktop/cmp/form/fieldset/FieldSetModel'; +import {type FieldsetHTMLAttributes, ReactElement, type ReactNode, useEffect} from 'react'; +import './FieldSet.scss'; + +export interface FieldSetProps + extends + HoistProps, + FieldsetHTMLAttributes, + TestSupportProps, + LayoutProps { + icon?: ReactElement; + label: ReactNode; + clickHandler?: () => void; + collapsed?: boolean; + hideItemCount?: boolean; +} + +export const [FieldSet, fieldSet] = hoistCmp.withFactory({ + displayName: 'FieldSet', + model: creates(FieldSetModel, {publishMode: 'limited'}), + render({model, ...props}) { + // Handle nested FieldSets + const fieldSetModel = useContextModel(FieldSetModel); + useEffect(() => { + if (fieldSetModel) { + fieldSetModel.addFieldSetModel(model); + return () => fieldSetModel.removeFieldSetModel(model); + } + }, [fieldSetModel, 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-field-set-tooltip', + items: displayedValidationMessages.map((it, idx) => + li({key: idx, item: it}) + ) + }); + } + + return collapsibleSet({ + intent: intentForSeverity(displayedSeverity), + renderMode: 'always', + tooltip, + ...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/fieldset/FieldSetModel.ts b/desktop/cmp/form/fieldset/FieldSetModel.ts new file mode 100644 index 0000000000..662baf7842 --- /dev/null +++ b/desktop/cmp/form/fieldset/FieldSetModel.ts @@ -0,0 +1,75 @@ +/* + * 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 {FieldModel} from '@xh/hoist/cmp/form'; +import {HoistModel} 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 class FieldSetModel extends HoistModel { + @observable.ref private fieldModelRegistry: FieldModel[] = []; + @observable.ref private fieldSetModelRegistry: FieldSetModel[] = []; + + @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; + } + + @computed + private get fieldModels(): FieldModel[] { + return [ + ...this.fieldModelRegistry, + ...this.fieldSetModelRegistry.flatMap(it => it.fieldModels) + ]; + } + + constructor() { + super(); + makeObservable(this); + } + + @action + addFieldModel(fieldModel: FieldModel) { + this.fieldModelRegistry = uniq([...this.fieldModelRegistry, fieldModel]); + } + + @action + removeFieldModel(fieldModel: FieldModel) { + this.fieldModelRegistry = this.fieldModelRegistry.filter(it => it !== fieldModel); + } + + @action + addFieldSetModel(fieldSetModel: FieldSetModel) { + this.fieldSetModelRegistry = uniq([...this.fieldSetModelRegistry, fieldSetModel]); + } + + @action + removeFieldSetModel(fieldSetModel: FieldSetModel) { + this.fieldSetModelRegistry = this.fieldSetModelRegistry.filter(it => it !== fieldSetModel); + } +} diff --git a/desktop/cmp/form/index.ts b/desktop/cmp/form/index.ts index dfeb374889..929ba3f8f1 100644 --- a/desktop/cmp/form/index.ts +++ b/desktop/cmp/form/index.ts @@ -1 +1,2 @@ export * from './FormField'; +export * from './fieldset/FieldSet'; From b69318ac8c688575f9d490380f9ae5fac888f638 Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Thu, 15 Jan 2026 09:43:54 -0500 Subject: [PATCH 2/5] Rename FieldSet -> CollapsibleFieldSet --- desktop/cmp/form/FormField.ts | 4 +- .../CollapsibleFieldSet.scss} | 2 +- .../CollapsibleFieldSet.ts | 88 +++++++++++++++++++ .../CollapsibleFieldSetModel.ts} | 19 ++-- desktop/cmp/form/fieldset/FieldSet.ts | 86 ------------------ desktop/cmp/form/index.ts | 2 +- 6 files changed, 104 insertions(+), 97 deletions(-) rename desktop/cmp/form/{fieldset/FieldSet.scss => collapsiblefieldset/CollapsibleFieldSet.scss} (86%) create mode 100644 desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.ts rename desktop/cmp/form/{fieldset/FieldSetModel.ts => collapsiblefieldset/CollapsibleFieldSetModel.ts} (71%) delete mode 100644 desktop/cmp/form/fieldset/FieldSet.ts diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index 6ff1b28790..0960da9dcd 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -21,7 +21,7 @@ import { import '@xh/hoist/desktop/register'; import {instanceManager} from '@xh/hoist/core/impl/InstanceManager'; import {maxSeverity, ValidationResult} from '@xh/hoist/data'; -import {FieldSetModel} from '@xh/hoist/desktop/cmp/form/fieldset/FieldSetModel'; +import {CollapsibleFieldSetModel} from '@xh/hoist/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel'; import {fmtDate, fmtDateTime, fmtJson, fmtNumber} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {tooltip} from '@xh/hoist/kit/blueprint'; @@ -130,7 +130,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ } // If within a FieldSet, register with its model for validation grouping - const fieldSetModel = useContextModel(FieldSetModel); + const fieldSetModel = useContextModel(CollapsibleFieldSetModel); useEffect(() => { if (fieldSetModel && model) { fieldSetModel.addFieldModel(model); diff --git a/desktop/cmp/form/fieldset/FieldSet.scss b/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.scss similarity index 86% rename from desktop/cmp/form/fieldset/FieldSet.scss rename to desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.scss index de7f5f2d19..96a3125d9b 100644 --- a/desktop/cmp/form/fieldset/FieldSet.scss +++ b/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.scss @@ -5,7 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ -ul.xh-field-set-tooltip { +ul.xh-collapsible-field-set-tooltip { margin: 0; padding: 0 1em 0 2em; } diff --git a/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.ts b/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.ts new file mode 100644 index 0000000000..c3ce5baae8 --- /dev/null +++ b/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.ts @@ -0,0 +1,88 @@ +/* + * 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 {collapsibleSet} from '@xh/hoist/cmp/layout/CollapsibleSet'; +import { + creates, + hoistCmp, + HoistProps, + Intent, + type LayoutProps, + type TestSupportProps, + useContextModel +} from '@xh/hoist/core'; +import {ValidationSeverity} from '@xh/hoist/data'; +import {CollapsibleFieldSetModel} from '@xh/hoist/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel'; +import {type FieldsetHTMLAttributes, ReactElement, type ReactNode, useEffect} from 'react'; +import './CollapsibleFieldSet.scss'; + +export interface CollapsibleFieldSetProps + extends + HoistProps, + FieldsetHTMLAttributes, + TestSupportProps, + LayoutProps { + icon?: ReactElement; + label: ReactNode; + clickHandler?: () => void; + collapsed?: boolean; + hideItemCount?: boolean; +} + +export const [CollapsibleFieldSet, collapsibleFieldSet] = + hoistCmp.withFactory({ + displayName: 'CollapsibleFieldSet', + model: creates(CollapsibleFieldSetModel, {publishMode: 'limited'}), + render({model, ...props}) { + // Handle nested CollapsibleFieldSets + const collapsibleFieldSetModel = useContextModel(CollapsibleFieldSetModel); + useEffect(() => { + if (collapsibleFieldSetModel) { + collapsibleFieldSetModel.addCollapsibleFieldSetModel(model); + return () => collapsibleFieldSetModel.removeCollapsibleFieldSetModel(model); + } + }, [collapsibleFieldSetModel, 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-field-set-tooltip', + items: displayedValidationMessages.map((it, idx) => + li({key: idx, item: it}) + ) + }); + } + + return collapsibleSet({ + intent: intentForSeverity(displayedSeverity), + renderMode: 'always', + 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/fieldset/FieldSetModel.ts b/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel.ts similarity index 71% rename from desktop/cmp/form/fieldset/FieldSetModel.ts rename to desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel.ts index 662baf7842..f2caa88bd9 100644 --- a/desktop/cmp/form/fieldset/FieldSetModel.ts +++ b/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel.ts @@ -12,9 +12,9 @@ import {makeObservable} from '@xh/hoist/mobx'; import {uniq} from 'lodash'; import {action, computed, observable} from 'mobx'; -export class FieldSetModel extends HoistModel { +export class CollapsibleFieldSetModel extends HoistModel { @observable.ref private fieldModelRegistry: FieldModel[] = []; - @observable.ref private fieldSetModelRegistry: FieldSetModel[] = []; + @observable.ref private collapsibleFieldSetModelRegistry: CollapsibleFieldSetModel[] = []; @computed get displayedSeverity(): ValidationSeverity { @@ -44,7 +44,7 @@ export class FieldSetModel extends HoistModel { private get fieldModels(): FieldModel[] { return [ ...this.fieldModelRegistry, - ...this.fieldSetModelRegistry.flatMap(it => it.fieldModels) + ...this.collapsibleFieldSetModelRegistry.flatMap(it => it.fieldModels) ]; } @@ -64,12 +64,17 @@ export class FieldSetModel extends HoistModel { } @action - addFieldSetModel(fieldSetModel: FieldSetModel) { - this.fieldSetModelRegistry = uniq([...this.fieldSetModelRegistry, fieldSetModel]); + addCollapsibleFieldSetModel(collapsibleFieldSetModel: CollapsibleFieldSetModel) { + this.collapsibleFieldSetModelRegistry = uniq([ + ...this.collapsibleFieldSetModelRegistry, + collapsibleFieldSetModel + ]); } @action - removeFieldSetModel(fieldSetModel: FieldSetModel) { - this.fieldSetModelRegistry = this.fieldSetModelRegistry.filter(it => it !== fieldSetModel); + removeCollapsibleFieldSetModel(collapsibleFieldSetModel: CollapsibleFieldSetModel) { + this.collapsibleFieldSetModelRegistry = this.collapsibleFieldSetModelRegistry.filter( + it => it !== collapsibleFieldSetModel + ); } } diff --git a/desktop/cmp/form/fieldset/FieldSet.ts b/desktop/cmp/form/fieldset/FieldSet.ts deleted file mode 100644 index 35a5894d5e..0000000000 --- a/desktop/cmp/form/fieldset/FieldSet.ts +++ /dev/null @@ -1,86 +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 {li, ul} from '@xh/hoist/cmp/layout'; -import {collapsibleSet} from '@xh/hoist/cmp/layout/CollapsibleSet'; -import { - creates, - hoistCmp, - HoistProps, - Intent, - type LayoutProps, - type TestSupportProps, - useContextModel -} from '@xh/hoist/core'; -import {ValidationSeverity} from '@xh/hoist/data'; -import {FieldSetModel} from '@xh/hoist/desktop/cmp/form/fieldset/FieldSetModel'; -import {type FieldsetHTMLAttributes, ReactElement, type ReactNode, useEffect} from 'react'; -import './FieldSet.scss'; - -export interface FieldSetProps - extends - HoistProps, - FieldsetHTMLAttributes, - TestSupportProps, - LayoutProps { - icon?: ReactElement; - label: ReactNode; - clickHandler?: () => void; - collapsed?: boolean; - hideItemCount?: boolean; -} - -export const [FieldSet, fieldSet] = hoistCmp.withFactory({ - displayName: 'FieldSet', - model: creates(FieldSetModel, {publishMode: 'limited'}), - render({model, ...props}) { - // Handle nested FieldSets - const fieldSetModel = useContextModel(FieldSetModel); - useEffect(() => { - if (fieldSetModel) { - fieldSetModel.addFieldSetModel(model); - return () => fieldSetModel.removeFieldSetModel(model); - } - }, [fieldSetModel, 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-field-set-tooltip', - items: displayedValidationMessages.map((it, idx) => - li({key: idx, item: it}) - ) - }); - } - - return collapsibleSet({ - intent: intentForSeverity(displayedSeverity), - renderMode: 'always', - tooltip, - ...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/index.ts b/desktop/cmp/form/index.ts index 929ba3f8f1..aae9d5148d 100644 --- a/desktop/cmp/form/index.ts +++ b/desktop/cmp/form/index.ts @@ -1,2 +1,2 @@ export * from './FormField'; -export * from './fieldset/FieldSet'; +export * from '@xh/hoist/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet'; From 31d260cd8609f0d5db59937ef3f0c3c92d57a3c6 Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Thu, 15 Jan 2026 11:35:13 -0500 Subject: [PATCH 3/5] Add CollapsibleFieldSetModel / other refactors --- .../CollapsibleSet.scss | 0 .../CollapsibleSet.ts | 61 +++++++------ cmp/collapsibleset/CollapsibleSetModel.ts | 90 +++++++++++++++++++ cmp/collapsibleset/index.ts | 7 ++ desktop/cmp/button/CollapsibleSetButton.ts | 25 ++---- .../canvas/widgetwell/DashCanvasWidgetWell.ts | 3 +- desktop/cmp/form/FormField.ts | 6 +- .../CollapsibleFieldSet.ts | 29 +++--- .../CollapsibleFieldSetModel.ts | 67 +++++++++++--- mobile/cmp/button/CollapsibleSetButton.ts | 27 +++--- 10 files changed, 229 insertions(+), 86 deletions(-) rename cmp/{layout => collapsibleset}/CollapsibleSet.scss (100%) rename cmp/{layout => collapsibleset}/CollapsibleSet.ts (70%) create mode 100644 cmp/collapsibleset/CollapsibleSetModel.ts create mode 100644 cmp/collapsibleset/index.ts diff --git a/cmp/layout/CollapsibleSet.scss b/cmp/collapsibleset/CollapsibleSet.scss similarity index 100% rename from cmp/layout/CollapsibleSet.scss rename to cmp/collapsibleset/CollapsibleSet.scss diff --git a/cmp/layout/CollapsibleSet.ts b/cmp/collapsibleset/CollapsibleSet.ts similarity index 70% rename from cmp/layout/CollapsibleSet.ts rename to cmp/collapsibleset/CollapsibleSet.ts index 4ea7d74fd2..ef47a044c6 100644 --- a/cmp/layout/CollapsibleSet.ts +++ b/cmp/collapsibleset/CollapsibleSet.ts @@ -6,7 +6,7 @@ */ import {fieldset, vbox} from '@xh/hoist/cmp/layout'; -import type {HoistProps, Intent, LayoutProps, RenderMode, TestSupportProps} from '@xh/hoist/core'; +import {HoistProps, Intent, LayoutProps, TestSupportProps, uses} from '@xh/hoist/core'; import {BoxProps, hoistCmp, XH} from '@xh/hoist/core'; import {collapsibleSetButton as desktopCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/desktop'; import {collapsibleSetButton as mobileCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/mobile'; @@ -14,55 +14,64 @@ 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 FieldsetHTMLAttributes, type ReactElement, type ReactNode, useState} from 'react'; +import {type FieldsetHTMLAttributes, type ReactElement, type ReactNode, useRef} from 'react'; +import {CollapsibleSetModel} from './CollapsibleSetModel'; import './CollapsibleSet.scss'; export interface CollapsibleSetProps - extends FieldsetHTMLAttributes, HoistProps, TestSupportProps, LayoutProps { + extends + HoistProps, + FieldsetHTMLAttributes, + TestSupportProps, + LayoutProps { + /** An icon placed left of the label. */ icon?: ReactElement; + /** The label to display. */ label: ReactNode; + /** Tooltip to show when hovering over the label. */ tooltip?: ReactElement | string; + /** Intent to apply to the label and border. */ intent?: Intent; - clickHandler?: () => void; - collapsed?: boolean; + /** True to hide the item count in the label. */ hideItemCount?: boolean; - renderMode?: RenderMode; + /** Additional props to pass to the inner content box. */ innerBoxProps?: BoxProps; } export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory({ displayName: 'CollapsibleSet', - model: false, + model: uses(CollapsibleSetModel, { + fromContext: false, + publishMode: 'limited', + createDefault: true + }), className: 'xh-collapsible-set', render({ icon, label, tooltip, intent, - collapsed, children, hideItemCount, className, disabled, - renderMode = 'unmountOnHide', innerBoxProps = {}, + model, ...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); + const wasDisplayed = useRef(false), + {collapsed, renderMode} = model; + + let [layoutProps, {testId, ...restProps}] = splitLayoutProps(rest); restProps = mergeDeep({style: layoutProps}, {[TEST_ID]: testId}, restProps); - const [isCollapsed, setIsCollapsed] = useState(collapsed === true), - [expandCount, setExpandCount] = useState(!collapsed ? 1 : 0), - items = castArray(children), + const items = castArray(children), itemCount = hideItemCount === true ? '' : ` (${items.length})`, classes = []; - if (isCollapsed) { + if (collapsed) { classes.push('xh-collapsible-set--collapsed'); } else { classes.push('xh-collapsible-set--expanded'); @@ -84,11 +93,13 @@ export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory { - setIsCollapsed(val); - setExpandCount(expandCount + 1); - }, - collapsed: isCollapsed, + collapsed, disabled }), vbox({ className: 'xh-collapsible-set__content', items: content, - display: isCollapsed ? 'none' : 'flex', + display: collapsed ? 'none' : 'flex', flexWrap: 'wrap', ...innerBoxProps }) diff --git a/cmp/collapsibleset/CollapsibleSetModel.ts b/cmp/collapsibleset/CollapsibleSetModel.ts new file mode 100644 index 0000000000..a3a7d15d95 --- /dev/null +++ b/cmp/collapsibleset/CollapsibleSetModel.ts @@ -0,0 +1,90 @@ +/* + * 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 CollapsibleSetConfig { + /** Default collapsed state. */ + defaultCollapsed?: boolean; + + /** How should collapsed content be rendered? */ + renderMode?: RenderMode; + + /** Options governing persistence. */ + persistWith?: PersistOptions; +} + +export interface CollapsibleSetPersistState { + collapsed: boolean; +} + +/** + * CollapsibleSetModel supports configuration and state-management for user-driven expand/collapse, + * along with support for saving this state via a configured PersistenceProvider. + */ +export class CollapsibleSetModel + extends HoistModel + implements Persistable +{ + declare config: CollapsibleSetConfig; + + //----------------------- + // Immutable Properties + //----------------------- + readonly defaultCollapsed: boolean; + readonly renderMode: RenderMode; + + //--------------------- + // Observable State + //--------------------- + @bindable + collapsed: boolean = false; + + constructor({ + defaultCollapsed = false, + renderMode = 'unmountOnHide', + persistWith = null + }: CollapsibleSetConfig = {}) { + super(); + makeObservable(this); + + this.collapsed = this.defaultCollapsed = defaultCollapsed; + this.renderMode = renderMode; + if (persistWith) { + PersistenceProvider.create({ + persistOptions: { + path: 'collapsibleSet', + ...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/collapsibleset/index.ts b/cmp/collapsibleset/index.ts new file mode 100644 index 0000000000..694ac4af0c --- /dev/null +++ b/cmp/collapsibleset/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 './CollapsibleSet'; diff --git a/desktop/cmp/button/CollapsibleSetButton.ts b/desktop/cmp/button/CollapsibleSetButton.ts index c759be9cab..0ac3b6cb27 100644 --- a/desktop/cmp/button/CollapsibleSetButton.ts +++ b/desktop/cmp/button/CollapsibleSetButton.ts @@ -5,43 +5,36 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {type ReactElement, type ReactNode, type JSX, useState} from 'react'; +import {CollapsibleSetModel} from '@xh/hoist/cmp/collapsibleset/CollapsibleSetModel'; +import {type ReactElement, type ReactNode, type JSX} from 'react'; import {tooltip as bpTooltip} from '@xh/hoist/kit/blueprint'; -import {hoistCmp} from '@xh/hoist/core'; +import {hoistCmp, uses} 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 { +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), + model: uses(CollapsibleSetModel), + render({icon, text, tooltip, intent, disabled, model}) { + const {collapsed} = model, btn = button({ text, icon, - rightIcon: isCollapsed ? Icon.angleDown() : Icon.angleUp(), - outlined: isCollapsed && !intent, - minimal: !intent || (intent && !isCollapsed), + rightIcon: collapsed ? Icon.angleDown() : Icon.angleUp(), intent, disabled, - onClick: () => { - const val = !isCollapsed; - setIsCollapsed(val); - clickHandler?.(val); - } + onClick: () => (model.collapsed = !collapsed) }); return legend( diff --git a/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts b/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts index 56d44ea822..c1a91518d5 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 {collapsibleSet} from '@xh/hoist/cmp/collapsibleset/CollapsibleSet'; import './DashCanvasWidgetWell.scss'; @@ -124,7 +124,6 @@ function createDraggableItems(dashCanvasModel: DashCanvasModel, flexDirection): return collapsibleSet({ icon, - collapsed: false, label, flexDirection, items: items.map(it => it.item) diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index 0960da9dcd..423b11622f 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -133,15 +133,15 @@ export const [FormField, formField] = hoistCmp.withFactory({ const fieldSetModel = useContextModel(CollapsibleFieldSetModel); useEffect(() => { if (fieldSetModel && model) { - fieldSetModel.addFieldModel(model); - return () => fieldSetModel.removeFieldModel(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/collapsiblefieldset/CollapsibleFieldSet.ts b/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.ts index c3ce5baae8..3542296aa8 100644 --- a/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.ts +++ b/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.ts @@ -6,18 +6,19 @@ */ import {li, ul} from '@xh/hoist/cmp/layout'; -import {collapsibleSet} from '@xh/hoist/cmp/layout/CollapsibleSet'; +import {collapsibleSet} from '@xh/hoist/cmp/collapsibleset/CollapsibleSet'; import { - creates, hoistCmp, HoistProps, Intent, type LayoutProps, type TestSupportProps, - useContextModel + useContextModel, + uses } from '@xh/hoist/core'; import {ValidationSeverity} from '@xh/hoist/data'; import {CollapsibleFieldSetModel} from '@xh/hoist/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel'; +import {runInAction} from 'mobx'; import {type FieldsetHTMLAttributes, ReactElement, type ReactNode, useEffect} from 'react'; import './CollapsibleFieldSet.scss'; @@ -29,22 +30,30 @@ export interface CollapsibleFieldSetProps LayoutProps { icon?: ReactElement; label: ReactNode; - clickHandler?: () => void; - collapsed?: boolean; - hideItemCount?: boolean; } export const [CollapsibleFieldSet, collapsibleFieldSet] = hoistCmp.withFactory({ displayName: 'CollapsibleFieldSet', - model: creates(CollapsibleFieldSetModel, {publishMode: 'limited'}), + model: uses(CollapsibleFieldSetModel, { + fromContext: false, + publishMode: 'limited', + createDefault: true + }), render({model, ...props}) { // Handle nested CollapsibleFieldSets const collapsibleFieldSetModel = useContextModel(CollapsibleFieldSetModel); useEffect(() => { if (collapsibleFieldSetModel) { - collapsibleFieldSetModel.addCollapsibleFieldSetModel(model); - return () => collapsibleFieldSetModel.removeCollapsibleFieldSetModel(model); + runInAction(() => { + collapsibleFieldSetModel.registerChildCollapsibleFieldSetModel(model); + model.parent = collapsibleFieldSetModel; + }); + return () => + runInAction(() => { + collapsibleFieldSetModel.unregisterChildCollapsibleFieldSetModel(model); + model.parent = null; + }); } }, [collapsibleFieldSetModel, model]); @@ -65,8 +74,8 @@ export const [CollapsibleFieldSet, collapsibleFieldSet] = } return collapsibleSet({ + hideItemCount: true, intent: intentForSeverity(displayedSeverity), - renderMode: 'always', tooltip, model, ...props diff --git a/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel.ts b/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel.ts index f2caa88bd9..58b6be61a5 100644 --- a/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel.ts +++ b/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel.ts @@ -5,16 +5,41 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ +import {CollapsibleSetModel} from '@xh/hoist/cmp/collapsibleset/CollapsibleSetModel'; import {FieldModel} from '@xh/hoist/cmp/form'; -import {HoistModel} from '@xh/hoist/core'; +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 class CollapsibleFieldSetModel extends HoistModel { - @observable.ref private fieldModelRegistry: FieldModel[] = []; +export interface CollapsibleFieldSetConfig { + /** Default collapsed state. */ + defaultCollapsed?: boolean; + + /** True to disable all descendant fields. */ + disabled?: boolean; + + /** Options governing persistence. */ + persistWith?: PersistOptions; +} + +export class CollapsibleFieldSetModel extends CollapsibleSetModel { + declare config: CollapsibleFieldSetConfig; + + @observable.ref parent: CollapsibleFieldSetModel | null; + + //----------------- + // Implementation + //----------------- @observable.ref private collapsibleFieldSetModelRegistry: CollapsibleFieldSetModel[] = []; + @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 { @@ -40,6 +65,25 @@ export class CollapsibleFieldSetModel extends HoistModel { return ret; } + constructor({disabled = false, ...rest}: CollapsibleFieldSetConfig = {}) { + super({...rest, renderMode: 'always'}); + makeObservable(this); + this.isDisabled = disabled; + } + + @action + setDisabled(disabled: boolean) { + this.isDisabled = disabled; + } + + //------------------------ + // Implementation + //------------------------ + @computed + private get ancestors(): CollapsibleFieldSetModel[] { + return this.parent ? [this.parent, ...this.parent.ancestors] : []; + } + @computed private get fieldModels(): FieldModel[] { return [ @@ -48,31 +92,30 @@ export class CollapsibleFieldSetModel extends HoistModel { ]; } - constructor() { - super(); - makeObservable(this); - } - + /** @internal */ @action - addFieldModel(fieldModel: FieldModel) { + registerChildFieldModel(fieldModel: FieldModel) { this.fieldModelRegistry = uniq([...this.fieldModelRegistry, fieldModel]); } + /** @internal */ @action - removeFieldModel(fieldModel: FieldModel) { + unregisterChildFieldModel(fieldModel: FieldModel) { this.fieldModelRegistry = this.fieldModelRegistry.filter(it => it !== fieldModel); } + /** @internal */ @action - addCollapsibleFieldSetModel(collapsibleFieldSetModel: CollapsibleFieldSetModel) { + registerChildCollapsibleFieldSetModel(collapsibleFieldSetModel: CollapsibleFieldSetModel) { this.collapsibleFieldSetModelRegistry = uniq([ ...this.collapsibleFieldSetModelRegistry, collapsibleFieldSetModel ]); } + /** @internal */ @action - removeCollapsibleFieldSetModel(collapsibleFieldSetModel: CollapsibleFieldSetModel) { + unregisterChildCollapsibleFieldSetModel(collapsibleFieldSetModel: CollapsibleFieldSetModel) { this.collapsibleFieldSetModelRegistry = this.collapsibleFieldSetModelRegistry.filter( it => it !== collapsibleFieldSetModel ); diff --git a/mobile/cmp/button/CollapsibleSetButton.ts b/mobile/cmp/button/CollapsibleSetButton.ts index 33e6cbf0da..b6b5de0550 100644 --- a/mobile/cmp/button/CollapsibleSetButton.ts +++ b/mobile/cmp/button/CollapsibleSetButton.ts @@ -5,43 +5,38 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {type ReactElement, type ReactNode, type JSX, useState} from 'react'; +import {CollapsibleSetModel} from '@xh/hoist/cmp/collapsibleset/CollapsibleSetModel'; +import {type ReactElement, type ReactNode, type JSX} 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 {hoistCmp, uses} 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 { +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), + model: uses(CollapsibleSetModel), + render({icon, text, tooltip, intent, disabled, model}) { + const {collapsed} = model, btn = button({ - text: fragment(text, isCollapsed ? Icon.angleDown() : Icon.angleUp()), + text: fragment(text, collapsed ? Icon.angleDown() : Icon.angleUp()), icon, - outlined: isCollapsed && !intent, - minimal: !intent || (intent && !isCollapsed), + outlined: collapsed && !intent, + minimal: !intent || (intent && !collapsed), intent, disabled, - onClick: () => { - const val = !isCollapsed; - setIsCollapsed(val); - clickHandler?.(val); - } + onClick: () => (model.collapsed = !collapsed) }); return legend( From a6314597e242c526b815428ad9998b262df2d914 Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Thu, 15 Jan 2026 12:06:32 -0500 Subject: [PATCH 4/5] Add collapsiblefieldset/index.ts --- desktop/cmp/form/collapsiblefieldset/index.ts | 2 ++ desktop/cmp/form/index.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 desktop/cmp/form/collapsiblefieldset/index.ts diff --git a/desktop/cmp/form/collapsiblefieldset/index.ts b/desktop/cmp/form/collapsiblefieldset/index.ts new file mode 100644 index 0000000000..e45995906e --- /dev/null +++ b/desktop/cmp/form/collapsiblefieldset/index.ts @@ -0,0 +1,2 @@ +export * from './CollapsibleFieldSet'; +export * from './CollapsibleFieldSetModel'; diff --git a/desktop/cmp/form/index.ts b/desktop/cmp/form/index.ts index aae9d5148d..865a2af0f6 100644 --- a/desktop/cmp/form/index.ts +++ b/desktop/cmp/form/index.ts @@ -1,2 +1,2 @@ export * from './FormField'; -export * from '@xh/hoist/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet'; +export * from './collapsiblefieldset'; From e1c29dcb296f880f95ffb3f58f6071e22fdbab53 Mon Sep 17 00:00:00 2001 From: Greg Solomon Date: Fri, 16 Jan 2026 12:30:11 -0500 Subject: [PATCH 5/5] Rename CollapsibleSet -> Card + other changes from discussion w/ Anselm + Colin --- cmp/card/Card.scss | 57 ++++++ cmp/card/Card.ts | 167 ++++++++++++++++++ .../CardModel.ts} | 30 ++-- cmp/{collapsibleset => card}/index.ts | 2 +- cmp/collapsibleset/CollapsibleSet.scss | 45 ----- cmp/collapsibleset/CollapsibleSet.ts | 144 --------------- desktop/appcontainer/AppContainer.ts | 4 +- desktop/cmp/button/CollapsibleSetButton.ts | 50 ------ .../cmp/button/card/CollapseToggleButton.ts | 56 ++++++ .../canvas/widgetwell/DashCanvasWidgetWell.ts | 8 +- desktop/cmp/form/FormField.ts | 6 +- .../CollapsibleFieldSet.ts | 97 ---------- desktop/cmp/form/collapsiblefieldset/index.ts | 2 - .../FormFieldSet.scss} | 2 +- desktop/cmp/form/formfieldset/FormFieldSet.ts | 96 ++++++++++ .../FormFieldSetModel.ts} | 35 ++-- desktop/cmp/form/formfieldset/index.ts | 2 + desktop/cmp/form/index.ts | 2 +- dynamics/desktop.ts | 4 +- dynamics/mobile.ts | 4 +- mobile/appcontainer/AppContainer.ts | 4 +- mobile/cmp/button/CollapsibleSetButton.ts | 52 ------ .../cmp/button/card/CollapseToggleButton.ts | 56 ++++++ styles/vars.scss | 9 + 24 files changed, 497 insertions(+), 437 deletions(-) create mode 100644 cmp/card/Card.scss create mode 100644 cmp/card/Card.ts rename cmp/{collapsibleset/CollapsibleSetModel.ts => card/CardModel.ts} (71%) rename cmp/{collapsibleset => card}/index.ts (85%) delete mode 100644 cmp/collapsibleset/CollapsibleSet.scss delete mode 100644 cmp/collapsibleset/CollapsibleSet.ts delete mode 100644 desktop/cmp/button/CollapsibleSetButton.ts create mode 100644 desktop/cmp/button/card/CollapseToggleButton.ts delete mode 100644 desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.ts delete mode 100644 desktop/cmp/form/collapsiblefieldset/index.ts rename desktop/cmp/form/{collapsiblefieldset/CollapsibleFieldSet.scss => formfieldset/FormFieldSet.scss} (86%) create mode 100644 desktop/cmp/form/formfieldset/FormFieldSet.ts rename desktop/cmp/form/{collapsiblefieldset/CollapsibleFieldSetModel.ts => formfieldset/FormFieldSetModel.ts} (71%) create mode 100644 desktop/cmp/form/formfieldset/index.ts delete mode 100644 mobile/cmp/button/CollapsibleSetButton.ts create mode 100644 mobile/cmp/button/card/CollapseToggleButton.ts diff --git a/cmp/card/Card.scss b/cmp/card/Card.scss new file mode 100644 index 0000000000..621c156448 --- /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 0000000000..476b309656 --- /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/collapsibleset/CollapsibleSetModel.ts b/cmp/card/CardModel.ts similarity index 71% rename from cmp/collapsibleset/CollapsibleSetModel.ts rename to cmp/card/CardModel.ts index a3a7d15d95..00070b9b63 100644 --- a/cmp/collapsibleset/CollapsibleSetModel.ts +++ b/cmp/card/CardModel.ts @@ -16,7 +16,10 @@ import { import {bindable, makeObservable} from '@xh/hoist/mobx'; import {isNil} from 'lodash'; -export interface CollapsibleSetConfig { +export interface CardConfig { + /** Can card be collapsed? */ + collapsible?: boolean; + /** Default collapsed state. */ defaultCollapsed?: boolean; @@ -27,23 +30,21 @@ export interface CollapsibleSetConfig { persistWith?: PersistOptions; } -export interface CollapsibleSetPersistState { +export interface CardPersistState { collapsed: boolean; } /** - * CollapsibleSetModel supports configuration and state-management for user-driven expand/collapse, + * CardModel supports configuration and state-management for user-driven expand/collapse, * along with support for saving this state via a configured PersistenceProvider. */ -export class CollapsibleSetModel - extends HoistModel - implements Persistable -{ - declare config: CollapsibleSetConfig; +export class CardModel extends HoistModel implements Persistable { + declare config: CardConfig; //----------------------- // Immutable Properties //----------------------- + readonly collapsible: boolean; readonly defaultCollapsed: boolean; readonly renderMode: RenderMode; @@ -54,19 +55,22 @@ export class CollapsibleSetModel collapsed: boolean = false; constructor({ + collapsible = true, defaultCollapsed = false, renderMode = 'unmountOnHide', persistWith = null - }: CollapsibleSetConfig = {}) { + }: CardConfig = {}) { super(); makeObservable(this); - this.collapsed = this.defaultCollapsed = defaultCollapsed; + this.collapsible = collapsible; + this.collapsed = this.defaultCollapsed = collapsible && defaultCollapsed; this.renderMode = renderMode; + if (persistWith) { PersistenceProvider.create({ persistOptions: { - path: 'collapsibleSet', + path: 'card', ...persistWith }, target: this @@ -77,11 +81,11 @@ export class CollapsibleSetModel //--------------------- // Persistable Interface //--------------------- - getPersistableState(): PersistableState { + getPersistableState(): PersistableState { return new PersistableState({collapsed: this.collapsed}); } - setPersistableState(state: PersistableState) { + setPersistableState(state: PersistableState) { const {collapsed} = state.value; if (!isNil(collapsed)) { this.collapsed = collapsed; diff --git a/cmp/collapsibleset/index.ts b/cmp/card/index.ts similarity index 85% rename from cmp/collapsibleset/index.ts rename to cmp/card/index.ts index 694ac4af0c..99d29d109b 100644 --- a/cmp/collapsibleset/index.ts +++ b/cmp/card/index.ts @@ -4,4 +4,4 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -export * from './CollapsibleSet'; +export * from './Card'; diff --git a/cmp/collapsibleset/CollapsibleSet.scss b/cmp/collapsibleset/CollapsibleSet.scss deleted file mode 100644 index 4a42a47358..0000000000 --- a/cmp/collapsibleset/CollapsibleSet.scss +++ /dev/null @@ -1,45 +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 { - 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-intent-primary-trans2); - &.xh-collapsible-set--enabled { - border-color: var(--xh-intent-primary); - } - } - - &--intent-success { - border-color: var(--xh-intent-success-trans2); - &.xh-collapsible-set--enabled { - border-color: var(--xh-intent-success); - } - } - - &--intent-warning { - border-color: var(--xh-intent-warning-trans2); - &.xh-collapsible-set--enabled { - border-color: var(--xh-intent-warning); - } - } - - &--intent-danger { - border-color: var(--xh-intent-danger-trans2); - &.xh-collapsible-set--enabled { - border-color: var(--xh-intent-danger); - } - } -} diff --git a/cmp/collapsibleset/CollapsibleSet.ts b/cmp/collapsibleset/CollapsibleSet.ts deleted file mode 100644 index ef47a044c6..0000000000 --- a/cmp/collapsibleset/CollapsibleSet.ts +++ /dev/null @@ -1,144 +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 {fieldset, vbox} from '@xh/hoist/cmp/layout'; -import {HoistProps, Intent, LayoutProps, TestSupportProps, uses} from '@xh/hoist/core'; -import {BoxProps, hoistCmp, XH} from '@xh/hoist/core'; -import {collapsibleSetButton as desktopCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/desktop'; -import {collapsibleSetButton as mobileCollapsibleSetButtonImpl} from '@xh/hoist/dynamics/mobile'; -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 FieldsetHTMLAttributes, type ReactElement, type ReactNode, useRef} from 'react'; -import {CollapsibleSetModel} from './CollapsibleSetModel'; - -import './CollapsibleSet.scss'; - -export interface CollapsibleSetProps - extends - HoistProps, - FieldsetHTMLAttributes, - TestSupportProps, - LayoutProps { - /** An icon placed left of the label. */ - icon?: ReactElement; - /** The label to display. */ - label: ReactNode; - /** Tooltip to show when hovering over the label. */ - tooltip?: ReactElement | string; - /** Intent to apply to the label and border. */ - intent?: Intent; - /** True to hide the item count in the label. */ - hideItemCount?: boolean; - /** Additional props to pass to the inner content box. */ - innerBoxProps?: BoxProps; -} - -export const [CollapsibleSet, collapsibleSet] = hoistCmp.withFactory({ - displayName: 'CollapsibleSet', - model: uses(CollapsibleSetModel, { - fromContext: false, - publishMode: 'limited', - createDefault: true - }), - className: 'xh-collapsible-set', - render({ - icon, - label, - tooltip, - intent, - children, - hideItemCount, - className, - disabled, - innerBoxProps = {}, - model, - ...rest - }) { - const wasDisplayed = useRef(false), - {collapsed, renderMode} = model; - - let [layoutProps, {testId, ...restProps}] = splitLayoutProps(rest); - - restProps = mergeDeep({style: layoutProps}, {[TEST_ID]: testId}, restProps); - - const items = castArray(children), - itemCount = hideItemCount === true ? '' : ` (${items.length})`, - classes = []; - - if (collapsed) { - 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; - - if (collapsed) { - classes.push('xh-collapsible-set--collapsed'); - } else { - wasDisplayed.current = true; - } - - let content: ReactNode[]; - switch (renderMode) { - case 'always': - content = items; - classes.push('xh-collapsible-set--render-mode-always'); - break; - - case 'lazy': - content = collapsed && !wasDisplayed.current ? [] : items; - classes.push('xh-collapsible-set--render-mode-lazy'); - break; - - // unmountOnHide - default: - content = collapsed ? [] : items; - classes.push('xh-collapsible-set--render-mode-unmount-on-hide'); - break; - } - - return fieldset({ - className: classNames(className, classes), - items: [ - btn({ - icon, - text: `${label}${itemCount}`, - tooltip, - intent, - collapsed, - disabled - }), - vbox({ - className: 'xh-collapsible-set__content', - items: content, - display: collapsed ? 'none' : 'flex', - flexWrap: 'wrap', - ...innerBoxProps - }) - ], - disabled, - ...restProps - }); - } -}); diff --git a/desktop/appcontainer/AppContainer.ts b/desktop/appcontainer/AppContainer.ts index a825d2d7bd..e86eb0643a 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 0ac3b6cb27..0000000000 --- a/desktop/cmp/button/CollapsibleSetButton.ts +++ /dev/null @@ -1,50 +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 {CollapsibleSetModel} from '@xh/hoist/cmp/collapsibleset/CollapsibleSetModel'; -import {type ReactElement, type ReactNode, type JSX} from 'react'; -import {tooltip as bpTooltip} from '@xh/hoist/kit/blueprint'; -import {hoistCmp, uses} 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; - intent?: Intent; - disabled?: boolean; -} - -export const [CollapsibleSetButton, collapsibleSetButton] = - hoistCmp.withFactory({ - displayName: 'CollapsibleSetButton', - model: uses(CollapsibleSetModel), - render({icon, text, tooltip, intent, disabled, model}) { - const {collapsed} = model, - btn = button({ - text, - icon, - rightIcon: collapsed ? Icon.angleDown() : Icon.angleUp(), - intent, - disabled, - onClick: () => (model.collapsed = !collapsed) - }); - - 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 0000000000..a55076dd6d --- /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 c1a91518d5..c17f150056 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/collapsibleset/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,9 +122,9 @@ function createDraggableItems(dashCanvasModel: DashCanvasModel, flexDirection): ).length === 1, icon = sameIcons ? items[0].icon : null; - return collapsibleSet({ + return card({ icon, - label, + title, flexDirection, items: items.map(it => it.item) }); diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index 423b11622f..47136b02fe 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -21,7 +21,7 @@ import { import '@xh/hoist/desktop/register'; import {instanceManager} from '@xh/hoist/core/impl/InstanceManager'; import {maxSeverity, ValidationResult} from '@xh/hoist/data'; -import {CollapsibleFieldSetModel} from '@xh/hoist/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel'; +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'; @@ -129,8 +129,8 @@ export const [FormField, formField] = hoistCmp.withFactory({ logWarn(`Unable to bind FormField to field "${field}" on backing FormModel`, FormField); } - // If within a FieldSet, register with its model for validation grouping - const fieldSetModel = useContextModel(CollapsibleFieldSetModel); + // If within a FormFieldSet, register with its model for validation grouping + const fieldSetModel = useContextModel(FormFieldSetModel); useEffect(() => { if (fieldSetModel && model) { fieldSetModel.registerChildFieldModel(model); diff --git a/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.ts b/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.ts deleted file mode 100644 index 3542296aa8..0000000000 --- a/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.ts +++ /dev/null @@ -1,97 +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 {li, ul} from '@xh/hoist/cmp/layout'; -import {collapsibleSet} from '@xh/hoist/cmp/collapsibleset/CollapsibleSet'; -import { - hoistCmp, - HoistProps, - Intent, - type LayoutProps, - type TestSupportProps, - useContextModel, - uses -} from '@xh/hoist/core'; -import {ValidationSeverity} from '@xh/hoist/data'; -import {CollapsibleFieldSetModel} from '@xh/hoist/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel'; -import {runInAction} from 'mobx'; -import {type FieldsetHTMLAttributes, ReactElement, type ReactNode, useEffect} from 'react'; -import './CollapsibleFieldSet.scss'; - -export interface CollapsibleFieldSetProps - extends - HoistProps, - FieldsetHTMLAttributes, - TestSupportProps, - LayoutProps { - icon?: ReactElement; - label: ReactNode; -} - -export const [CollapsibleFieldSet, collapsibleFieldSet] = - hoistCmp.withFactory({ - displayName: 'CollapsibleFieldSet', - model: uses(CollapsibleFieldSetModel, { - fromContext: false, - publishMode: 'limited', - createDefault: true - }), - render({model, ...props}) { - // Handle nested CollapsibleFieldSets - const collapsibleFieldSetModel = useContextModel(CollapsibleFieldSetModel); - useEffect(() => { - if (collapsibleFieldSetModel) { - runInAction(() => { - collapsibleFieldSetModel.registerChildCollapsibleFieldSetModel(model); - model.parent = collapsibleFieldSetModel; - }); - return () => - runInAction(() => { - collapsibleFieldSetModel.unregisterChildCollapsibleFieldSetModel(model); - model.parent = null; - }); - } - }, [collapsibleFieldSetModel, 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-field-set-tooltip', - items: displayedValidationMessages.map((it, idx) => - li({key: idx, item: it}) - ) - }); - } - - return collapsibleSet({ - hideItemCount: true, - 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/collapsiblefieldset/index.ts b/desktop/cmp/form/collapsiblefieldset/index.ts deleted file mode 100644 index e45995906e..0000000000 --- a/desktop/cmp/form/collapsiblefieldset/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './CollapsibleFieldSet'; -export * from './CollapsibleFieldSetModel'; diff --git a/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.scss b/desktop/cmp/form/formfieldset/FormFieldSet.scss similarity index 86% rename from desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.scss rename to desktop/cmp/form/formfieldset/FormFieldSet.scss index 96a3125d9b..070ffba006 100644 --- a/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSet.scss +++ b/desktop/cmp/form/formfieldset/FormFieldSet.scss @@ -5,7 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ -ul.xh-collapsible-field-set-tooltip { +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 0000000000..958df8ee47 --- /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/collapsiblefieldset/CollapsibleFieldSetModel.ts b/desktop/cmp/form/formfieldset/FormFieldSetModel.ts similarity index 71% rename from desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel.ts rename to desktop/cmp/form/formfieldset/FormFieldSetModel.ts index 58b6be61a5..bf20134a17 100644 --- a/desktop/cmp/form/collapsiblefieldset/CollapsibleFieldSetModel.ts +++ b/desktop/cmp/form/formfieldset/FormFieldSetModel.ts @@ -5,7 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {CollapsibleSetModel} from '@xh/hoist/cmp/collapsibleset/CollapsibleSetModel'; +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'; @@ -13,7 +13,10 @@ import {makeObservable} from '@xh/hoist/mobx'; import {uniq} from 'lodash'; import {action, computed, observable} from 'mobx'; -export interface CollapsibleFieldSetConfig { +export interface FormFieldSetConfig { + /** Can form field set be collapsed? */ + collapsible?: boolean; + /** Default collapsed state. */ defaultCollapsed?: boolean; @@ -24,15 +27,15 @@ export interface CollapsibleFieldSetConfig { persistWith?: PersistOptions; } -export class CollapsibleFieldSetModel extends CollapsibleSetModel { - declare config: CollapsibleFieldSetConfig; +export class FormFieldSetModel extends CardModel { + declare config: FormFieldSetConfig; - @observable.ref parent: CollapsibleFieldSetModel | null; + @observable.ref parent: FormFieldSetModel | null; //----------------- // Implementation //----------------- - @observable.ref private collapsibleFieldSetModelRegistry: CollapsibleFieldSetModel[] = []; + @observable.ref private formFieldSetModelRegistry: FormFieldSetModel[] = []; @observable.ref private fieldModelRegistry: FieldModel[] = []; @observable private isDisabled: boolean; @@ -65,7 +68,7 @@ export class CollapsibleFieldSetModel extends CollapsibleSetModel { return ret; } - constructor({disabled = false, ...rest}: CollapsibleFieldSetConfig = {}) { + constructor({disabled = false, ...rest}: FormFieldSetConfig = {}) { super({...rest, renderMode: 'always'}); makeObservable(this); this.isDisabled = disabled; @@ -80,7 +83,7 @@ export class CollapsibleFieldSetModel extends CollapsibleSetModel { // Implementation //------------------------ @computed - private get ancestors(): CollapsibleFieldSetModel[] { + private get ancestors(): FormFieldSetModel[] { return this.parent ? [this.parent, ...this.parent.ancestors] : []; } @@ -88,7 +91,7 @@ export class CollapsibleFieldSetModel extends CollapsibleSetModel { private get fieldModels(): FieldModel[] { return [ ...this.fieldModelRegistry, - ...this.collapsibleFieldSetModelRegistry.flatMap(it => it.fieldModels) + ...this.formFieldSetModelRegistry.flatMap(it => it.fieldModels) ]; } @@ -106,18 +109,18 @@ export class CollapsibleFieldSetModel extends CollapsibleSetModel { /** @internal */ @action - registerChildCollapsibleFieldSetModel(collapsibleFieldSetModel: CollapsibleFieldSetModel) { - this.collapsibleFieldSetModelRegistry = uniq([ - ...this.collapsibleFieldSetModelRegistry, - collapsibleFieldSetModel + registerChildFormFieldSetModel(formFieldSetModel: FormFieldSetModel) { + this.formFieldSetModelRegistry = uniq([ + ...this.formFieldSetModelRegistry, + formFieldSetModel ]); } /** @internal */ @action - unregisterChildCollapsibleFieldSetModel(collapsibleFieldSetModel: CollapsibleFieldSetModel) { - this.collapsibleFieldSetModelRegistry = this.collapsibleFieldSetModelRegistry.filter( - it => it !== collapsibleFieldSetModel + 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 0000000000..cab9aff072 --- /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 865a2af0f6..8393a9cf6e 100644 --- a/desktop/cmp/form/index.ts +++ b/desktop/cmp/form/index.ts @@ -1,2 +1,2 @@ export * from './FormField'; -export * from './collapsiblefieldset'; +export * from './formfieldset'; diff --git a/dynamics/desktop.ts b/dynamics/desktop.ts index aeb54478d0..2efcb77116 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 24415ace98..cb2b6f0b7a 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 e25325a8af..a64e3123f8 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 b6b5de0550..0000000000 --- a/mobile/cmp/button/CollapsibleSetButton.ts +++ /dev/null @@ -1,52 +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 {CollapsibleSetModel} from '@xh/hoist/cmp/collapsibleset/CollapsibleSetModel'; -import {type ReactElement, type ReactNode, type JSX} from 'react'; -import {tooltip as bpTooltip} from '@xh/hoist/kit/blueprint'; -import {fragment} from '@xh/hoist/cmp/layout'; -import {hoistCmp, uses} 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; - intent?: Intent; - disabled?: boolean; -} - -export const [CollapsibleSetButton, collapsibleSetButton] = - hoistCmp.withFactory({ - displayName: 'CollapsibleSetButton', - model: uses(CollapsibleSetModel), - render({icon, text, tooltip, intent, disabled, model}) { - const {collapsed} = model, - btn = button({ - text: fragment(text, collapsed ? Icon.angleDown() : Icon.angleUp()), - icon, - outlined: collapsed && !intent, - minimal: !intent || (intent && !collapsed), - intent, - disabled, - onClick: () => (model.collapsed = !collapsed) - }); - - 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 0000000000..34e2ede9e9 --- /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 1bc8c55eaa..00bdd81114 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 //------------------------