Added some missing form validation standard features (implemented for #1181) (#1167)

* #1163 - added read-only properties 'validity' and 'validationMessage' to all nine form controls

* #1163 - added base support for showing form validation messages below the form controls

* #1163 - animated validation errors in demo

* #1181 - Removed all previous changes that have been validation error specific

* Started with 'Inline validation' demo / fixed merge issues / etc.

* #1181 - continued work on missing form validation features

* #1181 - enhanced validation support for SlColorPicker / some cleanup

* #1181 - fixed CSS issues

* #1181 - fixed again CSS issues

* '1181 - added form validation features finally working

* #1181 - bug fixes

* #1181 - fixed open issues / added API doc comments

* #1181 - updated inline validation demos / removed some legacy code

* #1181 - finished invalid form validation example

* #1181 - added tests / several bugfixes

* #1181 - fixed typos etc.

* #1181 - tests

* #1181 - tests

* #1181 - tests
pull/1192/head
xdev1 2023-02-14 20:50:06 +01:00 zatwierdzone przez GitHub
rodzic 19cf823da5
commit 4a28825ea7
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
22 zmienionych plików z 1080 dodań i 39 usunięć

Wyświetl plik

@ -295,13 +295,15 @@ This example demonstrates custom validation styles using `data-user-invalid` and
required
></sl-input>
<sl-select label="Favorite Animal" help-text="Select the best option." clearable required>
<sl-select name="animal" label="Favorite Animal" help-text="Select the best option." clearable required>
<sl-option value="birds">Birds</sl-option>
<sl-option value="cats">Cats</sl-option>
<sl-option value="dogs">Dogs</sl-option>
<sl-option value="other">Other</sl-option>
</sl-select>
<sl-checkbox value="accept" required>Accept terms and conditions</sl-checkbox>
<sl-button type="submit" variant="primary">Submit</sl-button>
<sl-button type="reset" variant="default">Reset</sl-button>
</form>
@ -316,46 +318,452 @@ This example demonstrates custom validation styles using `data-user-invalid` and
<style>
.validity-styles sl-input,
.validity-styles sl-select {
.validity-styles sl-select,
.validity-styles sl-checkbox {
display: block;
margin-bottom: var(--sl-spacing-medium);
}
/* user invalid styles */
.validity-styles sl-input[data-user-invalid]::part(base),
.validity-styles sl-select[data-user-invalid]::part(combobox) {
.validity-styles sl-select[data-user-invalid]::part(combobox),
.validity-styles sl-checkbox[data-user-invalid]::part(control) {
border-color: var(--sl-color-danger-600);
}
.validity-styles [data-user-invalid]::part(form-control-label),
.validity-styles [data-user-invalid]::part(form-control-help-text) {
.validity-styles [data-user-invalid]::part(form-control-help-text),
.validity-styles sl-checkbox[data-user-invalid]::part(label) {
color: var(--sl-color-danger-700);
}
.validity-styles sl-checkbox[data-user-invalid]::part(control) {
outline: none;
}
.validity-styles sl-input:focus-within[data-user-invalid]::part(base),
.validity-styles sl-select:focus-within[data-user-invalid]::part(combobox) {
.validity-styles sl-select:focus-within[data-user-invalid]::part(combobox),
.validity-styles sl-checkbox:focus-within[data-user-invalid]::part(control) {
border-color: var(--sl-color-danger-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
}
/* User valid styles */
.validity-styles sl-input[data-user-valid]::part(base),
.validity-styles sl-select[data-user-valid]::part(combobox) {
.validity-styles sl-select[data-user-valid]::part(combobox),
.validity-styles sl-checkbox[data-user-valid]::part(control) {
border-color: var(--sl-color-success-600);
}
.validity-styles [data-user-valid]::part(form-control-label),
.validity-styles [data-user-valid]::part(form-control-help-text) {
.validity-styles [data-user-valid]::part(form-control-help-text),
.validity-styles sl-checkbox[data-user-valid]::part(label) {
color: var(--sl-color-success-700);
}
.validity-styles sl-checkbox[data-user-valid]::part(control) {
background-color: var(--sl-color-success-600);
outline: none;
}
.validity-styles sl-input:focus-within[data-user-valid]::part(base),
.validity-styles sl-select:focus-within[data-user-valid]::part(combobox) {
.validity-styles sl-select:focus-within[data-user-valid]::part(combobox),
.validity-styles sl-checkbox:focus-within[data-user-valid]::part(control) {
border-color: var(--sl-color-success-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
}
</style>
```
## Inline Form Validation
You can switch from normal validation mode, where validation messages are presented by browser specific tooltips, to an inline validation mode where the validation messages are displayed below the form fields, normally in red color.
This can be achieved completely in userland with customizations using CSS and JavaScript.
Here's the same example as the previous one, but this time we use inline form validation.
```html preview
<sl-animation class="animation-inline-validation" name="shakeX" duration="1000" iterations="1" easing="easeInOut">
<form class="inline-validation">
<sl-input
name="name"
label="Name"
help-text="What would you like people to call you?"
autocomplete="off"
required
></sl-input>
<sl-select name="animal" label="Favorite Animal" help-text="Select the best option." clearable required>
<sl-option value="birds">Birds</sl-option>
<sl-option value="cats">Cats</sl-option>
<sl-option value="dogs">Dogs</sl-option>
<sl-option value="other">Other</sl-option>
</sl-select>
<sl-checkbox value="accept" required>Accept terms and conditions</sl-checkbox>
<sl-button type="submit" variant="primary">Submit</sl-button>
<sl-button type="reset" variant="default">Reset</sl-button>
</form>
</sl-animation>
<style>
.inline-validation sl-input,
.inline-validation sl-select,
.inline-validation sl-checkbox {
display: block;
margin-bottom: var(--sl-spacing-medium);
}
/* user invalid styles */
.inline-validation sl-input[data-user-invalid]::part(base),
.inline-validation sl-select[data-user-invalid]::part(combobox),
.inline-validation sl-checkbox[data-user-invalid]::part(control) {
border-color: var(--sl-color-danger-600);
}
.inline-validation [data-user-invalid]::part(form-control-label),
.inline-validation [data-user-invalid]::part(form-control-help-text),
.inline-validation sl-checkbox[data-user-invalid]::part(label) {
color: var(--sl-color-danger-700);
}
.inline-validation sl-checkbox[data-user-invalid]::part(control) {
outline: none;
}
.inline-validation sl-input:focus-within[data-user-invalid]::part(base),
.inline-validation sl-select:focus-within[data-user-invalid]::part(combobox),
.inline-validation sl-checkbox:focus-within[data-user-invalid]::part(control) {
border-color: var(--sl-color-danger-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
}
/* User valid styles */
.inline-validation sl-input[data-user-valid]::part(base),
.inline-validation sl-select[data-user-valid]::part(combobox),
.inline-validation sl-checkbox[data-user-valid]::part(control) {
border-color: var(--sl-color-success-600);
}
.inline-validation [data-user-valid]::part(form-control-label),
.inline-validation [data-user-valid]::part(form-control-help-text),
.inline-validation sl-checkbox[data-user-valid]::part(label) {
color: var(--sl-color-success-700);
}
.inline-validation sl-checkbox[data-user-valid]::part(control) {
background-color: var(--sl-color-success-600);
outline: none;
}
.inline-validation sl-input:focus-within[data-user-valid]::part(base),
.inline-validation sl-select:focus-within[data-user-valid]::part(combobox),
.inline-validation sl-checkbox:focus-within[data-user-valid]::part(control) {
border-color: var(--sl-color-success-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
}
/* styles for the inline validation messages */
.inline-validation :is([data-valid], [data-invalid]):not(sl-button)::after {
display: block;
font-size: var(--sl-font-size-small);
color: var(--sl-color-danger-700);
content: '\00a0';
}
.inline-validation [data-user-invalid]:not(sl-button)::after {
content: attr(data-error);
}
</style>
<script type="module">
// With the following few lines of JavaScript code plus the app independent
// utility function `activateInlineFormValidation` you can switch to
// inline form validation mode.
const form = document.querySelector('form.inline-validation');
const animation = document.querySelector('sl-animation.animation-inline-validation');
activateInlineFormValidation(form);
form.addEventListener('submit', ev => {
ev.preventDefault();
alert('All fields are valid');
});
// I the user tries to submit invalid form data then shake the form
// for a moment to indicate an submit error
form.addEventListener(
'sl-invalid',
() => {
animation.play = true;
},
true
);
/**
* `activateInlineFormValidation` is a utility function for Shoelace based HTML
* forms. It allows to switch from the usual tooltip based way of showing validation
* errors to inline form validation where validation errors will be displayed below
* the corresponding form controls.
* This will be achieved by dynamically adding data attributes for error messages
* to the form controls, if required. And to use the CSS function `attr(...)`
* to retrieve the error messages in CSS (by using the `::after` pseudo-element).
*
* @param container A DOM container element, for example the form element
* @param errorAttribute Name of the data attribute of the form controls to
* store the current validation message. Default value is
* 'data-error'.
*
* @return Returns a cancellation function to undo the changes that
* have been necessary to activate inline validation
*/
function activateInlineFormValidation(container, errorAttribute = 'data-error') {
let formControls = null; // type: Set<HTMLElement> | null
// Checks whether an element is a Shoelace form control
const isFormControl = elem => {
return (
elem instanceof HTMLElement &&
typeof elem.checkValidity === 'function' &&
typeof elem.reportValidity === 'function' &&
typeof elem.validationMessage === 'string'
);
};
// Updates the error data attribute of a given Shoelace form control,
// depending on the form control's `validationMessage` property
const updateValidationMessage = formControl => {
const message = formControl.validationMessage;
if (typeof message === 'string' && message !== '') {
formControl.setAttribute(errorAttribute, message);
} else {
formControl.removeAttribute(errorAttribute);
}
};
// Updates the error attributes for all Shoelace form controls
// in the container and returns a set of all currently existing
// Shoelace form controls in the container.
const updateAllValidationMessages = () => {
const ret = new Set();
for (const elem of container.querySelectorAll(':is([data-valid], [data-invalid])')) {
if (isFormControl(elem)) {
ret.add(elem);
updateValidationMessage(elem);
}
}
return ret;
};
// --- event handlers --------------
const onInvalid = event => {
// Prevent the browser from showing the usual validation error tooltips
event.preventDefault();
};
const onInput = event => {
const target = event.target;
if (formControls.has(target)) {
// Update error attribute depending on validation message
updateValidationMessage(target);
}
};
// --- main ------------------------
// Register event handlers
container.addEventListener('sl-input', onInput);
container.addEventListener('sl-invalid', onInvalid, true);
// Register mutation observer to detect dynamically added
// or removed form controls
const observer = new MutationObserver(() => {
// Update and remember current form controls
const newFormControls = updateAllValidationMessages();
// Cleanup previously removed form controls
for (const formControl of formControls) {
if (!newFormControls.has(formControl)) {
formControl.removeAttribute(errorAttribute);
}
}
formControls = newFormControls;
});
// Observe the whole DOM subtree of the container
observer.observe(container, {
childList: true,
subtree: true
});
formControls = updateAllValidationMessages();
// provide cancellation functionality
let cancelled = false;
const cancel = () => {
if (cancelled) {
return;
}
container.removeEventListener('sl-input', onInput);
container.removeEventListener('sl-invalid', onInvalid, true);
observer.disconnect();
for (const formControl of formControls) {
formControl.removeAttribute(errorAttribute);
}
formControls = null;
cancelled = true;
};
return cancel;
}
</script>
```
## Inline Form Validation (old version - to be deleted after testing) // TODO!!!!
```html preview
<sl-animation class="animation-inline-validation2" name="shakeX" duration="1000" iterations="1" easing="easeInOut">
<form class="inline-validation2">
<sl-radio-group name="salutation" label="Salutation" required>
<sl-radio value="mrs">Mrs.</sl-radio>
<sl-radio value="mr">Mr.</sl-radio>
<sl-radio value="other">Other</sl-radio>
</sl-radio-group>
<sl-input name="name" label="Name" required></sl-input>
<sl-input name="email" type="email" label="Email" required></sl-input>
<sl-select name="country" label="Country" help-text="Only USA and Canada" clearable required>
<sl-option value="US">USA</sl-option>
<sl-option value="CA">Canada</sl-option>
</sl-select>
<label>
Your favorite color *
<sl-color-picker required>Your favorite color</sl-color-picker>
</label>
<sl-switch name="customer" required>Please approve that this is really your favorite color</sl-switch>
<sl-textarea name="question" label="Your question" required></sl-textarea>
<sl-checkbox name="accept" required>Accept terms and conditions</sl-checkbox>
<sl-button type="submit" variant="primary">Submit</sl-button>
<sl-button type="reset" variant="default">Reset</sl-button>
</form>
</sl-animation>
<script type="module">
const form = document.querySelector('form.inline-validation2');
const animation = document.querySelector('sl-animation.animation-inline-validation2');
updateAllValidationMessages(form);
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
});
form.addEventListener(
'sl-invalid',
event => {
updateValidationMessage(event.target);
event.preventDefault();
animation.play = true;
},
true
);
form.addEventListener('sl-input', event => {
updateValidationMessage(event.target);
});
function isFormControl(elem) {
return (
elem.hasAttribute('data-valid') ||
(elem.hasAttribute('data-invalid') && typeof elem.validationMessage === 'string')
);
}
function updateValidationMessage(formControl) {
if (isFormControl(formControl)) {
formControl.setAttribute('data-error', formControl.validationMessage);
}
}
function updateAllValidationMessages(container) {
for (const elem of container.querySelectorAll('*')) {
if (isFormControl(elem)) {
updateValidationMessage(elem);
}
}
}
</script>
<style>
.inline-validation2 :is([data-valid], [data-invalid]):not(sl-button) {
display: block;
margin-bottom: var(--sl-spacing-small);
}
.inline-validation2 sl-radio-group sl-radio {
display: inline-block;
margin-right: 1rem;
}
/* user invalid styles */
.inline-validation2 sl-input[data-user-invalid]::part(base),
.inline-validation2 sl-select[data-user-invalid]::part(combobox) {
border-color: var(--sl-color-danger-600);
}
.inline-validation2 sl-input:focus-within[data-user-invalid]::part(base),
.inline-validation2 sl-textarea:focus-within[data-user-invalid]::part(base),
.inline-validation2 sl-select:focus-within[data-user-invalid]::part(combobox) {
border-color: var(--sl-color-danger-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
}
/* User valid styles */
.inline-validation2 sl-input[data-user-valid]::part(base),
.inline-validation2 sl-textarea[data-user-valid]::part(base),
.inline-validation2 sl-select[data-user-valid]::part(combobox) {
border-color: var(--sl-color-success-600);
}
.inline-validation2 sl-input:focus-within[data-user-valid]::part(base),
.inline-validation2 sl-textarea:focus-within[data-user-valid]::part(base),
.inline-validation2 sl-select:focus-within[data-user-valid]::part(combobox) {
border-color: var(--sl-color-success-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
}
.inline-validation2 :is([data-valid], [data-invalid]):not(sl-button)::after {
display: block;
font-size: var(--sl-font-size-small);
color: var(--sl-color-danger-700);
content: '\00a0';
}
.inline-validation2 [data-user-invalid]:not(sl-button)::after {
content: attr(data-error);
}
</style>
```
## Getting Associated Form Controls
At this time, using [`HTMLFormElement.elements`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements) will not return Shoelace form controls because the browser is unaware of their status as custom element form controls. Fortunately, Shoelace provides an `elements()` function that does something very similar. However, instead of returning an [`HTMLFormControlsCollection`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormControlsCollection), it returns an array of HTML and Shoelace form controls in the order they appear in the DOM.

Wyświetl plik

@ -1,4 +1,5 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import sinon from 'sinon';
import type SlButton from './button';
@ -234,4 +235,31 @@ describe('<sl-button>', () => {
expect(clickHandler).to.have.been.calledOnce;
});
});
runFormControlBaseTests({
tagName: 'sl-button',
variantName: 'type="button"',
init: (control: SlButton) => {
control.type = 'button';
}
});
runFormControlBaseTests({
tagName: 'sl-button',
variantName: 'type="submit"',
init: (control: SlButton) => {
control.type = 'submit';
}
});
runFormControlBaseTests({
tagName: 'sl-button',
variantName: 'href="xyz"',
init: (control: SlButton) => {
control.href = 'some-url';
}
});
});

Wyświetl plik

@ -2,7 +2,7 @@ import '../icon/icon';
import '../spinner/spinner';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { FormControlController } from '../../internal/form';
import { FormControlController, validValidityState } from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
import { html, literal } from 'lit/static-html.js';
import { ifDefined } from 'lit/directives/if-defined.js';
@ -140,6 +140,24 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
/** Used to override the form owner's `target` attribute. */
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
/** Gets the validity state object */
get validity() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).validity;
}
return validValidityState;
}
/** Gets the validation message */
get validationMessage() {
if (this.isButton()) {
return (this.button as HTMLButtonElement).validationMessage;
}
return '';
}
connectedCallback() {
super.connectedCallback();
this.handleHostClick = this.handleHostClick.bind(this);
@ -185,6 +203,11 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitSlInvalidEvent(event);
}
private isButton() {
return this.href ? false : true;
}
@ -290,6 +313,7 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
tabindex=${this.disabled ? '-1' : '0'}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@invalid=${this.isButton() ? this.handleInvalid : null}
@click=${this.handleClick}
>
<slot name="prefix" part="prefix" class="button__prefix"></slot>

Wyświetl plik

@ -1,5 +1,6 @@
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlCheckbox from './checkbox';
@ -308,5 +309,7 @@ describe('<sl-checkbox>', () => {
expect(indeterminateIcon).to.be.null;
});
runFormControlBaseTests('sl-checkbox');
});
});

Wyświetl plik

@ -26,6 +26,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
* @event sl-change - Emitted when the checked state changes.
* @event sl-focus - Emitted when the checkbox gains focus.
* @event sl-input - Emitted when the checkbox receives input.
* @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`.
*
* @csspart base - The component's base wrapper.
* @csspart control - The square container that wraps the checkbox's checked state.
@ -85,6 +86,16 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
/** Makes the checkbox a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
@ -104,6 +115,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitSlInvalidEvent(event);
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
@ -137,12 +153,12 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
this.input.blur();
}
/** Checks for validity but does not show a validation message. Returns true when valid and false when invalid. */
/** Checks for validity but does not show a validation message. Returns true when valid and false when invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows a validation message if the control is invalid. */
/** Checks for validity and shows a validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
reportValidity() {
return this.input.reportValidity();
}
@ -189,6 +205,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
aria-checked=${this.checked ? 'true' : 'false'}
@click=${this.handleClick}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
/>

Wyświetl plik

@ -1,5 +1,6 @@
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@ -545,4 +546,6 @@ describe('<sl-color-picker>', () => {
// expect(el.hasAttribute('data-user-valid')).to.be.false;
});
});
runFormControlBaseTests('sl-color-picker');
});

Wyświetl plik

@ -49,10 +49,11 @@ declare const EyeDropper: EyeDropperConstructor;
*
* @slot label - The color picker's form label. Alternatively, you can use the `label` attribute.
*
* @event sl-blur Emitted when the color picker loses focus.
* @event sl-change Emitted when the color picker's value changes.
* @event sl-focus Emitted when the color picker receives focus.
* @event sl-input Emitted when the color picker receives input.
* @event sl-blur - Emitted when the color picker loses focus.
* @event sl-change - Emitted when the color picker's value changes.
* @event sl-focus - Emitted when the color picker receives focus.
* @event sl-input - Emitted when the color picker receives input.
* @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`.
*
* @csspart base - The component's base wrapper.
* @csspart trigger - The color picker's dropdown trigger.
@ -174,6 +175,19 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
*/
@property({ reflect: true }) form = '';
/** Makes the color picker a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.handleFocusIn = this.handleFocusIn.bind(this);
@ -188,6 +202,12 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
this.removeEventListener('focusout', this.handleFocusOut);
}
firstUpdated() {
this.input.updateComplete.then(() => {
this.formControlController.updateValidity();
});
}
private handleCopy() {
this.input.select();
document.execCommand('copy');
@ -444,6 +464,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
private handleInputInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitSlInvalidEvent(event);
}
private handleTouchMove(event: TouchEvent) {
event.preventDefault();
}
@ -732,18 +757,24 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
/** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
reportValidity() {
if (!this.inline && !this.checkValidity()) {
if (!this.inline && !this.validity.valid) {
// If the input is inline and invalid, show the dropdown so the browser can focus on it
this.dropdown.show();
this.addEventListener('sl-after-show', () => this.input.reportValidity(), { once: true });
return this.checkValidity();
if (!this.disabled) {
// By standards we have to emit a `sl-invalid` event here synchronously.
this.formControlController.emitSlInvalidEvent();
}
return false;
}
return this.input.reportValidity();
@ -893,11 +924,13 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
autocapitalize="off"
spellcheck="false"
value=${this.isEmpty ? '' : this.inputValue}
?required=${this.required}
?disabled=${this.disabled}
aria-label=${this.localize.term('currentValue')}
@keydown=${this.handleInputKeyDown}
@sl-change=${this.handleInputChange}
@sl-input=${this.handleInputInput}
@sl-invalid=${this.handleInputInvalid}
@sl-blur=${this.stopNestedEventPropagation}
@sl-focus=${this.stopNestedEventPropagation}
></sl-input>

Wyświetl plik

@ -1,8 +1,9 @@
// eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { getFormControls } from '../../../dist/utilities/form.js';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form'; // must come from the same module
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands'; // must come from the same module
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
import type SlInput from './input';
@ -496,4 +497,6 @@ describe('<sl-input>', () => {
expect(formControls.map((fc: HTMLInputElement) => fc.value).join('')).to.equal('12345678910'); // eslint-disable-line
});
});
runFormControlBaseTests('sl-input');
});

Wyświetl plik

@ -47,6 +47,7 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
* @event sl-clear - Emitted when the clear button is activated.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-input - Emitted when the control receives input.
* @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@ -227,6 +228,16 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
this.value = input.value;
}
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
@ -262,8 +273,9 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
this.emit('sl-input');
}
private handleInvalid() {
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitSlInvalidEvent(event);
}
private handleKeyDown(event: KeyboardEvent) {
@ -372,12 +384,12 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
/** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
reportValidity() {
return this.input.reportValidity();
}

Wyświetl plik

@ -1,5 +1,6 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlRadio from '../radio/radio';
@ -315,4 +316,6 @@ describe('when the value changes', () => {
radioGroup.value = '2';
await radioGroup.updateComplete;
});
runFormControlBaseTests('sl-radio-group');
});

Wyświetl plik

@ -1,11 +1,19 @@
import '../button-group/button-group';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { FormControlController } from '../../internal/form';
import {
customErrorValidityState,
FormControlController,
validValidityState,
valueMissingValidityState
} from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
import { watch } from '../../internal/watch';
import ShoelaceElement from '../../internal/shoelace-element';
import styles from './radio-group.styles';
import type { CSSResultGroup } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
@ -26,6 +34,7 @@ import type SlRadioButton from '../radio-button/radio-button';
*
* @event sl-change - Emitted when the radio group's selected value changes.
* @event sl-input - Emitted when the radio group receives user input.
* @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@ -75,6 +84,34 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
/** Ensures a child radio is checked before allowing the containing form to submit. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (hasCustomValidityMessage) {
return customErrorValidityState;
} else if (isRequiredAndEmpty) {
return valueMissingValidityState;
}
return validValidityState;
}
/** Gets the validation message */
get validationMessage() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (hasCustomValidityMessage) {
return this.customValidityMessage;
} else if (isRequiredAndEmpty) {
return this.validationInput.validationMessage;
}
return '';
}
connectedCallback() {
super.connectedCallback();
this.defaultValue = this.value;
@ -187,10 +224,15 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitSlInvalidEvent(event);
}
private updateCheckedRadio() {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
this.formControlController.setValidity(this.checkValidity());
this.formControlController.setValidity(this.validity.valid);
}
@watch('value')
@ -200,12 +242,13 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result. */
checkValidity() {
const isRequiredAndEmpty = this.required && !this.value;
const hasCustomValidityMessage = this.customValidityMessage !== '';
if (isRequiredAndEmpty || hasCustomValidityMessage) {
this.formControlController.emitSlInvalidEvent();
return false;
}
@ -220,9 +263,9 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
this.formControlController.updateValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
/** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result. */
reportValidity(): boolean {
const isValid = this.checkValidity();
const isValid = this.validity.valid;
this.errorMessage = this.customValidityMessage || isValid ? '' : this.validationInput.validationMessage;
this.formControlController.setValidity(isValid);
@ -289,6 +332,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
?required=${this.required}
tabindex="-1"
hidden
@invalid=${this.handleInvalid}
/>
</label>
</div>

Wyświetl plik

@ -1,5 +1,6 @@
import { clickOnElement } from '../../internal/test';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@ -229,4 +230,6 @@ describe('<sl-range>', () => {
expect(input.value).to.equal(0);
});
});
runFormControlBaseTests('sl-range');
});

Wyświetl plik

@ -101,6 +101,16 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = 0;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.syncRange());
@ -207,6 +217,11 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
}
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitSlInvalidEvent(event);
}
/** Sets focus on the range. */
focus(options?: FocusOptions) {
this.input.focus(options);
@ -306,8 +321,9 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
.value=${live(this.value.toString())}
aria-describedby="help-text"
@change=${this.handleChange}
@input=${this.handleInput}
@focus=${this.handleFocus}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@blur=${this.handleBlur}
/>
${this.tooltip !== 'none' && !this.disabled

Wyświetl plik

@ -1,5 +1,6 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@ -548,4 +549,6 @@ describe('<sl-select>', () => {
expect(tag.hasAttribute('pill')).to.be.true;
});
runFormControlBaseTests('sl-select');
});

Wyświetl plik

@ -46,6 +46,7 @@ import type SlPopup from '../popup/popup';
* @event sl-after-show - Emitted after the select's menu opens and all animations are complete.
* @event sl-hide - Emitted when the select's menu closes.
* @event sl-after-hide - Emitted after the select's menu closes and all animations are complete.
* @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@ -162,6 +163,16 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
/** The select's required attribute. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.valueInput.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.valueInput.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.handleDocumentFocusIn = this.handleDocumentFocusIn.bind(this);
@ -520,6 +531,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
});
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitSlInvalidEvent(event);
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Close the listbox when the control is disabled
@ -603,12 +619,12 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
return waitForEvent(this, 'sl-after-hide');
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
checkValidity() {
return this.valueInput.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
/** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
reportValidity() {
return this.valueInput.reportValidity();
}
@ -752,6 +768,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
tabindex="-1"
aria-hidden="true"
@focus=${() => this.focus()}
@invalid=${this.handleInvalid}
/>
${hasClearIcon

Wyświetl plik

@ -1,4 +1,5 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlSwitch from './switch';
@ -260,4 +261,6 @@ describe('<sl-switch>', () => {
expect(switchEl.checked).to.false;
});
});
runFormControlBaseTests('sl-switch');
});

Wyświetl plik

@ -23,6 +23,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
* @event sl-change - Emitted when the control's checked state changes.
* @event sl-input - Emitted when the control receives input.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`.
*
* @csspart base - The component's base wrapper.
* @csspart control - The control that houses the switch's thumb.
@ -76,6 +77,16 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
/** Makes the switch a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
firstUpdated() {
this.formControlController.updateValidity();
}
@ -89,6 +100,11 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitSlInvalidEvent(event);
}
private handleClick() {
this.checked = !this.checked;
this.emit('sl-change');
@ -142,12 +158,12 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
this.input.blur();
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
/** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
reportValidity() {
return this.input.reportValidity();
}
@ -185,6 +201,7 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
aria-checked=${this.checked ? 'true' : 'false'}
@click=${this.handleClick}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@keydown=${this.handleKeyDown}

Wyświetl plik

@ -1,4 +1,5 @@
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form';
import sinon from 'sinon';
@ -292,4 +293,6 @@ describe('<sl-textarea>', () => {
expect(textarea.spellcheck).to.be.false;
});
});
runFormControlBaseTests('sl-textarea');
});

Wyświetl plik

@ -25,6 +25,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-input - Emitted when the control receives input.
* @event sl-invalid - Emitted when `.checkValidity()` or `.reportValidity()` has been called and the returned value is `false`.
*
* @csspart form-control - The form control that wraps the label, input, and help text.
* @csspart form-control-label - The label's wrapper.
@ -135,6 +136,16 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
/** The default value of the form control. Primarily used for resetting the form control. */
@defaultValue() defaultValue = '';
/** Gets the validity state object */
get validity() {
return this.input.validity;
}
/** Gets the validation message */
get validationMessage() {
return this.input.validationMessage;
}
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.setTextareaHeight());
@ -175,6 +186,11 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
this.emit('sl-input');
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
this.formControlController.emitSlInvalidEvent(event);
}
private setTextareaHeight() {
if (this.resize === 'auto') {
this.input.style.height = 'auto';
@ -260,12 +276,12 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
}
}
/** Checks for validity but does not show the browser's validation message. */
/** Checks for validity but does not show the browser's validation message. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
/** Checks for validity and shows the browser's validation message if the control is invalid. Will emit an `sl-invalid` event in case of negative result (if not disabled). */
reportValidity() {
return this.input.reportValidity();
}
@ -344,6 +360,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
aria-describedby="help-text"
@change=${this.handleChange}
@input=${this.handleInput}
@invalid=${this.handleInvalid}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
></textarea>

Wyświetl plik

@ -127,7 +127,7 @@ export class FormControlController implements ReactiveController {
}
if (this.host.hasUpdated) {
this.setValidity(this.host.checkValidity());
this.setValidity(this.host.validity.valid);
}
}
@ -341,11 +341,68 @@ export class FormControlController implements ReactiveController {
}
/**
* Updates the form control's validity based on the current value of `host.checkValidity()`. Call this when anything
* Updates the form control's validity based on the current value of `host.validity.valid`. Call this when anything
* that affects constraint validation changes so the component receives the correct validity states.
*/
updateValidity() {
const host = this.host;
this.setValidity(host.checkValidity());
this.setValidity(host.validity.valid);
}
/**
* Dispatches a non-bubbling, cancelable custom event of type `sl-invalid`.
* If the `sl-invalid` event will be cancelled then the original `invalid`
* event (which may have been passed as argument) will also be cancelled.
* If no original `invalid` event has been passed then the `sl-invalid`
* event will be cancelled before being dispatched.
*/
emitSlInvalidEvent(originalInvalidEvent?: Event) {
const slInvalidEvent = new CustomEvent<void>('sl-invalid', {
bubbles: false,
composed: false,
cancelable: true
});
if (!originalInvalidEvent) {
slInvalidEvent.preventDefault();
}
if (!this.host.dispatchEvent(slInvalidEvent)) {
originalInvalidEvent?.preventDefault();
}
}
}
/*
* Predefined common validity states.
* All of them are read-only.
*/
// A validity state object that represents `valid`
export const validValidityState: ValidityState = Object.freeze({
badInput: false,
customError: false,
patternMismatch: false,
rangeOverflow: false,
rangeUnderflow: false,
stepMismatch: false,
tooLong: false,
tooShort: false,
typeMismatch: false,
valid: true,
valueMissing: false
});
// A validity state object that represents `value missing`
export const valueMissingValidityState: ValidityState = Object.freeze({
...validValidityState,
valid: false,
valueMissing: true
});
// A validity state object that represents a custom error
export const customErrorValidityState: ValidityState = Object.freeze({
...validValidityState,
valid: false,
customError: true
});

Wyświetl plik

@ -40,6 +40,10 @@ export interface ShoelaceFormControl extends ShoelaceElement {
minlength?: number;
maxlength?: number;
// Validation properties
readonly validity: ValidityState;
readonly validationMessage: string;
// Validation methods
checkValidity: () => boolean;
reportValidity: () => boolean;

Wyświetl plik

@ -0,0 +1,323 @@
import { expect, fixture } from '@open-wc/testing';
import type { ShoelaceFormControl } from '../shoelace-element';
// === exports =======================================================
export { runFormControlBaseTests };
// === types =========================================================
type CreateControlFn = () => Promise<ShoelaceFormControl>;
// === all form control tests ========================================
// Runs a set of generic tests for Shoelace form controls
function runFormControlBaseTests<T extends ShoelaceFormControl = ShoelaceFormControl>(
tagNameOrConfig:
| string
| {
tagName: string;
init?: (control: T) => void;
variantName: string;
}
) {
const isStringArg = typeof tagNameOrConfig === 'string';
const tagName = isStringArg ? tagNameOrConfig : tagNameOrConfig.tagName;
// component initialization function or null
const init =
isStringArg || !tagNameOrConfig.init //
? null
: tagNameOrConfig.init || null;
// either `<tagName>` or `<tagName> (<variantName>)
const displayName = isStringArg //
? tagName
: `${tagName} (${tagNameOrConfig.variantName})`;
// creates a testable form control instance
const createControl = async () => {
const control = await createFormControl<T>(tagName);
init?.(control);
return control;
};
runAllValidityTests(tagName, displayName, createControl);
}
// === all validity tests ============================================
// Checks the correct behavior of:
// - `.validity`
// - `.validationMessage`,
// - `.checkValidity()`
// - `.reportValidity()`
// - `.setCustomValidity(msg)`
//
// Applicable for all Shoelace form controls
function runAllValidityTests(
tagName: string, //
displayName: string,
createControl: () => Promise<ShoelaceFormControl>
) {
// will be used later to retrieve meta information about the control
describe(`Form validity base test for ${displayName}`, async () => {
it('should have a property `validity` of type `object`', async () => {
const control = await createControl();
expect(control).satisfy(() => control.validity !== null && typeof control.validity === 'object');
});
it('should have a property `validationMessage` of type `string`', async () => {
const control = await createControl();
expect(control).satisfy(() => typeof control.validationMessage === 'string');
});
it('should implement method `checkValidity`', async () => {
const control = await createControl();
expect(control).satisfies(() => typeof control.checkValidity === 'function');
});
it('should implement method `setCustomValidity`', async () => {
const control = await createControl();
expect(control).satisfies(() => typeof control.setCustomValidity === 'function');
});
it('should implement method `reportValidity`', async () => {
const control = await createControl();
expect(control).satisfies(() => typeof control.reportValidity === 'function');
});
it('should be valid initially', async () => {
const control = await createControl();
expect(control.validity.valid).to.equal(true);
});
it('should make sure that calling `.checkValidity()` will return `true` when valid', async () => {
const control = await createControl();
expect(control.checkValidity()).to.equal(true);
});
it('should make sure that calling `.reportValidity()` will return `true` when valid', async () => {
const control = await createControl();
expect(control.reportValidity()).to.equal(true);
});
it('should not emit an `sl-invalid` event when `.checkValidity()` is called while valid', async () => {
const control = await createControl();
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
expect(emittedEvents.length).to.equal(0);
});
it('should not emit an `sl-invalid` event when `.reportValidity()` is called while valid', async () => {
const control = await createControl();
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(0);
});
// TODO: As soon as `SlRadioGroup` has a property `disabled` this
// condition can be removed
if (tagName !== 'sl-radio-group') {
it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case while disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = true;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
expect(emittedEvents.length).to.equal(0);
});
it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case while disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = true;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(0);
});
}
// Run special tests depending on component type
const mode = getMode(await createControl());
if (mode === 'slButtonOfTypeButton') {
runSpecialTests_slButtonOfTypeButton(createControl);
} else if (mode === 'slButtonWithHRef') {
runSpecialTests_slButtonWithHref(createControl);
} else {
runSpecialTests_standard(createControl);
}
});
}
// === special tests for <sl-button type=button"...> =================
function runSpecialTests_slButtonOfTypeButton(createControl: CreateControlFn) {
it('should make sure that `.validity.valid` is `false` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.validity.valid).to.equal(false);
});
it('should make sure that calling `.checkValidity()` will still return `true` when custom error has been set', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.checkValidity()).to.equal(true);
});
it('should make sure that calling `.reportValidity()` will still return `true` when custom error has been set', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.reportValidity()).to.equal(true);
});
it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case, and not disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = false;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
expect(emittedEvents.length).to.equal(0);
});
it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case, and not disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = false;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(0);
});
}
// === special tests for <sl-button href="xyz"...> ===================
function runSpecialTests_slButtonWithHref(createControl: CreateControlFn) {
it('should make sure that calling `.checkValidity()` will return `true` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.checkValidity()).to.equal(true);
});
it('should make sure that calling `.reportValidity()` will return `true` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.reportValidity()).to.equal(true);
});
it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
expect(emittedEvents.length).to.equal(0);
});
it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(0);
});
}
// === special tests for all components with usual behavior =========
function runSpecialTests_standard(createControl: CreateControlFn) {
it('should make sure that `.validity.valid` is `false` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.validity.valid).to.equal(false);
});
it('should make sure that calling `.checkValidity()` will return `false` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.checkValidity()).to.equal(false);
});
it('should make sure that calling `.reportValidity()` will return `false` in custom error case', async () => {
const control = await createControl();
control.setCustomValidity('error');
expect(control.reportValidity()).to.equal(false);
});
it('should emit an `sl-invalid` event when `.checkValidity()` is called in custom error case and not disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = false;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
expect(emittedEvents.length).to.equal(1);
});
it('should emit an `sl-invalid` event when `.reportValidity()` is called in custom error case and not disabled', async () => {
const control = await createControl();
control.setCustomValidity('error');
control.disabled = false;
await control.updateComplete;
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
expect(emittedEvents.length).to.equal(1);
});
}
// === Local helper functions ========================================
// Creates a testable Shoelace form control instance
async function createFormControl<T extends ShoelaceFormControl = ShoelaceFormControl>(tagName: string): Promise<T> {
return await fixture<T>(`<${tagName}></${tagName}>`);
}
// Runs an action while listening for emitted events of a given type.
// Returns an array of all events of the given type that have been
// been emitted while the action was running.
function checkEventEmissions(control: ShoelaceFormControl, eventType: string, action: () => void): Event[] {
const emittedEvents: Event[] = [];
const eventHandler = (event: Event) => {
emittedEvents.push(event);
};
try {
control.addEventListener(eventType, eventHandler);
action();
} finally {
control.removeEventListener(eventType, eventHandler);
}
return emittedEvents;
}
// Component `sl-button` behaves quite different to the other
// components. To keep things simple we use simple conditions
// here. `sl-button` might stay the only component in Shoelace
// core behaves that way, so we just hard code it here.
function getMode(control: ShoelaceFormControl) {
// <sl-button type="button" ...> shall behave the same way as
// <button type="button" ...>
// Please find here a little demo:
// https://jsbin.com/qiwaxivuta/edit?html,console,output
if (
control.localName === 'sl-button' && //
'href' in control &&
'type' in control &&
control.type === 'button' &&
!control.href
) {
return 'slButtonOfTypeButton';
}
// <sl-button href="[xyz]" ...>
if (
control.localName === 'sl-button' && //
'href' in control &&
!!control.href
) {
return 'slButtonWithHRef';
}
// all other components
return 'standard';
}