Skip to content

Commit

Permalink
feat(toggle): 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 c7aee7d commit 4c5adbf
Show file tree
Hide file tree
Showing 9 changed files with 560 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@
}
}

&--error {
border: 1px solid var(--ods-color-form-element-border-critical);

&::before {
bottom: 1px;
}
}

&__label {
display: flex;
position: absolute;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AttachInternals, Component, Event, type EventEmitter, type FunctionalComponent, Host, Method, Prop, h } from '@stencil/core';
import { AttachInternals, Component, Element, Event, type EventEmitter, type FunctionalComponent, Host, Listen, Method, Prop, State, h } from '@stencil/core';
import { submitFormOnEnter } from '../../../../../utils/dom';
import { setFormValue } from '../../controller/ods-toggle';
import { updateInternals } from '../../controller/ods-toggle';
import { type OdsToggleChangeEventDetail } from '../../interfaces/event';

@Component({
Expand All @@ -13,9 +13,15 @@ import { type OdsToggleChangeEventDetail } from '../../interfaces/event';
})
export class OdsToggle {
private inputEl?: HTMLInputElement;
private observer?: MutationObserver;
private shouldUpdateIsInvalidState: boolean = false;

@Element() el!: HTMLElement;

@AttachInternals() private internals!: ElementInternals;

@State() private isInvalid: boolean = false;

@Prop({ reflect: true }) public defaultValue?: boolean;
// @Prop({ reflect: true }) public hasError: boolean = false; // Necessary ?
@Prop({ reflect: true }) public isDisabled: boolean = false;
Expand All @@ -30,62 +36,148 @@ export class OdsToggle {
@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()
public async checkValidity(): Promise<boolean> {
this.isInvalid = !this.internals.validity.valid;
return this.internals.checkValidity();
}

@Method()
public async clear(): Promise<void> {
this.value = null;
this.inputEl?.focus();
this.odsClear.emit();

// Element internal validityState is not yet updated, so we set the flag
// to update our internal state when it will be up-to-date
this.shouldUpdateIsInvalidState = true;
}

@Method()
public async getValidity(): Promise<ValidityState | undefined> {
return this.inputEl?.validity;
public async getValidationMessage(): Promise<string> {
return this.internals.validationMessage;
}

@Method()
public async getValidity(): Promise<ValidityState> {
return this.internals.validity;
}

@Method()
public async reportValidity(): Promise<boolean> {
this.isInvalid = !this.internals.validity.valid;
return this.internals.reportValidity();
}

@Method()
public async reset(): Promise<void> {
this.value = this.defaultValue ?? null;
this.odsReset.emit();

// Element internal validityState is not yet updated, so we set the flag
// to update our internal state when it will be up-to-date
this.shouldUpdateIsInvalidState = true;
}

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

componentWillLoad(): void {
this.observer = new MutationObserver((mutations: MutationRecord[]) => {
for (const mutation of mutations) {
if (mutation.attributeName === 'value') {
this.onValueChange();
}

// When observing is-required, the inner element validity is not yet up-to-date
// so we observe the element required attribute instead
if (mutation.attributeName === 'required') {
updateInternals(this.internals, this.value, this.inputEl);
this.isInvalid = !this.internals.validity.valid;
}
}
});

if (!this.value) {
this.value = this.defaultValue ?? null;
}
setFormValue(this.internals, this.value);
}

componentDidLoad(): void {
// Init the internals correctly as native element validity is now up-to-date
this.onValueChange();

this.observer?.observe(this.el, {
attributeFilter: ['value'],
attributeOldValue: true,
});

if (this.inputEl) {
this.observer?.observe(this.inputEl, {
attributeFilter: ['required'],
attributeOldValue: false,
});
}
}

async formResetCallback(): Promise<void> {
await this.reset();
}

private onBlur(): void {
this.isInvalid = !this.internals.validity.valid;
this.odsBlur.emit();
}

private onInput(): void {
if (this.isDisabled) {
return;
}

this.value = !this.value;
setFormValue(this.internals, this.value);
}

private onValueChange(): void {
updateInternals(this.internals, this.value, this.inputEl);

// In case the value gets updated from an other source than a blur event
// we may have to perform an internal validity state update
if (this.shouldUpdateIsInvalidState) {
this.isInvalid = !this.internals.validity.valid;
this.shouldUpdateIsInvalidState = false;
}

this.odsChange.emit({
name: this.name,
previousValue: !this.value,
value: this.value,
value: this.value ?? false,
});
}

render(): FunctionalComponent {
return (
<Host class='ods-toggle'>
<label class="ods-toggle__container">
<Host class='ods-toggle'
disabled={ this.isDisabled }>
<label class='ods-toggle__container'>
<input
checked={ this.value ?? false }
class="ods-toggle__container__input"
disabled={ this.isDisabled }
name={ this.name }
onBlur={ () => this.odsBlur.emit() }
onBlur={ () => this.onBlur() }
onFocus={ () => this.odsFocus.emit() }
onInput={ this.onInput.bind(this) }
onInput={ () => this.onInput() }
onKeyUp={ (event: KeyboardEvent): void => submitFormOnEnter(event, this.internals.form) }
ref={ (el): HTMLInputElement => this.inputEl = el as HTMLInputElement }
required={ this.isRequired }
Expand All @@ -97,6 +189,7 @@ export class OdsToggle {
'ods-toggle__container__slider': true,
'ods-toggle__container__slider--checked': this.value ?? false,
'ods-toggle__container__slider--disabled': this.isDisabled,
'ods-toggle__container__slider--error': this.isInvalid,
}}
part={ `slider ${this.value ? 'checked' : ''}` }>
{
Expand Down
13 changes: 9 additions & 4 deletions packages/ods/src/components/toggle/src/controller/ods-toggle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
function setFormValue(internals: ElementInternals, value: boolean | null): void {
internals.setFormValue(value?.toString() ?? 'false');
}
import { setInternalsValidityFromHtmlElement } from '../../../../utils/dom';

function updateInternals(internals: ElementInternals, value: boolean | null, inputEl?: HTMLInputElement): void {
internals.setFormValue(value?.toString() ?? '');

if (inputEl) {
setInternalsValidityFromHtmlElement(inputEl, internals);
}
}
export {
setFormValue,
updateInternals,
};
2 changes: 1 addition & 1 deletion packages/ods/src/components/toggle/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<body>
<p>Default</p>
<ods-toggle id="default-toggle" default-value="true">
<ods-toggle id="default-toggle" default-value="true" is-required>
</ods-toggle>

<button id="button-clear">Clear</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ describe('ods-toggle rendering', () => {
let page: E2EPage;
let slider: E2EElement;

async function isInErrorState(): Promise<boolean | undefined> {
return await page.evaluate(() => {
return document.querySelector('ods-toggle')?.shadowRoot?.querySelector('.ods-toggle__container__slider')?.classList.contains('ods-toggle__container__slider--error');
});
}

async function setup(content: string, customStyle?: string): Promise<void> {
page = await newE2EPage();

Expand Down Expand Up @@ -57,4 +63,32 @@ describe('ods-toggle rendering', () => {
expect(text).toEqualText('ON');
});
});

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-toggle is-required></ods-toggle></form>');

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

expect(await isInErrorState()).toBe(true);
});

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

await el.click();
await page.waitForChanges();

expect(await isInErrorState()).toBe(false);

await el.callMethod('clear');
await page.click('body', { offset: { x: 200, y: 200 } }); // Blur
await page.waitForChanges();

expect(await isInErrorState()).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import type { SpecPage } from '@stencil/core/testing';
import { newSpecPage } from '@stencil/core/testing';
import { OdsToggle } from '../../src';

// @ts-ignore for test purposes
global.MutationObserver = jest.fn(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
}));

describe('ods-toggle rendering', () => {
let page: SpecPage;
let root: HTMLElement | undefined;
Expand Down
Loading

0 comments on commit 4c5adbf

Please sign in to comment.