Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions cmp/card/Card.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
167 changes: 167 additions & 0 deletions cmp/card/Card.ts
Original file line number Diff line number Diff line change
@@ -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';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked and it looks like currently we only have a single import from @xh/hoist/kit/blueprint that's outside of the top-level desktop packages (desktop/admin/inspector). That's utils/impl/MenuItems.ts.

We should carefully consider if we're OK with BP imports getting into mobile code, and review how the bundles are created to see if this drags in more than we expect for mobile app builds. Or we could go the other way and stop being so hesitant to use BP in a mobile context, but until now (other than that one perhaps unfortunate exception) we've kept them isolated.

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<CardModel>, 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<CardProps>({
displayName: 'Card',
model: uses(CardModel, {
fromContext: false,
publishMode: 'limited',
createDefault: true
}),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I mentioned this w.r.t. Panel + PanelModel, which is a common source of annoyance for me when looking to author app-level components that ultimately render a Panel and want to take and pass along all (or at least most) Panel props.

Those app level components almost always have some app-level model that they are actually using/creating. This means that if I want to have a custom panel-based comp - say TradeDetailPanel + TradeDetailModel and I want my component to support resizing, I have to do some workaround like add a custom panelModel prop to my app comp that I relay to the underlying Panel as its model.

It's not the worst thing, but I do think we somewhat overloaded the component's primary "model" here in a way that makes some usage patterns more difficult. The extra friction of handling that props makes it more likely that apps have components named FooPanel that don't take expected props, or get an extra layer of nesting to support resizability (if the comp author doesn't bother take the approach above).

Rather than doubling down on that, could we consider something like a layoutModel or renderModel prop to accept one of these, or a config for one? I'd like to think about how this might play with e.g. a proper Hoist dialog or drawer component as well, and ultimately consider a change to Panel if we think this is a better option for these common container + building-block comps.

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');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already did this above

} else {
wasDisplayed.current = true;
}

let content: ReactNode[];
switch (renderMode) {
case 'always':
content = items;
classes.push('xh-card--render-mode-always');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we anticipate these CSS classes being useful? I wasn't sure why you would be targeting these variations on a CSS level. Do we do this with other renderMode enabled containers?

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({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a comment somewhere in this component re. the use of HTML fieldset and its legend element, below?

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<CardTitleProps>({
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);
}
});
94 changes: 94 additions & 0 deletions cmp/card/CardModel.ts
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realize that this is our more common standard - but still asking the question - would CardModelConfig be incrementally more clear? I feel like our comp props vs model config story is already a common source of confusion - we're using Config diligently to indicate "model config object" vs "comp props", but any harm in the extra word to be even more explicit?

/** 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<CardPersistState> {
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<CardPersistState> {
return new PersistableState({collapsed: this.collapsed});
}

setPersistableState(state: PersistableState<CardPersistState>) {
const {collapsed} = state.value;
if (!isNil(collapsed)) {
this.collapsed = collapsed;
}
}
}
7 changes: 7 additions & 0 deletions cmp/card/index.ts
Original file line number Diff line number Diff line change
@@ -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';
49 changes: 0 additions & 49 deletions cmp/layout/CollapsibleSet.scss

This file was deleted.

Loading