diff --git a/CHANGELOG.md b/CHANGELOG.md index 816a35447..151300f3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,12 @@ ### 💥 Breaking Changes -* Completed the refactoring away from `loadModel` to `loadObserver` started in v79: +* Modified several CSS classes related to `FormField` to better follow BEM conventions. + * ⚠️The commonly targeted `xh-form-field-label` class is now `xh-form-field__label`, although + please review new CSS vars (below) and consider using those instead of class-based selectors. + * Modifier classes now follow BEM conventions (e.g. `xh-form-field-invalid` is now + `xh-form-field--invalid`). +* Completed the refactoring from `loadModel` to `loadObserver` started in v79: * Renamed `XH.appLoadModel` to `XH.appLoadObserver`. The prior getter remains as an alias but is deprecated and scheduled for removal in v82. * Renamed `AppContainerModel.loadModel` to `loadObserver`. This is primarily an internal model, @@ -17,6 +22,8 @@ ### 🎁 New Features +* Enhanced `Field.rules` to support `warning` and `info` severity. Useful for non-blocking + validation scenarios, such as providing guidance to users without preventing form submission. * Added new `AppMenuButton.renderWithUserProfile` prop as a built-in alternative to the default hamburger menu. Set to `true` to render the current user's initials instead or provide a function to render a custom element for the user. @@ -27,8 +34,25 @@ * Introduced new `FilterBindTarget` and `FilterValueSource` interfaces to generalize the data sources that could be used with `FilterChooserModel` and `GridFilterModel`. Both `Store` and `View` implement these interfaces, meaning no changes are required for apps, but it is now - possible to use these models with other, alternate implementations if needed. -* Added new static typeguard methods on `Store`, `View`, and `Filter` + subclasses. + possible to use these models with alternate implementations. +* Added new static typeguard methods on `Store`, `View`, and `Filter` + its subclasses. +* Removed `RecordErrorMap` + reorganized validation types (not expected to impact most apps). + +### ✨ Styles + +* Applied the app-wide `--xh-font-family` to `input` elements. Previously these had continued to + take a default font defined by the browser stylesheet. + * Customize for inputs if needed via `--xh-input-font-family`. + * Note that the switch to Hoist's default Inter font w/tabular numbers might require some + inputs w/tight sizing to be made wider to avoid clipping (e.g. `DateInputs` sized to fit). +* Updated + added validation-related `FormField` CSS classes and variables to account for new `info` + and `warning` validation levels. Additionally validation messages and the `info` text element no + longer clip at a single line - they will wrap as needed. +* Added new CSS variables for `FormField` to allow easier customization of commonly adjusted styles, + with a focus on labels. See `vars.scss` for the full list. Consider replacing existing class-based + CSS overrides with overrides to variables where possible. +* Added new CSS variables `--xh-intent-danger-text-color` (and others). Consider using these when + styling text with Hoist intent colors to enhance legibility in dark mode. ## 79.0.0 - 2026-01-05 diff --git a/admin/tabs/activity/tracking/ActivityTracking.scss b/admin/tabs/activity/tracking/ActivityTracking.scss index efe6c6287..3d8450611 100644 --- a/admin/tabs/activity/tracking/ActivityTracking.scss +++ b/admin/tabs/activity/tracking/ActivityTracking.scss @@ -9,7 +9,7 @@ .xh-toolbar .xh-form-field { margin: 0 0 0 var(--xh-tbar-item-pad-px); - .xh-form-field-label { + .xh-form-field__label { min-width: 0 !important; margin-right: 6px; } @@ -60,7 +60,12 @@ margin: 0; padding: 2px var(--xh-pad-px); - &.xh-form-field-readonly .xh-form-field-readonly-display { + &__label { + font-size: var(--xh-font-size-small-px); + padding: 0; + } + + &__readonly-display { padding: var(--xh-pad-half-px); } @@ -68,11 +73,6 @@ background-color: var(--xh-grid-bg-odd); } } - - .xh-form-field-label { - font-size: var(--xh-font-size-small-px); - padding: 0; - } } h3 { diff --git a/admin/tabs/userData/roles/details/RoleDetails.scss b/admin/tabs/userData/roles/details/RoleDetails.scss index ad30ae4ce..873b132a3 100644 --- a/admin/tabs/userData/roles/details/RoleDetails.scss +++ b/admin/tabs/userData/roles/details/RoleDetails.scss @@ -6,7 +6,12 @@ margin: 0; padding: 2px var(--xh-pad-px); - &.xh-form-field-readonly .xh-form-field-readonly-display { + &__label { + font-size: var(--xh-font-size-small-px); + padding: 0; + } + + &__readonly-display { padding: var(--xh-pad-half-px); } @@ -14,10 +19,5 @@ background-color: var(--xh-grid-bg-odd); } } - - .xh-form-field-label { - font-size: var(--xh-font-size-small-px); - padding: 0; - } } } diff --git a/cmp/form/FormModel.ts b/cmp/form/FormModel.ts index af9e8a9b5..f1756f66a 100644 --- a/cmp/form/FormModel.ts +++ b/cmp/form/FormModel.ts @@ -256,7 +256,7 @@ export class FormModel extends HoistModel { /** True if all fields are valid. */ get isValid(): boolean { - return this.validationState == 'Valid'; + return this.validationState === 'Valid'; } /** List of all validation errors for this form. */ @@ -264,7 +264,7 @@ export class FormModel extends HoistModel { return flatMap(this.fields, s => s.allErrors); } - /** Recompute all validations and return true if the form is valid. */ + /** Recompute all ValidationResults and return true if the form is valid. */ async validateAsync(opts?: FormValidateOptions): Promise { const {display = true} = opts ?? {}, promises = map(this.fields, m => m.validateAsync({display})); diff --git a/cmp/form/field/BaseFieldModel.ts b/cmp/form/field/BaseFieldModel.ts index b5a6d78f5..67220a7fb 100644 --- a/cmp/form/field/BaseFieldModel.ts +++ b/cmp/form/field/BaseFieldModel.ts @@ -5,12 +5,19 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import {HoistModel, managed, TaskObserver} from '@xh/hoist/core'; -import {genDisplayName, required, Rule, RuleLike, ValidationState} from '@xh/hoist/data'; +import { + genDisplayName, + required, + Rule, + RuleLike, + ValidationResult, + ValidationState +} from '@xh/hoist/data'; import {action, bindable, computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; import {wait} from '@xh/hoist/promise'; import {executeIfFunction, withDefault} from '@xh/hoist/utils/js'; import {createObservableRef} from '@xh/hoist/utils/react'; -import {compact, flatten, isEmpty, isEqual, isFunction, isNil} from 'lodash'; +import {compact, flatten, isEmpty, isEqual, isFunction, isNil, isString} from 'lodash'; import {FormModel} from '../FormModel'; export interface BaseFieldConfig { @@ -91,11 +98,11 @@ export abstract class BaseFieldModel extends HoistModel { boundInputRef = createObservableRef(); - // An array with the result of evaluating each rule. Each element will be array of strings - // containing any validation errors for the rule. If validation for the rule has not - // completed will contain null + // An array with the result of evaluating each rule. Each element will be an array of + // ValidationResults for the rule. If validation for the rule has not completed will contain + // null. @observable - private _errors: string[][]; + private validationResultsInternal: ValidationResult[][]; @managed private validationTask = TaskObserver.trackLast(); @@ -118,7 +125,7 @@ export abstract class BaseFieldModel extends HoistModel { this._disabled = disabled; this._readonly = readonly; this.rules = this.processRuleSpecs(rules); - this._errors = this.rules.map(() => null); + this.validationResultsInternal = this.rules.map(() => null); } //----------------------------- @@ -175,12 +182,25 @@ export abstract class BaseFieldModel extends HoistModel { /** All validation errors for this field. */ @computed get errors(): string[] { - return compact(flatten(this._errors)); + return this.validationResults.filter(it => it.severity === 'error').map(it => it.message); + } + + /** All ValidationResults for this field. */ + @computed + get validationResults(): ValidationResult[] { + return compact(flatten(this.validationResultsInternal)); } /** All validation errors for this field and its sub-forms. */ get allErrors(): string[] { - return this.errors; + return this.allValidationResults + .filter(it => it.severity === 'error') + .map(it => it.message); + } + + /** All ValidationResults for this field and its sub-forms. */ + get allValidationResults(): ValidationResult[] { + return this.validationResults; } /** @@ -202,7 +222,7 @@ export abstract class BaseFieldModel extends HoistModel { // Force an immediate 'Unknown' state -- the async recompute leaves the old state in place until it completed. // (We want that for a value change, but not reset/init) Force the recompute only if needed. - this._errors.fill(null); + this.validationResultsInternal.fill(null); wait().then(() => { if (!this.isValidationPending && this.validationState === 'Unknown') { this.computeValidationAsync(); @@ -300,7 +320,7 @@ export abstract class BaseFieldModel extends HoistModel { } /** - * Recompute all validations and return true if the field is valid. + * Recompute all ValidationResults and return true if the field is valid. * * @param display - true to trigger the display of validation errors (if any) * by the bound FormField component after validation is complete. @@ -339,13 +359,13 @@ export abstract class BaseFieldModel extends HoistModel { const promises = this.rules.map(async (rule, idx) => { const result = await this.evaluateRuleAsync(rule); if (runId === this.validationRunId) { - runInAction(() => (this._errors[idx] = result)); + runInAction(() => (this.validationResultsInternal[idx] = result)); } }); await Promise.all(promises); } - private async evaluateRuleAsync(rule): Promise { + private async evaluateRuleAsync(rule: Rule): Promise { if (this.ruleIsActive(rule)) { const promises = rule.check.map(async constraint => { const {value, name, displayName} = this, @@ -355,7 +375,9 @@ export abstract class BaseFieldModel extends HoistModel { }); const ret = await Promise.all(promises); - return compact(flatten(ret)); + return compact(flatten(ret)).map(issue => + isString(issue) ? {message: issue, severity: 'error'} : issue + ); } return []; } @@ -367,10 +389,8 @@ export abstract class BaseFieldModel extends HoistModel { } protected deriveValidationState(): ValidationState { - const {_errors} = this; - - if (_errors.some(e => !isEmpty(e))) return 'NotValid'; - if (_errors.some(e => isNil(e))) return 'Unknown'; + if (!isEmpty(this.errors)) return 'NotValid'; + if (this.validationResultsInternal.some(e => isNil(e))) return 'Unknown'; return 'Valid'; } } diff --git a/cmp/form/field/SubformsFieldModel.ts b/cmp/form/field/SubformsFieldModel.ts index 537a7b735..53c23d7c1 100644 --- a/cmp/form/field/SubformsFieldModel.ts +++ b/cmp/form/field/SubformsFieldModel.ts @@ -5,7 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import {managed, PlainObject, XH} from '@xh/hoist/core'; -import {ValidationState} from '@xh/hoist/data'; +import {ValidationResult, ValidationState} from '@xh/hoist/data'; import {action, computed, makeObservable, override} from '@xh/hoist/mobx'; import {throwIf} from '@xh/hoist/utils/js'; import {clone, defaults, isEqual, flatMap, isArray, partition, without} from 'lodash'; @@ -47,7 +47,7 @@ export interface SubformAddOptions { * all existing form contents to new values. Call {@link add} or {@link remove} on one of these * fields to adjust the contents of its collection while preserving existing state. * - * Validation rules for the entire collection may be specified as for any field, but validations on + * Validation rules for the entire collection may be specified as for any field, but ValidationResults on * the subforms will also bubble up to this field, affecting its overall validation state. */ export class SubformsFieldModel extends BaseFieldModel { @@ -114,9 +114,9 @@ export class SubformsFieldModel extends BaseFieldModel { } @computed - override get allErrors(): string[] { - const subErrs = flatMap(this.value, s => s.allErrors); - return [...this.errors, ...subErrs]; + override get allValidationResults(): ValidationResult[] { + const subVals = flatMap(this.value, s => s.allValidations); + return [...this.validationResults, ...subVals]; } @override diff --git a/cmp/grid/Grid.scss b/cmp/grid/Grid.scss index 0cec05400..423b69ff4 100644 --- a/cmp/grid/Grid.scss +++ b/cmp/grid/Grid.scss @@ -104,16 +104,16 @@ padding-right: 0; } - // Render badge on invalid cells - .ag-cell.xh-cell--invalid { + // Render badge on cells with validation issues + .ag-cell.xh-cell--invalid, + .ag-cell.xh-cell--warning, + .ag-cell.xh-cell--info { &::before { content: ''; position: absolute; top: 0; right: 0; border-color: transparent; - border-right-color: var(--xh-intent-danger); - border-top-color: var(--xh-intent-danger); border-style: solid; } @@ -125,6 +125,21 @@ } } + .ag-cell.xh-cell--invalid::before { + border-right-color: var(--xh-intent-danger); + border-top-color: var(--xh-intent-danger); + } + + .ag-cell.xh-cell--warning::before { + border-right-color: var(--xh-intent-warning); + border-top-color: var(--xh-intent-warning); + } + + .ag-cell.xh-cell--info::before { + border-right-color: var(--xh-intent-primary); + border-top-color: var(--xh-intent-primary); + } + // Render left / right group borders .ag-cell.xh-cell--group-border-left { @include AgGrid.group-border(left); @@ -136,25 +151,33 @@ .xh-ag-grid { &--tiny { - .ag-cell.xh-cell--invalid::before { + .ag-cell.xh-cell--invalid::before, + .ag-cell.xh-cell--warning::before, + .ag-cell.xh-cell--info::before { border-width: 3px; } } &--compact { - .ag-cell.xh-cell--invalid::before { + .ag-cell.xh-cell--invalid::before, + .ag-cell.xh-cell--warning::before, + .ag-cell.xh-cell--info::before { border-width: 4px; } } &--standard { - .ag-cell.xh-cell--invalid::before { + .ag-cell.xh-cell--invalid::before, + .ag-cell.xh-cell--warning::before, + .ag-cell.xh-cell--info::before { border-width: 5px; } } &--large { - .ag-cell.xh-cell--invalid::before { + .ag-cell.xh-cell--invalid::before, + .ag-cell.xh-cell--warning::before, + .ag-cell.xh-cell--info::before { border-width: 6px; } } diff --git a/cmp/grid/columns/Column.ts b/cmp/grid/columns/Column.ts index d02f50476..75b397aa7 100644 --- a/cmp/grid/columns/Column.ts +++ b/cmp/grid/columns/Column.ts @@ -10,9 +10,12 @@ import { CubeFieldSpec, FieldSpec, genDisplayName, + maxSeverity, RecordAction, RecordActionSpec, - StoreRecord + StoreRecord, + ValidationResult, + ValidationSeverity } from '@xh/hoist/data'; import {logDebug, logWarn, throwIf, warnIf, withDefault} from '@xh/hoist/utils/js'; import classNames from 'classnames'; @@ -21,6 +24,7 @@ import { clone, find, get, + groupBy, isArray, isEmpty, isFinite, @@ -867,20 +871,28 @@ export class Column { if (location === 'header') return div({ref: wrapperRef, item: this.headerTooltip}); if (!hasRecord) return null; - // Override with validation errors, if present + // Override with validation errors, if present -- only show highest-severity level if (editor) { - const errors = record.errors[field]; - if (!isEmpty(errors)) { + const validationsBySeverity = groupBy( + record.validationResults[field], + 'severity' + ) as Record, + validationMessages = ( + validationsBySeverity.error ?? + validationsBySeverity.warning ?? + validationsBySeverity.info + )?.map(v => v.message); + if (!isEmpty(validationMessages)) { return div({ ref: wrapperRef, item: ul({ className: classNames( 'xh-grid-tooltip--validation', - errors.length === 1 + validationMessages.length === 1 ? 'xh-grid-tooltip--validation--single' : null ), - items: errors.map((it, idx) => li({key: idx, item: it})) + items: validationMessages.map((it, idx) => li({key: idx, item: it})) }) }); } @@ -1007,10 +1019,12 @@ export class Column { }); ret.cellEditorPopup = this.editorIsPopup; ret.cellClassRules = { - 'xh-cell--invalid': agParams => { - const record = agParams.data; - return record && !isEmpty(record.errors[field]); - }, + 'xh-cell--invalid': agParams => + maxSeverity(agParams.data.validationResults[field]) === 'error', + 'xh-cell--warning': agParams => + maxSeverity(agParams.data.validationResults[field]) === 'warning', + 'xh-cell--info': agParams => + maxSeverity(agParams.data.validationResults[field]) === 'info', 'xh-cell--editable': agParams => { return this.isEditableForRecord(agParams.data); }, diff --git a/cmp/input/HoistInput.scss b/cmp/input/HoistInput.scss index cf8b660af..a0ee630f0 100644 --- a/cmp/input/HoistInput.scss +++ b/cmp/input/HoistInput.scss @@ -6,9 +6,27 @@ */ .xh-input { - &.xh-input-invalid { + // Override browser user agent styles on input elements. + input { + font-family: var(--xh-input-font-family); + font-feature-settings: var(--xh-input-font-feature-settings); + } + + &.xh-input--invalid { input { border: var(--xh-form-field-invalid-border); } } + + &.xh-input--warning { + input { + border: var(--xh-form-field-warning-border); + } + } + + &.xh-input--info { + input { + border: var(--xh-form-field-info-border); + } + } } diff --git a/cmp/input/HoistInputModel.ts b/cmp/input/HoistInputModel.ts index fdc15ad9b..0733467a3 100644 --- a/cmp/input/HoistInputModel.ts +++ b/cmp/input/HoistInputModel.ts @@ -6,6 +6,7 @@ */ import {FieldModel} from '@xh/hoist/cmp/form'; import {DefaultHoistProps, HoistModel, HoistModelClass, useLocalModel} from '@xh/hoist/core'; +import {maxSeverity} from '@xh/hoist/data'; import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {createObservableRef} from '@xh/hoist/utils/react'; import classNames from 'classnames'; @@ -336,13 +337,20 @@ export function useHoistInputModel( useImperativeHandle(ref, () => inputModel); const field = inputModel.getField(), - validityClass = field?.isNotValid && field?.validationDisplayed ? 'xh-input-invalid' : null, + severityToDisplay = field?.validationDisplayed && maxSeverity(field?.validationResults), + displayInvalid = severityToDisplay === 'error', disabledClass = props.disabled ? 'xh-input-disabled' : null; return component({ ...props, model: inputModel, ref: inputModel.domRef, - className: classNames('xh-input', validityClass, disabledClass, props.className) + className: classNames( + 'xh-input', + severityToDisplay && `xh-input--${severityToDisplay}`, + displayInvalid && 'xh-input--invalid', + disabledClass, + props.className + ) }); } diff --git a/data/Field.ts b/data/Field.ts index 8d4e8f33d..e9cfad2e7 100644 --- a/data/Field.ts +++ b/data/Field.ts @@ -6,9 +6,10 @@ */ import {XH} from '@xh/hoist/core'; +import {RuleLike} from '@xh/hoist/data/validation/Types'; import {isLocalDate, LocalDate} from '@xh/hoist/utils/datetime'; import {withDefault} from '@xh/hoist/utils/js'; -import {Rule, RuleLike} from './validation/Rule'; +import {Rule} from './validation/Rule'; import equal from 'fast-deep-equal'; import {isDate, isString, toNumber, isFinite, startCase, isFunction, castArray} from 'lodash'; import DOMPurify from 'dompurify'; diff --git a/data/Store.ts b/data/Store.ts index 6dad659ef..2f91fa9de 100644 --- a/data/Store.ts +++ b/data/Store.ts @@ -16,8 +16,12 @@ import { parseFilter, StoreRecord, StoreRecordId, - StoreRecordOrId + StoreRecordOrId, + StoreValidationMessagesMap, + StoreValidationResultsMap, + ValidationResult } from '@xh/hoist/data'; +import {StoreValidator} from '@xh/hoist/data/impl/StoreValidator'; import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {logWithDebug, throwIf, warnIf} from '@xh/hoist/utils/js'; import equal from 'fast-deep-equal'; @@ -41,7 +45,6 @@ import { } from 'lodash'; import {instanceManager} from '../core/impl/InstanceManager'; import {RecordSet} from './impl/RecordSet'; -import {StoreErrorMap, StoreValidator} from './impl/StoreValidator'; export interface StoreConfig { /** Field names, configs, or instances. */ @@ -893,10 +896,14 @@ export class Store extends HoistBase implements FilterBindTarget, FilterValueSou return this._current.maxDepth; // maxDepth should not be effected by filtering. } - get errors(): StoreErrorMap { + get errors(): StoreValidationMessagesMap { return this.validator.errors; } + get validationResults(): StoreValidationResultsMap { + return this.validator.validationResults; + } + /** Count of all validation errors for the store. */ get errorCount(): number { return this.validator.errorCount; @@ -907,6 +914,11 @@ export class Store extends HoistBase implements FilterBindTarget, FilterValueSou return uniq(flatMapDeep(this.errors, values)); } + /** Array of all ValidationResults for this store. */ + get allValidationResults(): ValidationResult[] { + return uniq(flatMapDeep(this.validationResults, values)); + } + /** * Get a record by ID, or null if no matching record found. * @@ -979,7 +991,7 @@ export class Store extends HoistBase implements FilterBindTarget, FilterValueSou return this.validator.isNotValid; } - /** Recompute validations for all records and return true if the store is valid. */ + /** Recompute ValidationResults for all records and return true if the store is valid. */ async validateAsync(): Promise { return this.validator.validateAsync(); } diff --git a/data/StoreRecord.ts b/data/StoreRecord.ts index 73fca376f..a8838a145 100644 --- a/data/StoreRecord.ts +++ b/data/StoreRecord.ts @@ -5,6 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import {PlainObject} from '@xh/hoist/core'; +import {ValidationResult} from '@xh/hoist/data/validation/Types'; import {throwIf} from '@xh/hoist/utils/js'; import {isNil, flatMap, isMatch, isEmpty, pickBy} from 'lodash'; import {Store} from './Store'; @@ -153,11 +154,21 @@ export class StoreRecord { return this.validator?.errors ?? {}; } + /** Map of field names to list of ValidationResults. */ + get validationResults(): Record { + return this.validator?.validationResults ?? {}; + } + /** Array of all errors for this record. */ get allErrors() { return flatMap(this.errors); } + /** Array of all ValidationResults for this record. */ + get allValidationResults(): ValidationResult[] { + return flatMap(this.validationResults); + } + /** Count of all validation errors for the record. */ get errorCount(): number { return this.validator?.errorCount ?? 0; diff --git a/data/impl/RecordValidator.ts b/data/impl/RecordValidator.ts index c527be88c..e570938c7 100644 --- a/data/impl/RecordValidator.ts +++ b/data/impl/RecordValidator.ts @@ -4,9 +4,18 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {Field, Rule, StoreRecord, StoreRecordId, ValidationState} from '@xh/hoist/data'; +import { + Field, + RecordValidationMessagesMap, + RecordValidationResultsMap, + Rule, + StoreRecord, + StoreRecordId, + ValidationResult, + ValidationState +} from '@xh/hoist/data'; import {computed, observable, makeObservable, runInAction} from '@xh/hoist/mobx'; -import {compact, flatten, isEmpty, mapValues, values} from 'lodash'; +import {compact, flatten, isEmpty, isString, mapValues, values} from 'lodash'; import {TaskObserver} from '../../core'; /** @@ -16,9 +25,9 @@ import {TaskObserver} from '../../core'; export class RecordValidator { record: StoreRecord; - @observable.ref _fieldErrors: RecordErrorMap = null; - _validationTask = TaskObserver.trackLast(); - _validationRunId = 0; + @observable.ref private fieldValidations: RecordValidationResultsMap = null; + private validationTask = TaskObserver.trackLast(); + private validationRunId = 0; get id(): StoreRecordId { return this.record.id; @@ -44,20 +53,28 @@ export class RecordValidator { /** Map of field names to field-level errors. */ @computed.struct - get errors(): RecordErrorMap { - return this._fieldErrors ?? {}; + get errors(): RecordValidationMessagesMap { + return mapValues(this.fieldValidations ?? {}, issues => + compact(issues.map(it => (it?.severity === 'error' ? it.message : null))) + ); + } + + /** Map of field names to field-level ValidationResults. */ + @computed.struct + get validationResults(): RecordValidationResultsMap { + return this.fieldValidations ?? {}; } /** Count of all validation errors for the record. */ @computed get errorCount(): number { - return flatten(values(this._fieldErrors)).length; + return flatten(values(this.errors)).length; } /** True if any fields are currently recomputing their validation state. */ @computed get isPending(): boolean { - return this._validationTask.isPending; + return this.validationTask.isPending; } private _validators = []; @@ -68,41 +85,43 @@ export class RecordValidator { } /** - * Recompute validations for the record and return true if valid. + * Recompute ValidationResults for the record and return true if valid. */ async validateAsync(): Promise { - let runId = ++this._validationRunId, - fieldErrors = {}, + let runId = ++this.validationRunId, + fieldValidations = {}, {record} = this, fieldsToValidate = record.store.fields.filter(it => !isEmpty(it.rules)); const promises = fieldsToValidate.flatMap(field => { - fieldErrors[field.name] = []; + fieldValidations[field.name] = []; return field.rules.map(async rule => { const result = await this.evaluateRuleAsync(record, field, rule); - fieldErrors[field.name].push(result); + fieldValidations[field.name].push(result); }); }); - await Promise.all(promises).linkTo(this._validationTask); + await Promise.all(promises).linkTo(this.validationTask); - if (runId !== this._validationRunId) return; - fieldErrors = mapValues(fieldErrors, it => compact(flatten(it))); + if (runId !== this.validationRunId) return; + fieldValidations = mapValues(fieldValidations, it => compact(flatten(it))); - runInAction(() => (this._fieldErrors = fieldErrors)); + runInAction(() => (this.fieldValidations = fieldValidations)); return this.isValid; } /** The current validation state for the record. */ getValidationState(): ValidationState { - const {_fieldErrors} = this; - - if (_fieldErrors === null) return 'Unknown'; // Before executing any rules - - return values(_fieldErrors).some(errors => !isEmpty(errors)) ? 'NotValid' : 'Valid'; + if (this.fieldValidations === null) return 'Unknown'; // Before executing any rules + if (this.errorCount) return 'NotValid'; + return 'Valid'; } - async evaluateRuleAsync(record: StoreRecord, field: Field, rule: Rule): Promise { + async evaluateRuleAsync( + record: StoreRecord, + field: Field, + rule: Rule + ): Promise { const values = record.getValues(), {name, displayName} = field, value = record.get(name); @@ -114,7 +133,9 @@ export class RecordValidator { }); const ret = await Promise.all(promises); - return compact(flatten(ret)); + return compact(flatten(ret)).map(issue => + isString(issue) ? {message: issue, severity: 'error'} : issue + ); } } @@ -123,6 +144,3 @@ export class RecordValidator { return !when || when(field, record.getValues()); } } - -/** Map of Field names to Field-level error lists. */ -export type RecordErrorMap = Record; diff --git a/data/impl/StoreValidator.ts b/data/impl/StoreValidator.ts index a66d7978a..c983eaa5f 100644 --- a/data/impl/StoreValidator.ts +++ b/data/impl/StoreValidator.ts @@ -6,11 +6,15 @@ */ import {HoistBase} from '@xh/hoist/core'; +import { + StoreValidationMessagesMap, + StoreValidationResultsMap, + ValidationState +} from '@xh/hoist/data'; import {computed, makeObservable, runInAction, observable} from '@xh/hoist/mobx'; import {sumBy, chunk} from 'lodash'; import {findIn} from '@xh/hoist/utils/js'; -import {RecordErrorMap, RecordValidator} from './RecordValidator'; -import {ValidationState} from '../validation/ValidationState'; +import {RecordValidator} from './RecordValidator'; import {Store} from '../Store'; import {StoreRecordId} from '../StoreRecord'; @@ -41,7 +45,7 @@ export class StoreValidator extends HoistBase { /** Map of StoreRecord IDs to StoreRecord-level error maps. */ @computed.struct - get errors(): StoreErrorMap { + get errors(): StoreValidationMessagesMap { return this.getErrorMap(); } @@ -51,6 +55,12 @@ export class StoreValidator extends HoistBase { return sumBy(this.validators, 'errorCount'); } + /** Map of StoreRecord IDs to StoreRecord-level ValidationResults maps. */ + @computed.struct + get validationResults(): StoreValidationResultsMap { + return this.getValidationResultsMap(); + } + /** True if any records are currently recomputing their validation state. */ @computed get isPending() { @@ -76,7 +86,7 @@ export class StoreValidator extends HoistBase { } /** - * Recompute validations for the store and return true if valid. + * Recompute ValidationResults for the store and return true if valid. */ async validateAsync(): Promise { await this.validateInChunksAsync(this.validators); @@ -92,12 +102,18 @@ export class StoreValidator extends HoistBase { } /** @returns map of StoreRecord IDs to StoreRecord-level error maps. */ - getErrorMap(): StoreErrorMap { - const ret = {}; + getErrorMap(): StoreValidationMessagesMap { + const ret: StoreValidationMessagesMap = {}; this._validators.forEach(v => (ret[v.id] = v.errors)); return ret; } + getValidationResultsMap(): StoreValidationResultsMap { + const ret: StoreValidationResultsMap = {}; + this._validators.forEach(v => (ret[v.id] = v.validationResults)); + return ret; + } + /** * @param id - ID of RecordValidator (should match record.id) */ @@ -155,6 +171,3 @@ export class StoreValidator extends HoistBase { return Array.from(this._validators.values(), fn); } } - -/** Map of StoreRecord IDs to StoreRecord-level error maps. */ -export type StoreErrorMap = Record; diff --git a/data/index.ts b/data/index.ts index f4903c8f1..6fe9395b6 100644 --- a/data/index.ts +++ b/data/index.ts @@ -42,3 +42,4 @@ export * from './cube/ViewRowData'; export * from './validation/constraints'; export * from './validation/Rule'; export * from './validation/ValidationState'; +export * from './validation/Types'; diff --git a/data/validation/Rule.ts b/data/validation/Rule.ts index ee1d11aee..19c807637 100644 --- a/data/validation/Rule.ts +++ b/data/validation/Rule.ts @@ -4,10 +4,8 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ -import {Awaitable, PlainObject, Some} from '../../core'; -import {castArray} from 'lodash'; -import {StoreRecord} from '../StoreRecord'; -import {BaseFieldModel} from '../../cmp/form'; +import {Constraint, RuleSpec, ValidationResult, ValidationSeverity, When} from '@xh/hoist/data'; +import {castArray, groupBy, isEmpty} from 'lodash'; /** * Immutable object representing a validation rule. @@ -23,54 +21,16 @@ export class Rule { } /** - * Function to validate a value. + * Utility to determine the maximum severity from a list of ValidationResults. * - * @param fieldState - context w/value for the constraint's target Field. - * @param allValues - current values for all fields in form, keyed by field name. - * @returns String(s) or array of strings describing errors, or null or undefined if rule passes - * successfully. May return a Promise of strings for async validation. + * @param validationResults - list of ValidationResults to evaluate. + * @returns The highest severity level found, or null if none. */ -export type Constraint = ( - fieldState: FieldState, - allValues: PlainObject -) => Awaitable>; - -/** - * Function to determine when to perform validation on a value. - * - * @param entity - the entity being evaluated. Typically a field for store validation or - * a BaseFieldModel for Form validation. - * @param allValues - current values for all fields in form or record, keyed by field name. - * @returns true if this rule is currently active. - */ -export type When = (entity: any, allValues: PlainObject) => boolean; - -export interface FieldState { - /** Current value of the field */ - value: T; - - /** Name of the field */ - name: string; - - /** Display name of the field */ - displayName: string; - - /** Record being validated, if validating Store data. */ - record?: StoreRecord; - - /** FieldModel being validated, if validating Form data. */ - fieldModel?: BaseFieldModel; -} - -export interface RuleSpec { - /** Function(s) to perform validation. */ - check: Some; - - /** - * Function to determine when this rule is active. - * If not specified rule is considered to be always active. - */ - when?: When; +export function maxSeverity(validationResults: ValidationResult[]): ValidationSeverity { + if (isEmpty(validationResults)) return null; + const bySeverity = groupBy(validationResults, 'severity'); + if ('error' in bySeverity) return 'error'; + if ('warning' in bySeverity) return 'warning'; + if ('info' in bySeverity) return 'info'; + return null; } - -export type RuleLike = RuleSpec | Constraint | Rule; diff --git a/data/validation/Types.ts b/data/validation/Types.ts new file mode 100644 index 000000000..542b3643e --- /dev/null +++ b/data/validation/Types.ts @@ -0,0 +1,81 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +import type {BaseFieldModel} from '@xh/hoist/cmp/form'; +import type {Awaitable, PlainObject, Some} from '@xh/hoist/core'; +import type {Rule, StoreRecord, StoreRecordId} from '@xh/hoist/data'; + +/** + * Function to validate a value. + * + * @param fieldState - context w/value for the constraint's target Field. + * @param allValues - current values for all fields in form, keyed by field name. + * @returns ValidationResult(s) or string(s) describing errors or null / undefined if rule passes. + * May return a Promise resolving to the same for async validation. + */ +export type Constraint = ( + fieldState: FieldState, + allValues: PlainObject +) => Awaitable>; + +/** + * Function to determine when to perform validation on a value. + * + * @param entity - the entity being evaluated. Typically a field for store validation or + * a BaseFieldModel for Form validation. + * @param allValues - current values for all fields in form or record, keyed by field name. + * @returns true if this rule is currently active. + */ +export type When = (entity: any, allValues: PlainObject) => boolean; + +export interface FieldState { + /** Current value of the field */ + value: T; + + /** Name of the field */ + name: string; + + /** Display name of the field */ + displayName: string; + + /** Record being validated, if validating Store data. */ + record?: StoreRecord; + + /** FieldModel being validated, if validating Form data. */ + fieldModel?: BaseFieldModel; +} + +export interface RuleSpec { + /** Function(s) to perform validation. */ + check: Some; + + /** + * Function to determine when this rule is active. + * If not specified rule is considered to be always active. + */ + when?: When; +} + +export type RuleLike = RuleSpec | Constraint | Rule; + +export interface ValidationResult { + severity: ValidationSeverity; + message: string; +} + +export type ValidationSeverity = 'error' | 'warning' | 'info'; + +/** Map of StoreRecord IDs to StoreRecord-level messages maps. */ +export type StoreValidationMessagesMap = Record; + +/** Map of StoreRecord IDs to StoreRecord-level ValidationResults maps. */ +export type StoreValidationResultsMap = Record; + +/** Map of Field names to Field-level Validation lists. */ +export type RecordValidationResultsMap = Record; + +/** Map of Field names to Field-level validation message lists. */ +export type RecordValidationMessagesMap = Record; diff --git a/data/validation/constraints.ts b/data/validation/constraints.ts index c962663fd..f7ccd98e3 100644 --- a/data/validation/constraints.ts +++ b/data/validation/constraints.ts @@ -4,11 +4,12 @@ * * Copyright © 2026 Extremely Heavy Industries Inc. */ +import {Constraint} from '@xh/hoist/data'; import {LocalDate} from '@xh/hoist/utils/datetime'; import {pluralize} from '@xh/hoist/utils/js'; import {isArray, isEmpty, isFinite, isNil, isString, uniq} from 'lodash'; import moment from 'moment'; -import {Constraint} from './Rule'; + /** * A set of validation functions to assist in form field validation. */ diff --git a/desktop/appcontainer/OptionsDialog.scss b/desktop/appcontainer/OptionsDialog.scss index 09c06536c..b248a6ce2 100644 --- a/desktop/appcontainer/OptionsDialog.scss +++ b/desktop/appcontainer/OptionsDialog.scss @@ -8,7 +8,7 @@ .xh-options-dialog { width: 500px; - .xh-form-field-label { + .xh-form-field__label { min-width: 120px !important; } } diff --git a/desktop/cmp/form/FormField.scss b/desktop/cmp/form/FormField.scss index a481ff571..16dd7122d 100644 --- a/desktop/cmp/form/FormField.scss +++ b/desktop/cmp/form/FormField.scss @@ -8,14 +8,30 @@ .xh-form-field { display: flex; flex-direction: column; - padding: 3px; - margin: 0 0 var(--xh-pad-px); - - .xh-form-field-label { - padding: 0 0 3px; + padding: var(--xh-form-field-padding); + margin: var(--xh-form-field-margin); + + &__label { + color: var(--xh-form-field-label-color); + font-size: var(--xh-form-field-label-font-size); + font-style: var(--xh-form-field-label-font-style); + font-weight: var(--xh-form-field-label-font-weight); + text-transform: var(--xh-form-field-label-text-transform); + + // Borders + padding (not inline) + border-bottom: var(--xh-form-field-label-border-bottom); + margin: var(--xh-form-field-label-margin); + padding: var(--xh-form-field-label-padding); + + // Borders + padding (inline) + .xh-form-field--inline & { + border-bottom: var(--xh-form-field-label-inline-border-bottom); + margin: var(--xh-form-field-label-inline-margin); + padding: var(--xh-form-field-label-inline-padding); + } } - .xh-form-field-inner { + &__inner { // Used for unsizeable children &--block { display: block; @@ -45,63 +61,72 @@ flex: 1; } } - } - .xh-form-field-info, - .xh-form-field-error-msg { - font-size: var(--xh-font-size-small-px); - line-height: calc(var(--xh-font-size-small-px) + var(--xh-pad-px)); - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } + &__info-msg, + &__validation-msg { + font-size: var(--xh-form-field-msg-font-size); + line-height: var(--xh-form-field-msg-line-height); + margin: var(--xh-form-field-msg-margin); + text-transform: var(--xh-form-field-msg-text-transform); + } + + &__validation-msg { + &--error { + color: var(--xh-form-field-invalid-color); + } + + &--info { + color: var(--xh-form-field-info-color); + } - .xh-form-field-error-msg { - color: var(--xh-red); + &--warning { + color: var(--xh-form-field-warning-color); + } + } } - &.xh-form-field-inline { + &--inline { flex-direction: row; align-items: baseline; - &.xh-form-field-json-input { + &.xh-form-field--json-input { align-items: start; } - .xh-form-field-label { + .xh-form-field__label { padding: 0 var(--xh-pad-half-px) 0 0; } } - &.xh-form-field-readonly { - &.xh-form-field-json-input { - .xh-form-field-inner { - &--flex { - border: var(--xh-border-solid); - padding: var(--xh-pad-half-px); - background-color: var(--xh-input-disabled-bg); - font-family: var(--xh-font-family-mono); - } - } - } - - .xh-form-field-error-msg { + &--readonly { + .xh-form-field__validation-msg { display: none; } - .xh-form-field-inner { - &--flex { - overflow-y: auto; - } + .xh-form-field__inner--flex { + overflow-y: auto; } - .xh-form-field-readonly-display { + .xh-form-field__readonly-display { padding: 6px 0; white-space: pre-wrap; } + + &.xh-form-field--json-input { + .xh-form-field__inner--flex { + background-color: var(--xh-input-disabled-bg); + border: var(--xh-border-solid); + font-family: var(--xh-font-family-mono); + padding: var(--xh-pad-half-px); + } + } } - &.xh-form-field-invalid:not(.xh-form-field-readonly) { + &.xh-form-field--invalid:not(.xh-form-field--readonly) { + .xh-form-field__label { + color: var(--xh-form-field-invalid-color); + } + .xh-check-box span { box-shadow: var(--xh-form-field-invalid-box-shadow) !important; } @@ -119,16 +144,76 @@ } .xh-text-input > svg { - color: var(--xh-intent-danger); + color: var(--xh-form-field-invalid-color); } .xh-text-area.textarea { border: var(--xh-form-field-invalid-border) !important; } } + + &.xh-form-field--warning:not(.xh-form-field--readonly) { + .xh-form-field__label { + color: var(--xh-form-field-warning-color); + } + + .xh-check-box span { + box-shadow: var(--xh-form-field-warning-box-shadow) !important; + } + + .xh-button-group-input button.xh-button { + box-shadow: var(--xh-form-field-warning-box-shadow); + } + + .xh-slider span { + box-shadow: var(--xh-form-field-warning-box-shadow); + } + + div.xh-select__control { + border: var(--xh-form-field-warning-border); + } + + .xh-text-input > svg { + color: var(--xh-form-field-warning-color); + } + + .xh-text-area.textarea { + border: var(--xh-form-field-warning-border) !important; + } + } + + &.xh-form-field--info:not(.xh-form-field--readonly) { + .xh-form-field__label { + color: var(--xh-form-field-info-color); + } + + .xh-check-box span { + box-shadow: var(--xh-form-field-info-box-shadow) !important; + } + + .xh-button-group-input button.xh-button { + box-shadow: var(--xh-form-field-info-box-shadow); + } + + .xh-slider span { + box-shadow: var(--xh-form-field-info-box-shadow); + } + + div.xh-select__control { + border: var(--xh-form-field-info-border); + } + + .xh-text-input > svg { + color: var(--xh-form-field-info-color); + } + + .xh-text-area.textarea { + border: var(--xh-form-field-info-border) !important; + } + } } -ul.xh-form-field-error-tooltip { +ul.xh-form-field__validation-tooltip { margin: 0; padding: 0 1em 0 2em; } @@ -141,12 +226,12 @@ ul.xh-form-field-error-tooltip { align-items: baseline; overflow: visible !important; - .xh-form-field-inner--flex { + .xh-form-field__inner--flex { flex-direction: row; align-items: center; } - .xh-form-field-error-msg { + .xh-form-field__validation-msg { margin: 0 var(--xh-pad-px); } } diff --git a/desktop/cmp/form/FormField.ts b/desktop/cmp/form/FormField.ts index ec922e621..0bfd97327 100644 --- a/desktop/cmp/form/FormField.ts +++ b/desktop/cmp/form/FormField.ts @@ -19,6 +19,7 @@ import { } from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {instanceManager} from '@xh/hoist/core/impl/InstanceManager'; +import {maxSeverity, ValidationResult} from '@xh/hoist/data'; import {fmtDate, fmtDateTime, fmtJson, fmtNumber} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {tooltip} from '@xh/hoist/kit/blueprint'; @@ -26,7 +27,7 @@ import {isLocalDate} from '@xh/hoist/utils/datetime'; import {errorIf, getTestId, logWarn, TEST_ID, throwIf, withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps, getReactElementName, useOnMount, useOnUnmount} from '@xh/hoist/utils/react'; import classNames from 'classnames'; -import {isBoolean, isDate, isEmpty, isFinite, isNil, isUndefined, kebabCase} from 'lodash'; +import {first, isBoolean, isDate, isEmpty, isFinite, isNil, isUndefined, kebabCase} from 'lodash'; import {Children, cloneElement, ReactElement, ReactNode, useContext, useState} from 'react'; import './FormField.scss'; @@ -122,16 +123,19 @@ export const [FormField, formField] = hoistCmp.withFactory({ const isRequired = model?.isRequired || false, readonly = model?.readonly || false, disabled = props.disabled || model?.disabled, - validationDisplayed = model?.validationDisplayed || false, - notValid = model?.isNotValid || false, - displayNotValid = validationDisplayed && notValid, - errors = model?.errors || [], + severityToDisplay = model?.validationDisplayed + ? maxSeverity(model.validationResults) + : null, + displayInvalid = severityToDisplay === 'error', + validationResultsToDisplay = severityToDisplay + ? model.validationResults.filter(v => v.severity === severityToDisplay) + : [], requiredStr = defaultProp('requiredIndicator', props, formContext, '*'), requiredIndicator = isRequired && !readonly && requiredStr ? span({ item: ' ' + requiredStr, - className: 'xh-form-field-required-indicator' + className: 'xh-form-field__required-indicator' }) : null; @@ -163,19 +167,24 @@ export const [FormField, formField] = hoistCmp.withFactory({ // Styles const classes = []; - if (childElementName) classes.push(`xh-form-field-${kebabCase(childElementName)}`); - if (isRequired) classes.push('xh-form-field-required'); - if (inline) classes.push('xh-form-field-inline'); - if (minimal) classes.push('xh-form-field-minimal'); - if (readonly) classes.push('xh-form-field-readonly'); - if (disabled) classes.push('xh-form-field-disabled'); - if (displayNotValid) classes.push('xh-form-field-invalid'); + if (childElementName) classes.push(`xh-form-field--${kebabCase(childElementName)}`); + if (isRequired) classes.push('xh-form-field--required'); + if (inline) classes.push('xh-form-field--inline'); + if (minimal) classes.push('xh-form-field--minimal'); + if (readonly) classes.push('xh-form-field--readonly'); + if (disabled) classes.push('xh-form-field--disabled'); + + if (severityToDisplay) { + classes.push(`xh-form-field--${severityToDisplay}`); + if (displayInvalid) classes.push('xh-form-field--invalid'); + } + // Test ID handling const testId = getFormFieldTestId(props, formContext, model?.name); useOnMount(() => instanceManager.registerModelWithTestId(testId, model)); useOnUnmount(() => instanceManager.unregisterModelWithTestId(testId)); - // generate actual element child to render + // Generate actual element child to render let childEl: ReactElement = !child || readonly ? readonlyChild({ @@ -189,7 +198,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ childIsSizeable, childId, disabled, - displayNotValid, + displayNotValid: severityToDisplay === 'error', leftErrorIcon, commitOnChange, testId: getTestId(testId, 'input') @@ -198,16 +207,42 @@ export const [FormField, formField] = hoistCmp.withFactory({ if (minimal) { childEl = tooltip({ item: childEl, - className: `xh-input ${displayNotValid ? 'xh-input-invalid' : ''}`, + className: classNames( + 'xh-input', + severityToDisplay && `xh-input--${severityToDisplay}`, + displayInvalid && 'xh-input--invalid' + ), targetTagName: !blockChildren.includes(childElementName) || childWidth ? 'span' : 'div', position: tooltipPosition, boundary: tooltipBoundary, - disabled: !displayNotValid, - content: getErrorTooltipContent(errors) + disabled: !severityToDisplay, + content: getValidationTooltipContent(validationResultsToDisplay) }); } + // Generate inlined validation messages, if any to show and not rendering in minimal mode. + let validationMsgEl: ReactElement = null; + if (severityToDisplay && !minimal) { + const validationMsgCls = `xh-form-field__inner__validation-msg xh-form-field__inner__validation-msg--${severityToDisplay}`, + firstMsg = first(validationResultsToDisplay)?.message; + + validationMsgEl = + validationResultsToDisplay.length > 1 + ? tooltip({ + openOnTargetFocus: false, + className: validationMsgCls, + item: firstMsg + ' (...)', + content: getValidationTooltipContent( + validationResultsToDisplay + ) as ReactElement + }) + : div({ + className: validationMsgCls, + item: firstMsg + }); + } + return box({ ref, key: model?.xhId, @@ -217,7 +252,7 @@ export const [FormField, formField] = hoistCmp.withFactory({ items: [ labelEl({ omit: !label, - className: 'xh-form-field-label', + className: 'xh-form-field__label', items: [label, requiredIndicator], htmlFor: clickableLabel ? childId : null, style: { @@ -228,23 +263,19 @@ export const [FormField, formField] = hoistCmp.withFactory({ }), div({ className: classNames( - 'xh-form-field-inner', - childIsSizeable ? 'xh-form-field-inner--flex' : 'xh-form-field-inner--block' + 'xh-form-field__inner', + childIsSizeable + ? 'xh-form-field__inner--flex' + : 'xh-form-field__inner--block' ), items: [ childEl, div({ - className: 'xh-form-field-info', + className: 'xh-form-field__inner__info-msg', omit: !info, item: info }), - tooltip({ - omit: minimal || !displayNotValid, - openOnTargetFocus: false, - className: 'xh-form-field-error-msg', - item: errors ? errors[0] : null, - content: getErrorTooltipContent(errors) as ReactElement - }) + validationMsgEl ] }) ] @@ -262,7 +293,7 @@ const readonlyChild = hoistCmp.factory({ render({model, readonlyRenderer, testId}) { const value = model ? model['value'] : null; return div({ - className: 'xh-form-field-readonly-display', + className: 'xh-form-field__readonly-display', [TEST_ID]: testId, item: readonlyRenderer(value, model) }); @@ -323,7 +354,6 @@ const editableChild = hoistCmp.factory({ //-------------------------------- // Helper Functions //--------------------------------- - export function defaultReadonlyRenderer(value: any): ReactNode { if (isLocalDate(value)) return fmtDate(value); if (isDate(value)) return fmtDateTime(value); @@ -359,21 +389,25 @@ function getValidChild(children) { return child; } -function getErrorTooltipContent(errors: string[]): ReactElement | string { - // If no errors, something other than null must be returned. +function getValidationTooltipContent(validationResults: ValidationResult[]): ReactElement | string { + // If no ValidationResults, something other than null must be returned. // If null is returned, as of Blueprint v5, the Blueprint Tooltip component causes deep re-renders of its target // when content changes from null <-> not null. // In `formField` `minimal:true` mode with `commitonchange:true`, this causes the // TextInput component to lose focus when its validation state changes, which is undesirable. // It is not clear if this is a bug or intended behavior in BP v5, but this workaround prevents the issue. // `Tooltip:content` has been a required prop since at least BP v4, but something about the way it is used in BP v5 changed. - if (isEmpty(errors)) return 'Is Valid'; - - if (errors.length === 1) return errors[0]; - return ul({ - className: 'xh-form-field-error-tooltip', - items: errors.map((it, idx) => li({key: idx, item: it})) - }); + if (isEmpty(validationResults)) { + return 'Is Valid'; + } else if (validationResults.length === 1) { + return first(validationResults).message; + } else { + const severity = first(validationResults).severity; + return ul({ + className: `xh-form-field__validation-tooltip xh-form-field__validation-tooltip--${severity}`, + items: validationResults.map((it, idx) => li({key: idx, item: it.message})) + }); + } } function defaultProp>( diff --git a/desktop/cmp/input/CodeInput.scss b/desktop/cmp/input/CodeInput.scss index f238ad7d1..60ba54841 100644 --- a/desktop/cmp/input/CodeInput.scss +++ b/desktop/cmp/input/CodeInput.scss @@ -29,12 +29,24 @@ .xh-code-input { height: 100px; - &.xh-input-invalid { + &.xh-input--invalid { div.CodeMirror { border: var(--xh-form-field-invalid-border); } } + &.xh-input--warning { + div.CodeMirror { + border: var(--xh-form-field-warning-border); + } + } + + &.xh-input--info { + div.CodeMirror { + border: var(--xh-form-field-info-border); + } + } + &.xh-input-disabled { .CodeMirror { background-color: var(--xh-input-disabled-bg); diff --git a/desktop/cmp/input/RadioInput.scss b/desktop/cmp/input/RadioInput.scss index 156df33d1..3e16d9ea8 100644 --- a/desktop/cmp/input/RadioInput.scss +++ b/desktop/cmp/input/RadioInput.scss @@ -10,13 +10,25 @@ margin-right: var(--xh-pad-double-px); } - &.xh-input-invalid .xh-radio-input-option .bp6-control-indicator { - border: var(--xh-form-field-invalid-border); - - &::before { + &.xh-input--invalid, + &.xh-input--warning, + &.xh-input--info { + .xh-radio-input-option .bp6-control-indicator::before { margin: -1px; } } + + &.xh-input--invalid .xh-radio-input-option .bp6-control-indicator { + border: var(--xh-form-field-invalid-border); + } + + &.xh-input--warning .xh-radio-input-option .bp6-control-indicator { + border: var(--xh-form-field-warning-border); + } + + &.xh-input--info .xh-radio-input-option .bp6-control-indicator { + border: var(--xh-form-field-info-border); + } } // Toolbar specific style diff --git a/desktop/cmp/input/SwitchInput.scss b/desktop/cmp/input/SwitchInput.scss index 70f6bc714..e59bc2370 100644 --- a/desktop/cmp/input/SwitchInput.scss +++ b/desktop/cmp/input/SwitchInput.scss @@ -5,12 +5,30 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ -.xh-switch-input.xh-input-invalid { - .bp6-control-indicator { - border: var(--xh-form-field-invalid-border); - - &::before { +.xh-switch-input { + &.xh-input--invalid, + &.xh-input--warning, + &.xh-input--info { + .bp6-control-indicator::before { margin: 1px; } } + + &.xh-input--invalid { + .bp6-control-indicator { + border: var(--xh-form-field-invalid-border); + } + } + + &.xh-input--warning { + .bp6-control-indicator { + border: var(--xh-form-field-warning-border); + } + } + + &.xh-input--info { + .bp6-control-indicator { + border: var(--xh-form-field-info-border); + } + } } diff --git a/desktop/cmp/input/TextArea.scss b/desktop/cmp/input/TextArea.scss index a108919df..099328126 100644 --- a/desktop/cmp/input/TextArea.scss +++ b/desktop/cmp/input/TextArea.scss @@ -12,7 +12,15 @@ // Suppress resize handles added by browser - we want to manage the size more closely. resize: none; - &.xh-input-invalid { + &.xh-input--invalid { border: var(--xh-form-field-invalid-border); } + + &.xh-input--warning { + border: var(--xh-form-field-warning-border); + } + + &.xh-input--info { + border: var(--xh-form-field-info-border); + } } diff --git a/desktop/cmp/rest/impl/RestForm.scss b/desktop/cmp/rest/impl/RestForm.scss index cca9b68dc..cacd2ce13 100644 --- a/desktop/cmp/rest/impl/RestForm.scss +++ b/desktop/cmp/rest/impl/RestForm.scss @@ -18,7 +18,7 @@ border: 1px solid var(--xh-form-field-box-shadow-color-top); } - .xh-form-field-label { + .xh-form-field__label { margin-right: var(--xh-pad-px); font-weight: bold; } diff --git a/desktop/cmp/viewmanager/ViewManager.scss b/desktop/cmp/viewmanager/ViewManager.scss index 2c5d65295..957c59f98 100644 --- a/desktop/cmp/viewmanager/ViewManager.scss +++ b/desktop/cmp/viewmanager/ViewManager.scss @@ -9,27 +9,19 @@ &__form { padding: var(--xh-pad-px); - .xh-form-field.xh-form-field-readonly { - &:not(.xh-form-field-inline) { - .xh-form-field-label { - border-bottom: var(--xh-border-solid); + .xh-form-field { + &--readonly { + &:not(.xh-form-field--inline) { + .xh-form-field__label { + border-bottom: var(--xh-border-solid); + } } } - .xh-form-field-readonly-display { + &__readonly-display { padding: 0; } } - - .xh-form-field .xh-form-field-info { - line-height: 1.5em; - margin-top: var(--xh-pad-half-px); - white-space: unset; - - .xh-icon { - margin-right: 2px; - } - } } &__metadata { diff --git a/kit/blueprint/styles.scss b/kit/blueprint/styles.scss index 09cded0b8..98f229c97 100644 --- a/kit/blueprint/styles.scss +++ b/kit/blueprint/styles.scss @@ -350,15 +350,15 @@ textarea.bp6-input, z-index: 16; } -// Controls ship with default bottom and (inline) right margins. We expose variables -// to customize and default those to 0 to avoid adding margins by default. +// Controls ship with default bottom and (inline) right margins. +// We zero those out here so padding can be re-applied at HoistInput or FormField layer. // Hoist theme text-color applied to elements not styled with a more specific selector. .bp6-control { color: var(--xh-text-color); - margin-bottom: var(--xh-form-field-margin-bottom); + margin-bottom: 0; &.bp6-inline { - margin-right: var(--xh-form-field-margin-right); + margin-inline-end: 0; } } diff --git a/kit/onsen/styles.scss b/kit/onsen/styles.scss index 0bc2161df..b831208f5 100644 --- a/kit/onsen/styles.scss +++ b/kit/onsen/styles.scss @@ -24,13 +24,21 @@ border: var(--xh-border-solid); border-radius: var(--xh-border-radius-px); - &:not(.xh-input-invalid):focus-within { + &:not(.xh-input--invalid):not(.xh-input--warning):not(.xh-input--info):focus-within { border-color: var(--xh-focus-outline-color); } - &.xh-input-invalid { + &.xh-input--invalid { border: var(--xh-form-field-invalid-border); } + + &.xh-input--warning { + border: var(--xh-form-field-warning-border); + } + + &.xh-input--info { + border: var(--xh-form-field-info-border); + } } } diff --git a/mobile/cmp/form/FormField.scss b/mobile/cmp/form/FormField.scss index 91a240307..3b8f700ff 100644 --- a/mobile/cmp/form/FormField.scss +++ b/mobile/cmp/form/FormField.scss @@ -1,12 +1,30 @@ .xh-form-field { display: flex; flex-direction: column; + padding: var(--xh-form-field-padding); + margin: var(--xh-form-field-margin); - .xh-form-field-label { - padding: 0 0 3px; + &__label { + color: var(--xh-form-field-label-color); + font-size: var(--xh-form-field-label-font-size); + font-style: var(--xh-form-field-label-font-style); + font-weight: var(--xh-form-field-label-font-weight); + text-transform: var(--xh-form-field-label-text-transform); + + // Borders + padding (not inline) + border-bottom: var(--xh-form-field-label-border-bottom); + margin: var(--xh-form-field-label-margin); + padding: var(--xh-form-field-label-padding); + + // Borders + padding (inline) + .xh-form-field--inline & { + border-bottom: var(--xh-form-field-label-inline-border-bottom); + margin: var(--xh-form-field-label-inline-margin); + padding: var(--xh-form-field-label-inline-padding); + } } - .xh-form-field-inner { + &__inner { // Used for unsizeable children &--block { display: block; @@ -21,31 +39,46 @@ } } - .xh-form-field-info, - .xh-form-field-error-msg, - .xh-form-field-pending-msg { - font-size: var(--xh-font-size-small-px); - line-height: calc(var(--xh-font-size-small-px) + var(--xh-pad-px)); - color: var(--xh-text-color-muted); - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; + &__info-msg, + &__validation-msg { + font-size: var(--xh-form-field-msg-font-size); + line-height: var(--xh-form-field-msg-line-height); + margin: var(--xh-form-field-msg-margin); + text-transform: var(--xh-form-field-msg-text-transform); + } + + &__validation-msg { + &--error { + color: var(--xh-form-field-invalid-color); + } + + &--info { + color: var(--xh-form-field-info-color); + } + + &--warning { + color: var(--xh-form-field-warning-color); + } + } + + &--invalid .xh-form-field__label { + color: var(--xh-form-field-invalid-color); } - .xh-form-field-error-msg { - color: var(--xh-red); + &--warning .xh-form-field__label { + color: var(--xh-form-field-warning-color); } - &.xh-form-field-invalid .xh-form-field-label { - color: var(--xh-red); + &--info .xh-form-field__label { + color: var(--xh-form-field-info-color); } - &.xh-form-field-readonly { - .xh-form-field-error-msg { + &.xh-form-field--readonly { + .xh-form-field__validation-msg { display: none; } - .xh-form-field-readonly-display { + .xh-form-field__readonly-display { padding: var(--xh-pad-px) 0; } } diff --git a/mobile/cmp/form/FormField.ts b/mobile/cmp/form/FormField.ts index fe4be8651..2fba41c3a 100644 --- a/mobile/cmp/form/FormField.ts +++ b/mobile/cmp/form/FormField.ts @@ -8,6 +8,7 @@ import composeRefs from '@seznam/compose-react-refs/composeRefs'; import {FieldModel, FormContext, FormContextType, BaseFormFieldProps} from '@xh/hoist/cmp/form'; import {box, div, span} from '@xh/hoist/cmp/layout'; import {DefaultHoistProps, hoistCmp, HoistProps, TestSupportProps, uses, XH} from '@xh/hoist/core'; +import {maxSeverity} from '@xh/hoist/data'; import {fmtDate, fmtDateTime, fmtNumber} from '@xh/hoist/format'; import {label as labelCmp} from '@xh/hoist/mobile/cmp/input'; import '@xh/hoist/mobile/register'; @@ -15,7 +16,7 @@ import {isLocalDate} from '@xh/hoist/utils/datetime'; import {errorIf, throwIf, withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps} from '@xh/hoist/utils/react'; import classNames from 'classnames'; -import {isBoolean, isDate, isEmpty, isFinite, isUndefined} from 'lodash'; +import {first, isBoolean, isDate, isEmpty, isFinite, isUndefined} from 'lodash'; import {Children, cloneElement, ReactNode, useContext} from 'react'; import './FormField.scss'; @@ -65,16 +66,19 @@ export const [FormField, formField] = hoistCmp.withFactory({ const isRequired = model?.isRequired || false, readonly = model?.readonly || false, disabled = props.disabled || model?.disabled, - validationDisplayed = model?.validationDisplayed || false, - notValid = model?.isNotValid || false, - displayNotValid = validationDisplayed && notValid, - errors = model?.errors || [], + severityToDisplay = model?.validationDisplayed + ? maxSeverity(model.validationResults) + : null, + displayInvalid = severityToDisplay === 'error', + validationResultsToDisplay = severityToDisplay + ? model.validationResults.filter(v => v.severity === severityToDisplay) + : [], requiredStr = defaultProp('requiredIndicator', props, formContext, '*'), requiredIndicator = isRequired && !readonly && requiredStr ? span({ item: ' ' + requiredStr, - className: 'xh-form-field-required-indicator' + className: 'xh-form-field__required-indicator' }) : null, isPending = model && model.isValidationPending; @@ -97,11 +101,14 @@ export const [FormField, formField] = hoistCmp.withFactory({ // Styles const classes = []; - if (isRequired) classes.push('xh-form-field-required'); - if (minimal) classes.push('xh-form-field-minimal'); - if (readonly) classes.push('xh-form-field-readonly'); - if (disabled) classes.push('xh-form-field-disabled'); - if (displayNotValid) classes.push('xh-form-field-invalid'); + if (isRequired) classes.push('xh-form-field--required'); + if (minimal) classes.push('xh-form-field--minimal'); + if (readonly) classes.push('xh-form-field--readonly'); + if (disabled) classes.push('xh-form-field--disabled'); + if (severityToDisplay) { + classes.push(`xh-form-field--${severityToDisplay}`); + if (displayInvalid) classes.push('xh-form-field--invalid'); + } let childEl = readonly || !child @@ -124,30 +131,32 @@ export const [FormField, formField] = hoistCmp.withFactory({ items: [ labelCmp({ omit: !label, - className: 'xh-form-field-label', + className: 'xh-form-field__label', items: [label, requiredIndicator] }), div({ className: classNames( - 'xh-form-field-inner', - childIsSizeable ? 'xh-form-field-inner--flex' : 'xh-form-field-inner--block' + 'xh-form-field__inner', + childIsSizeable + ? 'xh-form-field__inner--flex' + : 'xh-form-field__inner--block' ), items: [ childEl, div({ omit: !info, - className: 'xh-form-field-info', + className: 'xh-form-field__info-msg', item: info }), div({ - omit: minimal || !isPending || !validationDisplayed, - className: 'xh-form-field-pending-msg', + omit: minimal || !isPending || !severityToDisplay, + className: `xh-form-field__validation-msg xh-form-field__validation-msg--pending`, item: 'Validating...' }), div({ - omit: minimal || !displayNotValid, - className: 'xh-form-field-error-msg', - items: notValid ? errors[0] : null + omit: minimal || !severityToDisplay, + className: `xh-form-field__validation-msg xh-form-field__validation-msg--${severityToDisplay}`, + item: first(validationResultsToDisplay)?.message }) ] }) @@ -166,7 +175,7 @@ const readonlyChild = hoistCmp.factory({ render({model, readonlyRenderer}) { const value = model ? model['value'] : null; return div({ - className: 'xh-form-field-readonly-display', + className: 'xh-form-field__readonly-display', item: readonlyRenderer(value, model) }); } diff --git a/styles/XH.scss b/styles/XH.scss index 542da3bfe..fcdcb6aac 100644 --- a/styles/XH.scss +++ b/styles/XH.scss @@ -19,6 +19,7 @@ body.xh-app { border-color: var(--xh-border-color); color: var(--xh-text-color); font-family: var(--xh-font-family); + font-feature-settings: var(--xh-font-feature-settings); // Important for default Inter font to ensure numbers are constant-width and line up properly. font-variant-numeric: tabular-nums; font-size: var(--xh-font-size-px); diff --git a/styles/vars.scss b/styles/vars.scss index f94b0f03d..1bc8c55ea 100644 --- a/styles/vars.scss +++ b/styles/vars.scss @@ -199,6 +199,19 @@ body { --xh-intent-danger-trans1: hsla(var(--xh-intent-danger-h), var(--xh-intent-danger-s), var(--xh-intent-danger-l3), var(--xh-intent-a1)); --xh-intent-danger-trans2: hsla(var(--xh-intent-danger-h), var(--xh-intent-danger-s), var(--xh-intent-danger-l3), var(--xh-intent-a2)); + // Add -text variants - customized to lighter values for dark theme to optimize for legibility and contrast. + --xh-intent-primary-text-color: var(--intent-danger-text-color, var(--xh-intent-primary)); + --xh-intent-success-text-color: var(--intent-danger-text-color, var(--xh-intent-success)); + --xh-intent-warning-text-color: var(--intent-danger-text-color, var(--xh-intent-warning)); + --xh-intent-danger-text-color: var(--intent-danger-text-color, var(--xh-intent-danger)); + + &.xh-dark { + --xh-intent-primary-text-color: var(--intent-danger-text-color, var(--xh-intent-primary-lighter)); + --xh-intent-success-text-color: var(--intent-danger-text-color, var(--xh-intent-success-lighter)); + --xh-intent-warning-text-color: var(--intent-danger-text-color, var(--xh-intent-warning-lighter)); + --xh-intent-danger-text-color: var(--intent-danger-text-color, var(--xh-intent-danger-lighter)); + } + //--------- // AppBar @@ -380,6 +393,10 @@ body { --xh-font-family-headings: var(--font-family-headings, var(--xh-font-family)); --xh-font-family-mono: var(--font-family-mono, #{(Monaco, Consolas, monospace)}); + // Control font features such as tabular numbers. + // For Hoist's default Inter font, see https://rsms.me/inter/#features + --xh-font-feature-settings: var(--font-feature-settings, 'tnum'); + --xh-font-size: var(--font-size, 13); --xh-font-size-large-mult: var(--font-size-large-mult, 1.2); --xh-font-size-large-em: var(--font-size-large-em, 1.2em); @@ -405,19 +422,58 @@ body { //------------------------ // Form Fields //------------------------ - --xh-form-field-box-shadow-color-top: var(--form-field-box-shadow-color-top, #{mc-trans('blue-grey', '100', 0.15)}); - --xh-form-field-box-shadow-color-bottom: var(--form-field-box-shadow-color-bottom, #{mc-trans('blue-grey', '100', 0.2)}); + --xh-form-field-margin: var(--form-field-margin, 0 0 var(--xh-pad-px) 0); + --xh-form-field-padding: var(--form-field-padding, 3px); + + // Box shadow + focus border --xh-form-field-box-shadow: var(--form-field-box-shadow, #{inset 0 0 0 1px var(--xh-form-field-box-shadow-color-top), inset 0 1px 1px var(--xh-form-field-box-shadow-color-bottom)}); + --xh-form-field-box-shadow-color-bottom: var(--form-field-box-shadow-color-bottom, #{mc-trans('blue-grey', '100', 0.2)}); + --xh-form-field-box-shadow-color-top: var(--form-field-box-shadow-color-top, #{mc-trans('blue-grey', '100', 0.15)}); --xh-form-field-focused-border-color: var(--form-field-focused-border-color, var(--xh-focus-outline-color)); --xh-form-field-focused-box-shadow: var(--form-field-focused-box-shadow, #{inset 0 0 0 1px var(--xh-form-field-focused-border-color), inset 0 1px 1px var(--xh-form-field-focused-border-color)}); - --xh-form-field-invalid-border-color: var(--form-field-invalid-border-color, var(--xh-intent-danger)); - --xh-form-field-invalid-box-shadow: var(--form-field-invalid-border-color, #{inset 0 0 0 1px var(--xh-form-field-invalid-border-color), inset 0 1px 1px var(--xh-form-field-invalid-border-color)}); + + // Label styling (shared) + --xh-form-field-label-color: var(--form-field-label-color, var(--xh-text-color)); + --xh-form-field-label-font-size: var(--form-field-label-font-size, var(--xh-font-size-px)); + --xh-form-field-label-font-style: var(--form-field-label-font-style, normal); + --xh-form-field-label-font-weight: var(--form-field-label-font-weight, normal); + --xh-form-field-label-text-transform: var(--form-field-label-text-transform, none); + + // Label borders+padding (with inline variants) + --xh-form-field-label-border-bottom: var(--form-field-label-border-bottom, none); + --xh-form-field-label-margin: var(--form-field-label-margin, 0 0 3px 0); + --xh-form-field-label-padding: var(--form-field-label-padding, 0); + --xh-form-field-label-inline-border-bottom: var(--form-field-label-inline-border-bottom, none); + --xh-form-field-label-inline-margin: var(--form-field-label-inline-margin, 0); + --xh-form-field-label-inline-padding: var(--form-field-label-inline-padding, 0); + + // Messages (general info text + validation results) + --xh-form-field-msg-font-size: var(--form-field-msg-font-size, var(--xh-font-size-small-px)); + --xh-form-field-msg-line-height: var(--form-field-msg-line-height, 1.5em); + --xh-form-field-msg-margin: var(--form-field-msg-margin, var(--xh-pad-half-px) 0 0 0); + --xh-form-field-msg-text-transform: var(--form-field-msg-text-transform, none); + + // Validation colors + borders + --xh-form-field-info-border: #{(var(--xh-form-field-info-border-width-px) solid var(--xh-form-field-info-border-color))}; + --xh-form-field-info-border-color: var(--form-field-info-border-color, var(--xh-intent-primary-text-color)); + --xh-form-field-info-border-width: var(--form-field-info-border-width, 1); + --xh-form-field-info-border-width-px: calc(var(--xh-form-field-info-border-width) * 1px); + --xh-form-field-info-box-shadow: var(--form-field-info-border-color, #{inset 0 0 0 1px var(--xh-form-field-info-border-color), inset 0 1px 1px var(--xh-form-field-info-border-color)}); + --xh-form-field-info-color: var(--form-field-info-color, var(--xh-intent-primary-text-color)); + + --xh-form-field-invalid-border: #{(var(--xh-form-field-invalid-border-width-px) solid var(--xh-form-field-invalid-border-color))}; + --xh-form-field-invalid-border-color: var(--form-field-invalid-border-color, var(--xh-intent-danger-text-color)); --xh-form-field-invalid-border-width: var(--form-field-invalid-border-width, 1); --xh-form-field-invalid-border-width-px: calc(var(--xh-form-field-invalid-border-width) * 1px); - --xh-form-field-invalid-border: #{(var(--xh-form-field-invalid-border-width-px) solid var(--xh-form-field-invalid-border-color))}; - --xh-form-field-invalid-message-text-color: var(--form-field-invalid-message-text-color, var(--xh-intent-danger)); - --xh-form-field-margin-bottom: var(--form-field-margin-bottom, 0); - --xh-form-field-margin-right: var(--form-field-margin-right, 0); + --xh-form-field-invalid-box-shadow: var(--form-field-invalid-border-color, #{inset 0 0 0 1px var(--xh-form-field-invalid-border-color), inset 0 1px 1px var(--xh-form-field-invalid-border-color)}); + --xh-form-field-invalid-color: var(--form-field-invalid-color, var(--xh-intent-danger-text-color)); + + --xh-form-field-warning-border: #{(var(--xh-form-field-warning-border-width-px) solid var(--xh-form-field-warning-border-color))}; + --xh-form-field-warning-border-color: var(--form-field-warning-border-color, var(--xh-intent-warning-text-color)); + --xh-form-field-warning-border-width: var(--form-field-warning-border-width, 1); + --xh-form-field-warning-border-width-px: calc(var(--xh-form-field-warning-border-width) * 1px); + --xh-form-field-warning-box-shadow: var(--form-field-warning-border-color, #{inset 0 0 0 1px var(--xh-form-field-warning-border-color), inset 0 1px 1px var(--xh-form-field-warning-border-color)}); + --xh-form-field-warning-color: var(--form-field-warning-color, var(--xh-intent-warning-text-color)); &.xh-dark { --xh-form-field-box-shadow-color-top: var(--form-field-box-shadow-color-top, #{mc-trans('blue-grey', '800', 0.15)}); @@ -557,16 +613,18 @@ body { // Inputs //------------------------ --xh-input-bg: var(--input-bg, var(--xh-bg)); - --xh-input-disabled-bg: var(--form-field-disabled-bg, rgba(206, 217, 224, 0.5)); // This and below currently matched from Blueprint defaults - --xh-input-disabled-text-color: var(--form-field-disabled-text-color, rgba(92, 112, 128, 0.5)); + --xh-input-disabled-bg: var(--input-disabled-bg, rgba(206, 217, 224, 0.5)); // This and below currently matched from Blueprint defaults + --xh-input-disabled-text-color: var(--input-disabled-text-color, rgba(92, 112, 128, 0.5)); + --xh-input-font-family: var(--input-font-family, var(--xh-font-family)); + --xh-input-font-feature-settings: var(--input-font-feature-settings, var(--xh-font-feature-settings)); --xh-input-text-color: var(--input-text-color, var(--xh-text-color)); --xh-input-placeholder-text-color: var(--input-placeholder-color, rgb(175, 183, 191)); --xh-input-disabled-checkmark-svg: var(--input-disabled-checkmark-svg, url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill-rule='evenodd' clip-rule='evenodd' d='M12 5c-.28 0-.53.11-.71.29L7 9.59l-2.29-2.3a1.003 1.003 0 00-1.42 1.42l3 3c.18.18.43.29.71.29s.53-.11.71-.29l5-5A1.003 1.003 0 0012 5z' fill='rgba(92, 112, 128, 0.5)'/%3e%3c/svg%3e")); &.xh-dark { --xh-input-bg: var(--input-bg, rgba(0, 0, 0, 0.3)); // Allow some blending w/backgrounds - e.g. in dialog. - --xh-input-disabled-bg: var(--form-field-disabled-bg, rgba(57, 75, 89, 0.5)); // This and below currently matched from Blueprint defaults - --xh-input-disabled-text-color: var(--form-field-disabled-text-color, rgba(191, 204, 214, 0.5)); + --xh-input-disabled-bg: var(--input-disabled-bg, rgba(57, 75, 89, 0.5)); // This and below currently matched from Blueprint defaults + --xh-input-disabled-text-color: var(--input-disabled-text-color, rgba(191, 204, 214, 0.5)); --xh-input-disabled-checkmark-svg: var(--input-disabled-checkmark-svg, url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill-rule='evenodd' clip-rule='evenodd' d='M12 5c-.28 0-.53.11-.71.29L7 9.59l-2.29-2.3a1.003 1.003 0 00-1.42 1.42l3 3c.18.18.43.29.71.29s.53-.11.71-.29l5-5A1.003 1.003 0 0012 5z' fill='rgba(191, 204, 214, 0.5)'/%3e%3c/svg%3e")); }