Skip to content

Commit

Permalink
feat(quantity): add validity state
Browse files Browse the repository at this point in the history
  • Loading branch information
aesteves60 authored and dpellier committed Nov 28, 2024
1 parent 72eb7f8 commit 800882c
Show file tree
Hide file tree
Showing 6 changed files with 410 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export class OdsInput {
this.onMaskedChange();

this.observer = new MutationObserver((mutations: MutationRecord[]) => {

for (const mutation of mutations) {
if (mutation.attributeName === 'value') {
this.onValueChange(mutation.oldValue);
Expand All @@ -142,6 +143,7 @@ export class OdsInput {
this.onValueChange();

this.observer?.observe(this.el, {
attributes: true,
attributeFilter: ['value'],
attributeOldValue: true,
});
Expand Down Expand Up @@ -213,12 +215,16 @@ export class OdsInput {

this.odsChange.emit({
name: this.name,
previousValue, // TODO always string if coming from mutation oldValue => need to parse if type number
previousValue: this.isNumeric(previousValue) ? Number(previousValue) : previousValue,
validity: this.internals.validity,
value: this.value,
});
}

private isNumeric(n?: string | number | null): boolean {
return !!n && !isNaN(parseFloat(n.toString())) && isFinite(n as number);
}

render(): FunctionalComponent {
const hasClearableIcon = this.isClearable && !this.isLoading && !!this.value;
const hasToggleMaskIcon = this.isPassword && !this.isLoading;
Expand Down
2 changes: 1 addition & 1 deletion packages/ods/src/components/quantity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"lint:scss": "stylelint 'src/components/**/*.scss'",
"lint:ts": "eslint '{src,tests}/**/*.{js,ts,tsx}'",
"start": "stencil build --dev --watch --serve",
"test:e2e": "stencil test --e2e --config stencil.config.ts --max-workers=2",
"test:e2e": "stencil test --e2e --runInBand --config stencil.config.ts --max-workers=2",
"test:e2e:ci": "tsc --noEmit && stencil test --e2e --ci --runInBand --config stencil.config.ts",
"test:spec": "stencil test --spec --config stencil.config.ts --coverage",
"test:spec:ci": "tsc --noEmit && stencil test --config stencil.config.ts --spec --ci"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AttachInternals, Component, Event, type EventEmitter, type FunctionalComponent, Host, Method, Prop, h } from '@stencil/core';
import { AttachInternals, Component, Event, type EventEmitter, type FunctionalComponent, Host, Listen, Method, Prop, State, h } from '@stencil/core';
import { submitFormOnEnter } from '../../../../../utils/dom';
import { ODS_BUTTON_COLOR, ODS_BUTTON_SIZE, ODS_BUTTON_VARIANT } from '../../../../button/src';
import { ODS_ICON_NAME } from '../../../../icon/src';
Expand All @@ -21,6 +21,8 @@ export class OdsQuantity {

@AttachInternals() internals!: ElementInternals;

@State() isInvalid: boolean = false;

@Prop({ reflect: true }) public ariaLabel: HTMLElement['ariaLabel'] = null;
@Prop({ reflect: true }) public ariaLabelledby?: string;
@Prop({ reflect: true }) public defaultValue?: number;
Expand All @@ -41,39 +43,59 @@ export class OdsQuantity {
@Event() odsFocus!: EventEmitter<void>;
@Event() odsReset!: EventEmitter<void>;

@Listen('invalid')
onInvalidEvent(event: Event): void {
// Remove the native validation message popup
event.preventDefault();

// Enforce the state here as we may still be in pristine state (if the form is submitted before any changes occurs)
this.isInvalid = true;
}

@Method()
async checkValidity(): Promise<boolean> {
return this.internals.checkValidity();
}

@Method()
async clear(): Promise<void> {
return this.odsInput?.clear();
await this.odsInput?.clear();
setTimeout(() => this.isInvalid = !this.internals.validity.valid, 0);
}

@Method()
async getValidationMessage(): Promise<string> {
return this.internals.validationMessage;
}

@Method()
async getValidity(): Promise<ValidityState | undefined> {
return this.odsInput?.getValidity();
return this.internals.validity;
}

@Method()
async reportValidity(): Promise<boolean> {
return this.internals.reportValidity();
}

@Method()
async reset(): Promise<void> {
return this.odsInput?.reset();
await this.odsInput?.reset();
setTimeout(() => this.isInvalid = !this.internals.validity.valid, 0);
}

componentWillLoad(): void {
// if (!this.value && this.value !== 0) {
// this.value = this.defaultValue ?? null;
// }
// setFormValue(this.internals, this.value);
@Method()
async willValidate(): Promise<boolean> {
return this.internals.willValidate;
}

if (!this.value && this.value !== 0 && (this.value !== VALUE_DEFAULT_VALUE || this.defaultValue)) {
componentWillLoad(): void {
if (!this.value && this.value !== 0 && (this.value !== VALUE_DEFAULT_VALUE || this.defaultValue !== undefined)) {
this.value = this.defaultValue ?? null;
}
}

async componentDidLoad(): Promise<void> {
// const validityState = await this.odsInput?.getValidity()
await updateInternals(this.internals, this.value, this.odsInput);
}

Expand Down Expand Up @@ -103,16 +125,22 @@ export class OdsQuantity {
} else {
this.value = Number(event.detail.value) ?? null;
}
// setFormValue(this.internals, this.value);

await updateInternals(this.internals, this.value, this.odsInput);
}

private getHasError(): boolean {
return this.hasError || this.isInvalid;
}

render(): FunctionalComponent {
return (
<Host class="ods-quantity">
<Host class="ods-quantity"
disabled={ this.isDisabled }
readonly={ this.isReadonly }>
<ods-button
class="ods-quantity__button"
color={ this.hasError ? ODS_BUTTON_COLOR.critical : ODS_BUTTON_COLOR.primary }
color={ this.getHasError() ? ODS_BUTTON_COLOR.critical : ODS_BUTTON_COLOR.primary }
exportparts="button:button-minus"
isDisabled={ isMinusButtonDisabled(this.isDisabled, this.isReadonly, this.value, this.min) }
icon={ ODS_ICON_NAME.minus }
Expand All @@ -128,7 +156,7 @@ export class OdsQuantity {
class="ods-quantity__input"
defaultValue={ this.defaultValue }
exportparts="input"
hasError={ this.hasError }
hasError={ this.getHasError() }
isDisabled={ this.isDisabled }
isReadonly={ this.isReadonly }
isRequired={ this.isRequired }
Expand All @@ -146,7 +174,7 @@ export class OdsQuantity {

<ods-button
class="ods-quantity__button"
color={ this.hasError ? ODS_BUTTON_COLOR.critical : ODS_BUTTON_COLOR.primary }
color={ this.getHasError() ? ODS_BUTTON_COLOR.critical : ODS_BUTTON_COLOR.primary }
exportparts="button:button-plus"
isDisabled={ isPlusButtonDisabled(this.isDisabled, this.isReadonly, this.value, this.max) }
icon={ ODS_ICON_NAME.plus }
Expand Down
7 changes: 7 additions & 0 deletions packages/ods/src/components/quantity/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
<form id="my-form">
<ods-quantity id="form-quantity"
is-required
default-value="0"
name="form-quantity">
</ods-quantity>

Expand Down Expand Up @@ -124,6 +125,12 @@
await formQuantity.reset();
});

['odsBlur', 'odsClear', 'odsFocus', 'odsReset', 'odsChange'].forEach((eventName) =>{
formQuantity.addEventListener(eventName, (event) => {
console.log(eventName, event);
});
});

submitFormButton.addEventListener('click', (e) => {
// e.preventDefault(); // comment to test native validation messages
const formData = new FormData(formElement);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,62 @@ describe('ods-quantity rendering', () => {
expect(buttonAdd.getAttribute('color')).toBe(ODS_BUTTON_COLOR.critical);
});
});

describe('error state', () => {
it('should render in error on form submit, before any changes, if invalid', async() => {
await setup('<form method="get" onsubmit="return false"><ods-quantity is-required></ods-quantity></form>');

await page.evaluate(() => {
document.querySelector<HTMLFormElement>('form')?.requestSubmit();
});
await page.waitForChanges();

const hasErrorClass = await page.evaluate(() => {
return document.querySelector('ods-quantity')?.shadowRoot?.querySelector('.ods-quantity__input')?.shadowRoot?.querySelector('.ods-input__input')?.classList.contains('ods-input__input--error');
});
expect(hasErrorClass).toBe(true);
});

it('should toggle the error state on value change', async() => {
await setup('<form method="get" onsubmit="return false"><ods-quantity is-required></ods-quantity></form>');

await el.type('0');
await page.waitForChanges();
const hasErrorClass = await page.evaluate(() => {
return document.querySelector('ods-quantity')?.shadowRoot?.querySelector('.ods-quantity__input')?.shadowRoot?.querySelector('.ods-input__input')?.classList.contains('ods-input__input--error');
});
expect(hasErrorClass).toBe(false);

await el.callMethod('clear');
await page.click('body', { offset: { x: 400, y: 400 } }); // Blur
await page.waitForChanges();
await new Promise(resolve => setTimeout(resolve, 20000));

const hasErrorClass2 = await page.evaluate(() => {
return document.querySelector('ods-quantity')?.shadowRoot?.querySelector('.ods-quantity__input')?.shadowRoot?.querySelector('.ods-input__input')?.classList.contains('ods-input__input--error');
});
await page.waitForChanges();
expect(hasErrorClass2).toBe(true);
});

it('should enforce the error state if has-error is set even on valid quantity', async() => {
await setup('<form method="get" onsubmit="return false"><ods-quantity is-required has-error value="0"></ods-quantity></form>');
await page.waitForChanges();

const hasErrorClass = await page.evaluate(() => {
return document.querySelector('ods-quantity')?.shadowRoot?.querySelector('.ods-quantity__input')?.shadowRoot?.querySelector('.ods-input__input')?.classList.contains('ods-input__input--error');
});
expect(hasErrorClass).toBe(true);

await page.evaluate(() => {
document.querySelector<HTMLFormElement>('form')?.requestSubmit();
});
await page.waitForChanges();

const hasErrorClass2 = await page.evaluate(() => {
return document.querySelector('ods-quantity')?.shadowRoot?.querySelector('.ods-quantity__input')?.shadowRoot?.querySelector('.ods-input__input')?.classList.contains('ods-input__input--error');
});
expect(hasErrorClass2).toBe(true);
});
});
});
Loading

0 comments on commit 800882c

Please sign in to comment.