diff --git a/CHANGELOG.md b/CHANGELOG.md index d3178d285..076d4dac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added - Button group component now allows resetting the selection state via the `selectedItems` property [#1168](https://github.com/IgniteUI/igniteui-webcomponents/pull/1168) +- Input, Textarea - exposed `validateOnly` to enable validation rules being enforced without restricting user input [#1178](https://github.com/IgniteUI/igniteui-webcomponents/pull/1178) ### Changed - Combo, Select and Dropdown components now use the native Popover API [#1082](https://github.com/IgniteUI/igniteui-webcomponents/pull/1082) diff --git a/src/components/button-group/toggle-button.ts b/src/components/button-group/toggle-button.ts index e98f33011..7b4ce51ff 100644 --- a/src/components/button-group/toggle-button.ts +++ b/src/components/button-group/toggle-button.ts @@ -18,7 +18,7 @@ import { styles as shared } from './themes/shared/button/button.common.css.js'; * * @csspart toggle - The native button element. */ -@themes(all, true) +@themes(all) export default class IgcToggleButtonComponent extends LitElement { public static override styles = [styles, shared]; public static readonly tagName = 'igc-toggle-button'; diff --git a/src/components/common/util.ts b/src/components/common/util.ts index 137c27ca6..cd5f108d4 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -55,24 +55,20 @@ export function isLTR(element: HTMLElement) { /** * Builds a string from format specifiers and replacement parameters. + * Will coerce non-string parameters to their string representations. * * @example * ```typescript - * format('{0} says "{1}".', 'John', 'Hello'); // 'John says "Hello".' + * formatString('{0} says "{1}".', 'John', 'Hello'); // 'John says "Hello".' + * formatString('{1} is greater than {0}', 0, 1); // '1 is greater than 0' * ``` */ -export function format(template: string, ...params: string[]): string { - return template.replace(/{(\d+)}/g, (match: string, index: number) => { - if (index >= params.length) { - return match; - } +export function formatString(template: string, ...params: unknown[]): string { + const length = params.length; - const value: string = params[index]; - if (typeof value !== 'number' && !value) { - return ''; - } - return value; - }); + return template.replace(/{(\d+)}/g, (match: string, index: number) => + index >= length ? match : `${params[index]}` + ); } /** diff --git a/src/components/common/validators.ts b/src/components/common/validators.ts index 6d1d6c08d..c3a64df84 100644 --- a/src/components/common/validators.ts +++ b/src/components/common/validators.ts @@ -1,5 +1,5 @@ import validatorMessages from './localization/validation-en.js'; -import { asNumber, format, isDefined } from './util.js'; +import { asNumber, formatString, isDefined } from './util.js'; type ValidatorHandler = (host: T) => boolean; type ValidatorMessageFormat = (host: T) => string; @@ -43,7 +43,7 @@ export const minLengthValidator: Validator<{ }> = { key: 'tooShort', message: ({ minLength }) => - format(validatorMessages.minLength, `${minLength}`), + formatString(validatorMessages.minLength, minLength), isValid: ({ minLength, value }) => minLength ? value.length >= minLength : true, }; @@ -54,7 +54,7 @@ export const maxLengthValidator: Validator<{ }> = { key: 'tooLong', message: ({ maxLength }) => - format(validatorMessages.maxLength, `${maxLength}`), + formatString(validatorMessages.maxLength, maxLength), isValid: ({ maxLength, value }) => maxLength ? value.length <= maxLength : true, }; @@ -71,7 +71,7 @@ export const minValidator: Validator<{ value: number | string; }> = { key: 'rangeUnderflow', - message: ({ min }) => format(validatorMessages.min, `${min}`), + message: ({ min }) => formatString(validatorMessages.min, min), isValid: ({ min, value }) => isDefined(min) ? isDefined(value) && asNumber(value) >= asNumber(min) @@ -83,7 +83,7 @@ export const maxValidator: Validator<{ value: number | string; }> = { key: 'rangeOverflow', - message: ({ max }) => format(validatorMessages.max, `${max}`), + message: ({ max }) => formatString(validatorMessages.max, max), isValid: ({ max, value }) => isDefined(max) ? isDefined(value) && asNumber(value) <= asNumber(max) diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index fedc46e3c..342db4103 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -17,7 +17,7 @@ import { registerComponent } from '../common/definitions/register.js'; import messages from '../common/localization/validation-en.js'; import type { AbstractConstructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import { format, partNameMap } from '../common/util.js'; +import { formatString, partNameMap } from '../common/util.js'; import type { Validator } from '../common/validators.js'; import type { IgcInputEventMap } from '../input/input-base.js'; import { @@ -84,7 +84,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< }, { key: 'rangeUnderflow', - message: () => format(messages.min, `${this.min}`), + message: () => formatString(messages.min, this.min), isValid: () => this.min ? !DateTimeUtil.lessThanMinValue( @@ -97,7 +97,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< }, { key: 'rangeOverflow', - message: () => format(messages.max, `${this.max}`), + message: () => formatString(messages.max, this.max), isValid: () => this.max ? !DateTimeUtil.greaterThanMaxValue( diff --git a/src/components/input/input.ts b/src/components/input/input.ts index 95815e165..a2b5a263e 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -3,9 +3,6 @@ import { property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; -import { alternateName } from '../common/decorators/alternateName.js'; -import { blazorSuppress } from '../common/decorators/blazorSuppress.js'; -import { blazorTwoWayBind } from '../common/decorators/blazorTwoWayBind.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import { partNameMap } from '../common/util.js'; @@ -111,12 +108,12 @@ export default class IgcInputComponent extends IgcInputBaseComponent { protected _value = ''; + /* @tsTwoWayProperty(true, "igcChange", "detail", false) */ /** * The value of the control. * @attr */ @property() - @blazorTwoWayBind('igcChange', 'detail') public set value(value: string) { this._value = value; this.setFormValue(value ? value : null); @@ -128,11 +125,11 @@ export default class IgcInputComponent extends IgcInputBaseComponent { return this._value; } + /* alternateName: displayType */ /** * The type attribute of the control. * @attr */ - @alternateName('displayType') @property({ reflect: true }) public type: | 'email' @@ -247,6 +244,15 @@ export default class IgcInputComponent extends IgcInputBaseComponent { @property() public autocomplete!: string; + /** + * Enables validation rules to be evaluated without restricting user input. This applies to the `maxLength` property for + * string-type inputs or allows spin buttons to exceed the predefined `min/max` limits for number-type inputs. + * + * @attr validate-only + */ + @property({ type: Boolean, reflect: true, attribute: 'validate-only' }) + public validateOnly = false; + /** * @internal */ @@ -270,8 +276,8 @@ export default class IgcInputComponent extends IgcInputBaseComponent { this.updateValidity(); } + /* blazorSuppress */ /** Replaces the selected text in the input. */ - @blazorSuppress() public override setRangeText( replacement: string, start: number, @@ -336,10 +342,10 @@ export default class IgcInputComponent extends IgcInputBaseComponent { tabindex=${this.tabIndex} autocomplete=${ifDefined(this.autocomplete as any)} inputmode=${ifDefined(this.inputmode)} - min=${ifDefined(this.min)} - max=${ifDefined(this.max)} + min=${ifDefined(this.validateOnly ? undefined : this.min)} + max=${ifDefined(this.validateOnly ? undefined : this.max)} minlength=${ifDefined(this.minLength)} - maxlength=${ifDefined(this.maxLength)} + maxlength=${ifDefined(this.validateOnly ? undefined : this.maxLength)} step=${ifDefined(this.step)} aria-invalid=${this.invalid ? 'true' : 'false'} @change=${this.handleChange} diff --git a/src/components/progress/base.ts b/src/components/progress/base.ts index 7eed0b42a..6ab177569 100644 --- a/src/components/progress/base.ts +++ b/src/components/progress/base.ts @@ -7,7 +7,7 @@ import { } from 'lit/decorators.js'; import { watch } from '../common/decorators/watch.js'; -import { asPercent, clamp, format } from '../common/util.js'; +import { asPercent, clamp, formatString } from '../common/util.js'; export abstract class IgcProgressBaseComponent extends LitElement { private __internals: ElementInternals; @@ -193,7 +193,7 @@ export abstract class IgcProgressBaseComponent extends LitElement { } protected renderLabelFormat() { - return format(this.labelFormat, `${this.value}`, `${this.max}`); + return formatString(this.labelFormat, this.value, this.max); } protected renderDefaultSlot() { diff --git a/src/components/rating/rating.ts b/src/components/rating/rating.ts index 956e32831..4b92d3aab 100644 --- a/src/components/rating/rating.ts +++ b/src/components/rating/rating.ts @@ -26,7 +26,7 @@ import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormAssociatedMixin } from '../common/mixins/form-associated.js'; import { SizableMixin } from '../common/mixins/sizable.js'; -import { clamp, format, isLTR } from '../common/util.js'; +import { clamp, formatString, isLTR } from '../common/util.js'; import IgcIconComponent from '../icon/icon.js'; import IgcRatingSymbolComponent from './rating-symbol.js'; import { styles } from './themes/rating.base.css.js'; @@ -61,7 +61,7 @@ export interface IgcRatingEventMap { * @cssproperty --symbol-full-filter - The filter(s) used for the filled symbol. * @cssproperty --symbol-empty-filter - The filter(s) used for the empty symbol. */ -@themes(all, true) +@themes(all) export default class IgcRatingComponent extends FormAssociatedMixin( SizableMixin( EventEmitterMixin>(LitElement) @@ -109,7 +109,7 @@ export default class IgcRatingComponent extends FormAssociatedMixin( // Skip IEEE 754 representation for screen readers const value = this.round(this.value); return this.valueFormat - ? format(this.valueFormat, `${value}`, `${this.max}`) + ? formatString(this.valueFormat, value, this.max) : `${value} of ${this.max}`; } @@ -431,8 +431,8 @@ export default class IgcRatingComponent extends FormAssociatedMixin( aria-hidden="true" part="symbols" @click=${this.isInteractive ? this.handleClick : nothing} - @mouseenter=${hoverActive ? () => this.handleHoverEnabled : nothing} - @mouseleave=${hoverActive ? () => this.handleHoverDisabled : nothing} + @mouseenter=${hoverActive ? this.handleHoverEnabled : nothing} + @mouseleave=${hoverActive ? this.handleHoverDisabled : nothing} @mousemove=${hoverActive ? this.handleMouseMove : nothing} > diff --git a/src/components/slider/slider-base.ts b/src/components/slider/slider-base.ts index 3a5967ddc..621326307 100644 --- a/src/components/slider/slider-base.ts +++ b/src/components/slider/slider-base.ts @@ -27,7 +27,7 @@ import { asNumber, asPercent, clamp, - format, + formatString, isDefined, isLTR, } from '../common/util.js'; @@ -378,7 +378,9 @@ export class IgcSliderBaseComponent extends LitElement { protected formatValue(value: number) { const strValue = value.toLocaleString(this.locale, this.valueFormatOptions); - return this.valueFormat ? format(this.valueFormat, strValue) : strValue; + return this.valueFormat + ? formatString(this.valueFormat, strValue) + : strValue; } private normalizeByStep(value: number) { diff --git a/src/components/stepper/step.ts b/src/components/stepper/step.ts index 433758b1d..0cac84348 100644 --- a/src/components/stepper/step.ts +++ b/src/components/stepper/step.ts @@ -44,7 +44,7 @@ import { all } from './themes/step/themes.js'; * @csspart body - Wrapper of the step's `content`. * @csspart content - The steps `content`. */ -@themes(all, true) +@themes(all) export default class IgcStepComponent extends LitElement { public static readonly tagName = 'igc-step'; public static override styles = [styles, shared]; diff --git a/src/components/tabs/tab.ts b/src/components/tabs/tab.ts index cea2269c8..61a65d222 100644 --- a/src/components/tabs/tab.ts +++ b/src/components/tabs/tab.ts @@ -22,7 +22,7 @@ import { styles } from './themes/tab.base.css.js'; * @csspart suffix - The suffix wrapper. */ -@themes(all, true) +@themes(all) export default class IgcTabComponent extends LitElement { public static readonly tagName = 'igc-tab'; public static override styles = [styles, shared]; diff --git a/src/components/tabs/tabs.ts b/src/components/tabs/tabs.ts index 908cf7324..22c58e758 100644 --- a/src/components/tabs/tabs.ts +++ b/src/components/tabs/tabs.ts @@ -56,7 +56,7 @@ export interface IgcTabsEventMap { * @csspart end-scroll-button - The end scroll button displayed when the tabs overflow. * @csspart content - The container for the tabs content. */ -@themes(all, true) +@themes(all) @blazorAdditionalDependencies('IgcTabComponent, IgcTabPanelComponent') export default class IgcTabsComponent extends EventEmitterMixin< IgcTabsEventMap, diff --git a/src/components/textarea/textarea.ts b/src/components/textarea/textarea.ts index 6a5f598ec..cd5d29ae7 100644 --- a/src/components/textarea/textarea.ts +++ b/src/components/textarea/textarea.ts @@ -252,6 +252,15 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin( @property() public wrap: 'hard' | 'soft' | 'off' = 'soft'; + /** + * Enables validation rules to be evaluated without restricting user input. This applies to the `maxLength` property + * when it is defined. + * + * @attr validate-only + */ + @property({ type: Boolean, reflect: true, attribute: 'validate-only' }) + public validateOnly = false; + constructor() { super(); this.addEventListener('focus', () => { @@ -467,6 +476,8 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin( autocapitalize=${ifDefined(this.autocapitalize)} inputmode=${ifDefined(this.inputMode)} spellcheck=${ifDefined(this.spellcheck)} + minlength=${ifDefined(this.minLength)} + maxlength=${ifDefined(this.validateOnly ? undefined : this.maxLength)} ?disabled=${this.disabled} ?required=${this.required} ?readonly=${this.readOnly} diff --git a/src/components/tree/tree-item.ts b/src/components/tree/tree-item.ts index be7b570db..fac9297cc 100644 --- a/src/components/tree/tree-item.ts +++ b/src/components/tree/tree-item.ts @@ -48,7 +48,7 @@ import type { IgcTreeSelectionService } from './tree.selection.js'; * @csspart text - The tree item displayed text. * @csspart select - The checkbox of the tree item when selection is enabled. */ -@themes(all, true) +@themes(all) export default class IgcTreeItemComponent extends LitElement { public static readonly tagName = 'igc-tree-item'; public static override styles = [styles, shared]; diff --git a/stories/input.stories.ts b/stories/input.stories.ts index f8362f6f1..60007df76 100644 --- a/stories/input.stories.ts +++ b/stories/input.stories.ts @@ -103,6 +103,13 @@ const metadata: Meta = { description: 'The autocomplete attribute of the control.', control: 'text', }, + validateOnly: { + type: 'boolean', + description: + 'Enables validation rules to be evaluated without restricting user input. This applies to the `maxLength` property for\nstring-type inputs or allows spin buttons to exceed the predefined `min/max` limits for number-type inputs.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, required: { type: 'boolean', description: 'Makes the control a required field in a form context.', @@ -152,6 +159,7 @@ const metadata: Meta = { args: { type: 'text', autofocus: false, + validateOnly: false, required: false, disabled: false, invalid: false, @@ -193,6 +201,11 @@ interface IgcInputArgs { autofocus: boolean; /** The autocomplete attribute of the control. */ autocomplete: string; + /** + * Enables validation rules to be evaluated without restricting user input. This applies to the `maxLength` property for + * string-type inputs or allows spin buttons to exceed the predefined `min/max` limits for number-type inputs. + */ + validateOnly: boolean; /** Makes the control a required field in a form context. */ required: boolean; /** The name attribute of the control. */ @@ -262,66 +275,171 @@ export const Form: Story = { render: () => { return html`
- - + +

+ Default state with no initial value and no validation. +

+
+ + +

+ With initial value and no validation. Resetting the form will + restore the initial value. +

+
-
+ +
+ readonly + > +

+ Read-only state. Does participate in form + submission. +

+ +
+ +
+ +

+ Disabled state. Does not participate in form + submission. +

+
+
+ +
+ +

With required validator.

+
+
+
- + > +

With minimum length validator.

+ + + > +

+ With maximum length validator. Since validate-only is not applied, + typing in the input beyond the maximum length is not possible. +

+ + + +

+ With maximum length validator and validate-only applied. Typing in + the input beyond the maximum length is possible and will invalidate + the input. +

+
+
+ +
+ > +

+ With minimum value validator. Since validate-only is not applied, + using the spin buttons to go below the minimum value is not + possible. +

+ + + +

+ With minimum value validator and validate-only applied. Using the + spin buttons to go below the minimum value is possible and will + invalidate the input. +

+
+ + > +

+ With maximum value validator. Since validate-only is not applied, + using the spin buttons to go above the maximum value is not + possible. +

+ + + +

+ With maximum value validator and validate-only applied. Using the + spin buttons to go above the maximum value is possible and will + invalidate the input. +

+
+
+ +
+ > +

With pattern validator.

+ + + > +

With email validator.

+ + > +

With URL validator.

+
${formControls()} `; diff --git a/stories/textarea.stories.ts b/stories/textarea.stories.ts index 74f4037b0..870102b0b 100644 --- a/stories/textarea.stories.ts +++ b/stories/textarea.stories.ts @@ -130,6 +130,13 @@ const metadata: Meta = { control: { type: 'inline-radio' }, table: { defaultValue: { summary: 'soft' } }, }, + validateOnly: { + type: 'boolean', + description: + 'Enables validation rules to be evaluated without restricting user input. This applies to the `maxLength` property\nwhen it is defined.', + control: 'boolean', + table: { defaultValue: { summary: false } }, + }, required: { type: 'boolean', description: 'Makes the control a required field in a form context.', @@ -162,6 +169,7 @@ const metadata: Meta = { value: '', spellcheck: true, wrap: 'soft', + validateOnly: false, required: false, disabled: false, invalid: false, @@ -233,6 +241,11 @@ interface IgcTextareaArgs { * for explanation of the available values. */ wrap: 'hard' | 'soft' | 'off'; + /** + * Enables validation rules to be evaluated without restricting user input. This applies to the `maxLength` property + * when it is defined. + */ + validateOnly: boolean; /** Makes the control a required field in a form context. */ required: boolean; /** The name attribute of the control. */ @@ -286,52 +299,105 @@ export const Form: Story = { return html`
- + +

+ Default state. No initial value and no validation. +

+
+
+ > +

+ Initial value bound through property and no validation.Resetting + the form will restore the initial value. +

+ + Hello world! +

+ Initial value bound through text projection and no + validation.Resetting the form will restore the initial value. +

+
+ > +
+

+ Disabled state. Does not participate in form + submission. +

+
+
+
+ > +
+

+ Read-only state. Does participate in form + submission. +

+
+
+
- + +

With required validator.

+
+ + >

+ With minimum length validator. +

+ + > +

+ With maximum length validator. Since validate-only is not applied, + typing in the input beyond the maximum length is not possible. +

+ + + +

+ With maximum length validator and validate-only applied. Typing in + the input beyond the maximum length is possible and will + invalidate the input. +

+
${formControls()}