-
Notifications
You must be signed in to change notification settings - Fork 11
New Card and FormFieldSet components
#4204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: drag-onto-dash-canvas
Are you sure you want to change the base?
Changes from all commits
205d641
b69318a
31d260c
a631459
e1c29dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| } | ||
| } | ||
| } |
| 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'; | ||
| 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 | ||
| }), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 Rather than doubling down on that, could we consider something like a |
||
| 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'); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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({ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth a comment somewhere in this component re. the use of HTML |
||
| 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); | ||
| } | ||
| }); | ||
| 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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| /** 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; | ||
| } | ||
| } | ||
| } | ||
| 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'; |
This file was deleted.
There was a problem hiding this comment.
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/blueprintthat's outside of the top-level desktop packages (desktop/admin/inspector). That'sutils/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.