feat(form-core): form-core types

Co-authored-by: Thijs Louisse <Thijs.Louisse@ing.com>
This commit is contained in:
Joren Broekema 2020-09-01 12:43:45 +02:00 committed by Thijs Louisse
parent 3e00819bdf
commit 874ff48339
105 changed files with 4691 additions and 4030 deletions

View file

@ -0,0 +1,18 @@
---
'@lion/form-core': minor
'@lion/core': minor
'@lion/fieldset': patch
'@lion/select-rich': patch
---
Form-core typings
#### Features
Provided typings for the form-core package and core package.
This also means that mixins that previously had implicit dependencies, now have explicit ones.
#### Patches
- lion-select-rich: invoker selectedElement now also clones text nodes (fix)
- fieldset: runs a FormGroup suite

View file

@ -203,13 +203,9 @@ Excellent! Lea can now use the tabs component like so:
```html
<lea-tabs>
<lea-tab slot="tab">Info</lea-tab>
<lea-tab-panel slot="panel">
Info page with lots of information about us.
</lea-tab-panel>
<lea-tab-panel slot="panel"> Info page with lots of information about us. </lea-tab-panel>
<lea-tab slot="tab">Work</lea-tab>
<lea-tab-panel slot="panel">
Work page that showcases our work.
</lea-tab-panel>
<lea-tab-panel slot="panel"> Work page that showcases our work. </lea-tab-panel>
</lea-tabs>
```

View file

@ -23,13 +23,9 @@ export default {
export const main = () => html`
<lea-tabs>
<lea-tab slot="tab">Info</lea-tab>
<lea-tab-panel slot="panel">
Info page with lots of information about us.
</lea-tab-panel>
<lea-tab-panel slot="panel"> Info page with lots of information about us. </lea-tab-panel>
<lea-tab slot="tab">Work</lea-tab>
<lea-tab-panel slot="panel">
Work page that showcases our work.
</lea-tab-panel>
<lea-tab-panel slot="panel"> Work page that showcases our work. </lea-tab-panel>
</lea-tabs>
`;
```
@ -51,13 +47,9 @@ import '@lion/tabs/lea-tabs.js';
```html
<lea-tabs>
<lea-tab slot="tab">Info</lea-tab>
<lea-tab-panel slot="panel">
Info page with lots of information about us.
</lea-tab-panel>
<lea-tab-panel slot="panel"> Info page with lots of information about us. </lea-tab-panel>
<lea-tab slot="tab">Work</lea-tab>
<lea-tab-panel slot="panel">
Work page that showcases our work.
</lea-tab-panel>
<lea-tab-panel slot="panel"> Work page that showcases our work. </lea-tab-panel>
</lea-tabs>
```
@ -71,13 +63,9 @@ You can set the `selectedIndex` to select a certain tab.
export const selectedIndex = () => html`
<lea-tabs .selectedIndex=${1}>
<lea-tab slot="tab">Info</lea-tab>
<lea-tab-panel slot="panel">
Info page with lots of information about us.
</lea-tab-panel>
<lea-tab-panel slot="panel"> Info page with lots of information about us. </lea-tab-panel>
<lea-tab slot="tab">Work</lea-tab>
<lea-tab-panel slot="panel">
Work page that showcases our work.
</lea-tab-panel>
<lea-tab-panel slot="panel"> Work page that showcases our work. </lea-tab-panel>
</lea-tabs>
`;
```
@ -93,12 +81,8 @@ export const slotsOrder = () => html`
<lea-tabs>
<lea-tab slot="tab">Info</lea-tab>
<lea-tab slot="tab">Work</lea-tab>
<lea-tab-panel slot="panel">
Info page with lots of information about us.
</lea-tab-panel>
<lea-tab-panel slot="panel">
Work page that showcases our work.
</lea-tab-panel>
<lea-tab-panel slot="panel"> Info page with lots of information about us. </lea-tab-panel>
<lea-tab-panel slot="panel"> Work page that showcases our work. </lea-tab-panel>
</lea-tabs>
`;
```
@ -122,9 +106,7 @@ export const distributeNewElements = () => {
render() {
return html`
<h3>Append</h3>
<button @click="${this.__handleAppendClick}">
Append
</button>
<button @click="${this.__handleAppendClick}">Append</button>
<lea-tabs id="appendTabs">
<lea-tab slot="tab">tab 1</lea-tab>
<lea-tab-panel slot="panel">panel 1</lea-tab-panel>
@ -133,9 +115,7 @@ export const distributeNewElements = () => {
</lea-tabs>
<hr />
<h3>Push</h3>
<button @click="${this.__handlePushClick}">
Push
</button>
<button @click="${this.__handlePushClick}">Push</button>
<lea-tabs id="pushTabs">
<lea-tab slot="tab">tab 1</lea-tab>
<lea-tab-panel slot="panel">panel 1</lea-tab-panel>

View file

@ -265,13 +265,9 @@ export const specialFeature = () =>
html`
<lea-tabs>
<lea-tab slot="tab">Info</lea-tab>
<lea-tab-panel slot="panel">
Info page with lots of information about us.
</lea-tab-panel>
<lea-tab-panel slot="panel"> Info page with lots of information about us. </lea-tab-panel>
<lea-tab slot="tab">Work</lea-tab>
<lea-tab-panel slot="panel">
Work page that showcases our work.
</lea-tab-panel>
<lea-tab-panel slot="panel"> Work page that showcases our work. </lea-tab-panel>
</lea-tabs>
`;
```

View file

@ -21,9 +21,7 @@ export const main = () => html`
<h3 slot="invoker">
<button>Lorem</button>
</h3>
<p slot="content">
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
</p>
<p slot="content">Lorem ipsum dolor sit, amet consectetur adipisicing elit.</p>
<h3 slot="invoker">
<button>Laboriosam</button>
</h3>
@ -56,9 +54,7 @@ import '@lion/accordion/lion-accordion.js';
<h3 slot="invoker">
<button>Lorem</button>
</h3>
<p slot="content">
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
</p>
<p slot="content">Lorem ipsum dolor sit, amet consectetur adipisicing elit.</p>
<h3 slot="invoker">
<button>Laboriosam</button>
</h3>
@ -83,9 +79,7 @@ export const expanded = () => html`
<h3 slot="invoker">
<button>Lorem</button>
</h3>
<p slot="content">
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
</p>
<p slot="content">Lorem ipsum dolor sit, amet consectetur adipisicing elit.</p>
<h3 slot="invoker">
<button>Laboriosam</button>
</h3>
@ -109,9 +103,7 @@ export const slotsOrder = () => html`
<h3 slot="invoker">
<button>Lorem</button>
</h3>
<p slot="content">
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
</p>
<p slot="content">Lorem ipsum dolor sit, amet consectetur adipisicing elit.</p>
<h3 slot="invoker">
<button>Laboriosam</button>
</h3>
@ -152,9 +144,7 @@ export const distributeNewElement = () => {
</h4>
<p slot="content">content 2</p>
</lion-accordion>
<button @click="${this.__handleAppendClick}">
Append
</button>
<button @click="${this.__handleAppendClick}">Append</button>
<hr />
<h3>Push</h3>
<lion-accordion id="pushTabs">
@ -173,9 +163,7 @@ export const distributeNewElement = () => {
`,
)}
</lion-accordion>
<button @click="${this.__handlePushClick}">
Push
</button>
<button @click="${this.__handlePushClick}">Push</button>
`;
}
constructor() {

View file

@ -276,9 +276,7 @@ export class LionCalendar extends LocalizeMixin(LitElement) {
return html`
<div class="calendar__navigation__month">
${this.__renderPreviousButton('Month', previousMonth, previousYear)}
<h2 class="calendar__navigation-heading" id="month" aria-atomic="true">
${month}
</h2>
<h2 class="calendar__navigation-heading" id="month" aria-atomic="true">${month}</h2>
${this.__renderNextButton('Month', nextMonth, nextYear)}
</div>
`;
@ -291,9 +289,7 @@ export class LionCalendar extends LocalizeMixin(LitElement) {
return html`
<div class="calendar__navigation__year">
${this.__renderPreviousButton('FullYear', month, previousYear)}
<h2 class="calendar__navigation-heading" id="year" aria-atomic="true">
${year}
</h2>
<h2 class="calendar__navigation-heading" id="year" aria-atomic="true">${year}</h2>
${this.__renderNextButton('FullYear', month, nextYear)}
</div>
`;

View file

@ -67,9 +67,7 @@ export function dayTemplate(day, { weekdays, monthsLabels = defaultMonthLabels }
?current-month=${day.currentMonth}
?next-month=${day.nextMonth}
>
<span class="calendar__day-button__text">
${day.date.getDate()}
</span>
<span class="calendar__day-button__text"> ${day.date.getDate()} </span>
</button>
</td>
`;

View file

@ -52,9 +52,7 @@ import '@lion/collapsible/lion-collapsible.js';
```html
<lion-collapsible>
<button slot="invoker">Invoker Text</button>
<div slot="content">
Extra content
</div>
<div slot="content">Extra content</div>
</lion-collapsible>
```
@ -97,12 +95,8 @@ export const methods = () => html`
<button @click=${() => document.querySelector('#car-collapsible').toggle()}>
Toggle content
</button>
<button @click=${() => document.querySelector('#car-collapsible').show()}>
Show content
</button>
<button @click=${() => document.querySelector('#car-collapsible').hide()}>
Hide content
</button>
<button @click=${() => document.querySelector('#car-collapsible').show()}>Show content</button>
<button @click=${() => document.querySelector('#car-collapsible').hide()}>Hide content</button>
</section>
`;
```
@ -140,9 +134,7 @@ A custom template can be specified to the `invoker` slot. It can be any button o
```js preview-story
export const customInvokerTemplate = () => html`
<lion-collapsible>
<button class="demo-custom-collapsible-invoker" slot="invoker">
MORE ABOUT CARS
</button>
<button class="demo-custom-collapsible-invoker" slot="invoker">MORE ABOUT CARS</button>
<div slot="content">
Most definitions of cars say that they run primarily on roads, seat one to eight people, have
four tires, and mainly transport people rather than goods.
@ -170,9 +162,7 @@ export const customAnimation = () => html`
vehicle.
</div>
<custom-collapsible>
<button class="demo-custom-collapsible-invoker" slot="invoker">
MORE ABOUT MOTORCYCLES
</button>
<button class="demo-custom-collapsible-invoker" slot="invoker">MORE ABOUT MOTORCYCLES</button>
<div slot="content">
Motorcycle design varies greatly to suit a range of different purposes: long distance
travel, commuting, cruising, sport including racing, and off-road riding. Motorcycling is
@ -186,9 +176,7 @@ export const customAnimation = () => html`
A car (or automobile) is a wheeled motor vehicle used for transportation.
</div>
<custom-collapsible opened>
<button class="demo-custom-collapsible-invoker" slot="invoker">
MORE ABOUT CARS
</button>
<button class="demo-custom-collapsible-invoker" slot="invoker">MORE ABOUT CARS</button>
<div slot="content">
Most definitions of cars say that they run primarily on roads, seat one to eight people,
have four tires, and mainly transport people rather than goods.

View file

@ -40,7 +40,9 @@ Since Scoped Elements changes tagnames under the hood, a tagname querySelector s
like this:
```js
this.querySelector(this.constructor.getScopedTagName('lion-input', this.constructor.scopedElements));
this.querySelector(
this.constructor.getScopedTagName('lion-input', this.constructor.scopedElements),
);
```
## CSS selectors

View file

@ -30,7 +30,7 @@
"sideEffects": false,
"dependencies": {
"@open-wc/dedupe-mixin": "^1.2.18",
"@open-wc/scoped-elements": "^1.0.3",
"@open-wc/scoped-elements": "^1.2.2",
"lit-element": "~2.4.0",
"lit-html": "^1.3.0"
},

View file

@ -18,7 +18,7 @@ import { dedupeMixin } from '@open-wc/dedupe-mixin';
*/
const DelegateMixinImplementation = superclass =>
// eslint-disable-next-line
class DelegateMixin extends superclass {
class extends superclass {
constructor() {
super();

View file

@ -10,7 +10,7 @@ import { dedupeMixin } from '@open-wc/dedupe-mixin';
*/
const DisabledMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow
class DisabledMixinHost extends superclass {
class extends superclass {
static get properties() {
return {
disabled: {

View file

@ -11,7 +11,7 @@ import { DisabledMixin } from './DisabledMixin.js';
*/
const DisabledWithTabIndexMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow
class DisabledWithTabIndexMixinHost extends DisabledMixin(superclass) {
class extends DisabledMixin(superclass) {
static get properties() {
return {
// we use a property here as if we use the native tabIndex we can not set a default value

View file

@ -12,7 +12,7 @@ import { dedupeMixin } from '@open-wc/dedupe-mixin';
*/
const SlotMixinImplementation = superclass =>
// eslint-disable-next-line no-unused-vars, no-shadow
class SlotMixinHost extends superclass {
class extends superclass {
/**
* @return {SlotsMap}
*/

View file

@ -12,7 +12,7 @@ import { dedupeMixin } from '@open-wc/dedupe-mixin';
*/
const UpdateStylesMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow
class UpdateStylesMixinHost extends superclass {
class extends superclass {
/**
* @example
* <my-element>

View file

@ -9,7 +9,7 @@ export type Delegations = {
attributes: string[];
};
export declare class DelegateMixinHost {
export declare class DelegateHost {
delegations: Delegations;
protected _connectDelegateMixin(): void;
@ -50,6 +50,6 @@ export declare class DelegateMixinHost {
*/
declare function DelegateMixinImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<DelegateMixinHost>;
): T & Constructor<DelegateHost>;
export type DelegateMixin = typeof DelegateMixinImplementation;

View file

@ -1,13 +1,7 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { LitElement } from 'lit-element';
export declare class DisabledMixinHost {
static get properties(): {
disabled: {
type: BooleanConstructor;
reflect: boolean;
};
};
export declare class DisabledHost {
disabled: boolean;
/**
@ -26,6 +20,6 @@ export declare class DisabledMixinHost {
export declare function DisabledMixinImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<DisabledMixinHost>;
): T & Constructor<DisabledHost>;
export type DisabledMixin = typeof DisabledMixinImplementation;

View file

@ -1,14 +1,7 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { DisabledMixinHost } from './DisabledMixinTypes';
import { DisabledHost } from './DisabledMixinTypes';
import { LitElement } from 'lit-element';
export declare class DisabledWithTabIndexMixinHost {
static get properties(): {
tabIndex: {
type: NumberConstructor;
reflect: boolean;
attribute: string;
};
};
export declare class DisabledWithTabIndexHost {
tabIndex: number;
/**
* Makes request to make the element disabled and set the tabindex
@ -27,6 +20,6 @@ export declare class DisabledWithTabIndexMixinHost {
export declare function DisabledWithTabIndexMixinImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<DisabledWithTabIndexMixinHost> & Constructor<DisabledMixinHost>;
): T & Constructor<DisabledWithTabIndexHost> & Constructor<DisabledHost>;
export type DisabledWithTabIndexMixin = typeof DisabledWithTabIndexMixinImplementation;

View file

@ -6,7 +6,7 @@ export type SlotsMap = {
[key: string]: typeof slotFunction;
};
export declare class SlotMixinHost {
export declare class SlotHost {
/**
* Obtains all the slots to create
*/
@ -50,6 +50,6 @@ export declare class SlotMixinHost {
*/
export declare function SlotMixinImplementation<T extends Constructor<HTMLElement>>(
superclass: T,
): T & Constructor<SlotMixinHost>;
): T & Constructor<SlotHost>;
export type SlotMixin = typeof SlotMixinImplementation;

View file

@ -3,7 +3,7 @@ import { Constructor } from '@open-wc/dedupe-mixin';
export type StylesMap = {
[key: string]: string;
};
export declare class UpdateStylesMixinHost {
export declare class UpdateStylesHost {
/**
* @example
* <my-element>
@ -29,6 +29,6 @@ export declare class UpdateStylesMixinHost {
*/
declare function UpdateStylesMixinImplementation<T extends Constructor<HTMLElement>>(
superclass: T,
): T & Constructor<UpdateStylesMixinHost>;
): T & Constructor<UpdateStylesHost>;
export type UpdateStylesMixin = typeof UpdateStylesMixinImplementation;

View file

@ -27,9 +27,7 @@ describe('lion-dialog', () => {
it('should show content on invoker click', async () => {
const el = await fixture(html`
<lion-dialog>
<div slot="content" class="dialog">
Hey there
</div>
<div slot="content" class="dialog">Hey there</div>
<button slot="invoker">Popup button</button>
</lion-dialog>
`);
@ -45,9 +43,7 @@ describe('lion-dialog', () => {
<div slot="content">
open nested overlay:
<lion-dialog>
<div slot="content">
Nested content
</div>
<div slot="content">Nested content</div>
<button slot="invoker">nested invoker button</button>
</lion-dialog>
</div>

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,13 @@
import { dedupeMixin } from '@lion/core';
import { FormControlMixin } from './FormControlMixin.js';
/**
* @typedef {import('../types/FocusMixinTypes').FocusMixin} FocusMixin
* @type {FocusMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
const FocusMixinImplementation = superclass =>
// eslint-disable-next-line no-unused-vars, max-len, no-shadow
class FocusMixin extends superclass {
class FocusMixin extends FormControlMixin(superclass) {
static get properties() {
return {
focused: {
@ -21,16 +23,12 @@ const FocusMixinImplementation = superclass =>
}
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
this.__registerEventsForFocusMixin();
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
this.__teardownEventsForFocusMixin();
}
@ -101,10 +99,22 @@ const FocusMixinImplementation = superclass =>
}
__teardownEventsForFocusMixin() {
this._inputNode.removeEventListener('focus', this.__redispatchFocus);
this._inputNode.removeEventListener('blur', this.__redispatchBlur);
this._inputNode.removeEventListener('focusin', this.__redispatchFocusin);
this._inputNode.removeEventListener('focusout', this.__redispatchFocusout);
this._inputNode.removeEventListener(
'focus',
/** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocus),
);
this._inputNode.removeEventListener(
'blur',
/** @type {EventListenerOrEventListenerObject} */ (this.__redispatchBlur),
);
this._inputNode.removeEventListener(
'focusin',
/** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocusin),
);
this._inputNode.removeEventListener(
'focusout',
/** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocusout),
);
}
};

View file

@ -1,7 +1,8 @@
import { css, dedupeMixin, html, nothing, SlotMixin } from '@lion/core';
import { Unparseable } from './validate/Unparseable.js';
import { DisabledMixin } from '@lion/core/src/DisabledMixin.js';
import { FormRegisteringMixin } from './registration/FormRegisteringMixin.js';
import { getAriaElementsInRightDomOrder } from './utils/getAriaElementsInRightDomOrder.js';
import { Unparseable } from './validate/Unparseable.js';
/**
* Generates random unique identifier (for dom elements)
@ -17,16 +18,17 @@ function uuid(prefix) {
* This Mixin is a shared fundament for all form components, it's applied on:
* - LionField (which is extended to LionInput, LionTextarea, LionSelect etc. etc.)
* - LionFieldset (which is extended to LionRadioGroup, LionCheckboxGroup, LionForm)
* @typedef {import('lit-html').TemplateResult} TemplateResult
* @typedef {import('lit-element').CSSResult} CSSResult
* @typedef {import('lit-html').nothing} nothing
* @typedef {import('@lion/core').TemplateResult} TemplateResult
* @typedef {import('@lion/core').CSSResult} CSSResult
* @typedef {import('@lion/core').nothing} nothing
* @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap
* @typedef {import('../types/FormControlMixinTypes.js').FormControlMixin} FormControlMixin
* @type {FormControlMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
const FormControlMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormControlMixin extends FormRegisteringMixin(SlotMixin(superclass)) {
class FormControlMixin extends FormRegisteringMixin(DisabledMixin(SlotMixin(superclass))) {
static get properties() {
return {
/**
@ -48,6 +50,21 @@ const FormControlMixinImplementation = superclass =>
type: String,
attribute: 'help-text',
},
/**
* The model value is the result of the parser function(when available).
* It should be considered as the internal value used for validation and reasoning/logic.
* The model value is 'ready for consumption' by the outside world (think of a Date
* object or a float). The modelValue can(and is recommended to) be used as both input
* value and output value of the `LionField`.
*
* Examples:
* - For a date input: a String '20/01/1999' will be converted to new Date('1999/01/20')
* - For a number input: a formatted String '1.234,56' will be converted to a Number:
* 1234.56
*/
modelValue: { attribute: false },
/**
* Contains all elements that should end up in aria-labelledby of `._inputNode`
*/
@ -112,7 +129,8 @@ const FormControlMixinImplementation = superclass =>
* @return {string}
*/
get fieldName() {
return this.__fieldName || this.label || this.name;
// @ts-expect-error
return this.__fieldName || this.label || this.name; // FIXME: when LionField is typed we can inherit this prop
}
/**
@ -184,7 +202,9 @@ const FormControlMixinImplementation = superclass =>
}
get _feedbackNode() {
return this.__getDirectSlotChild('feedback');
return /** @type {import('./validate/LionValidationFeedback').LionValidationFeedback | undefined} */ (this.__getDirectSlotChild(
'feedback',
));
}
constructor() {
@ -197,7 +217,11 @@ const FormControlMixinImplementation = superclass =>
this._ariaDescribedNodes = [];
/** @type {'child' | 'choice-group' | 'fieldset'} */
this._repropagationRole = 'child';
this.addEventListener('model-value-changed', this.__repropagateChildrenValues);
this._isRepropagationEndpoint = false;
this.addEventListener(
'model-value-changed',
/** @type {EventListenerOrEventListenerObject} */ (this.__repropagateChildrenValues),
);
}
connectedCallback() {
@ -339,12 +363,8 @@ const FormControlMixinImplementation = superclass =>
*/
render() {
return html`
<div class="form-field__group-one">
${this._groupOneTemplate()}
</div>
<div class="form-field__group-two">
${this._groupTwoTemplate()}
</div>
<div class="form-field__group-one">${this._groupOneTemplate()}</div>
<div class="form-field__group-two">${this._groupTwoTemplate()}</div>
`;
}
@ -479,10 +499,15 @@ const FormControlMixinImplementation = superclass =>
/**
* @param {?} modelValue
* @return {boolean}
*
* FIXME: Move to FormatMixin? Since there we have access to modelValue prop
*/
// @ts-expect-error
_isEmpty(modelValue = this.modelValue) {
let value = modelValue;
// @ts-expect-error
if (this.modelValue instanceof Unparseable) {
// @ts-expect-error
value = this.modelValue.viewValue;
}
@ -629,7 +654,7 @@ const FormControlMixinImplementation = superclass =>
}
/**
* @return {HTMLElement[]}
* @return {Array.<HTMLElement|undefined>}
*/
// Returns dom references to all elements that should be referred to by field(s)
_getAriaDescriptionElements() {
@ -681,10 +706,12 @@ const FormControlMixinImplementation = superclass =>
/**
* @param {string} slotName
* @return {HTMLElement}
* @return {HTMLElement | undefined}
*/
__getDirectSlotChild(slotName) {
return [...this.children].find(el => el.slot === slotName);
return /** @type {HTMLElement[]} */ (Array.from(this.children)).find(
el => el.slot === slotName,
);
}
__dispatchInitialModelValueChangedEvent() {
@ -756,6 +783,7 @@ const FormControlMixinImplementation = superclass =>
// We only send the checked changed up (not the unchecked). In this way a choice group
// (radio-group, checkbox-group, select/listbox) acts as an 'endpoint' (a single Field)
// just like the native <select>
// @ts-expect-error multipleChoice is not directly available but only as side effect
if (this._repropagationRole === 'choice-group' && !this.multipleChoice && !target.checked) {
return;
}

View file

@ -1,7 +1,9 @@
/* eslint-disable class-methods-use-this */
import { dedupeMixin } from '@lion/core';
import { FormControlMixin } from './FormControlMixin.js';
import { Unparseable } from './validate/Unparseable.js';
import { ValidateMixin } from './validate/ValidateMixin.js';
/**
* @typedef {import('../types/FormatMixinTypes').FormatMixin} FormatMixin
@ -52,25 +54,12 @@ import { Unparseable } from './validate/Unparseable.js';
* Flow: serializedValue (deserializer) -> `.modelValue` (formatter) -> `.formattedValue` -> `._inputNode.value`
*
* @type {FormatMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
const FormatMixinImplementation = superclass =>
class FormatMixin extends superclass {
class FormatMixin extends ValidateMixin(FormControlMixin(superclass)) {
static get properties() {
return {
/**
* The model value is the result of the parser function(when available).
* It should be considered as the internal value used for validation and reasoning/logic.
* The model value is 'ready for consumption' by the outside world (think of a Date
* object or a float). The modelValue can(and is recommended to) be used as both input
* value and output value of the `LionField`.
*
* Examples:
* - For a date input: a String '20/01/1999' will be converted to new Date('1999/01/20')
* - For a number input: a formatted String '1.234,56' will be converted to a Number:
* 1234.56
*/
modelValue: { attribute: false },
/**
* The view value is the result of the formatter function (when available).
* The result will be stored in the native _inputNode (usually an input[type=text]).
@ -296,7 +285,7 @@ const FormatMixinImplementation = superclass =>
*/
_onModelValueChanged(...args) {
this._calculateValues({ source: 'model' });
// @ts-ignore only passing this so a subclasser can use it, but we do not use it ourselves
// @ts-expect-error only passing this so a subclasser can use it, but we do not use it ourselves
this._dispatchModelValueChangedEvent(...args);
}
@ -405,7 +394,8 @@ const FormatMixinImplementation = superclass =>
this._inputNode.removeEventListener('input', this._proxyInputEvent);
this._inputNode.removeEventListener(
this.formatOn,
this._reflectBackFormattedValueDebounced,
/** @type {EventListenerOrEventListenerObject} */ (this
._reflectBackFormattedValueDebounced),
);
}
}

View file

@ -13,11 +13,9 @@ import { FormControlMixin } from './FormControlMixin.js';
* - leaves a form field(blur) -> 'touched' will be set to true. 'prefilled' when a
* field is left non-empty
* - on keyup (actually, on the model-value-changed event) -> 'dirty' will be set to true
* @param {HTMLElement} superclass
*/
/**
*
* @type {InteractionStateMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
const InteractionStateMixinImplementation = superclass =>
class InteractionStateMixin extends FormControlMixin(superclass) {
@ -105,18 +103,14 @@ const InteractionStateMixinImplementation = superclass =>
* Register event handlers and validate prefilled inputs
*/
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
this.addEventListener(this._leaveEvent, this._iStateOnLeave);
this.addEventListener(this._valueChangedEvent, this._iStateOnValueChange);
this.initInteractionState();
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
this.removeEventListener(this._leaveEvent, this._iStateOnLeave);
this.removeEventListener(this._valueChangedEvent, this._iStateOnValueChange);
}
@ -169,6 +163,27 @@ const InteractionStateMixinImplementation = superclass =>
_onDirtyChanged() {
this.dispatchEvent(new CustomEvent('dirty-changed', { bubbles: true, composed: true }));
}
/**
* Show the validity feedback when one of the following conditions is met:
*
* - submitted
* If the form is submitted, always show the error message.
*
* - prefilled
* the user already filled in something, or the value is prefilled
* when the form is initially rendered.
*
* - touched && dirty
* When a user starts typing for the first time in a field with for instance `required`
* validation, error message should not be shown until a field becomes `touched`
* (a user leaves(blurs) a field).
* When a user enters a field without altering the value(making it `dirty`),
* an error message shouldn't be shown either.
*/
_showFeedbackConditionFor() {
return (this.touched && this.dirty) || this.prefilled || this.submitted;
}
};
export const InteractionStateMixin = dedupeMixin(InteractionStateMixinImplementation);

View file

@ -1,14 +1,10 @@
import { LitElement, SlotMixin } from '@lion/core';
import { DisabledMixin } from '@lion/core/src/DisabledMixin.js';
import { ValidateMixin } from './validate/ValidateMixin.js';
import { FocusMixin } from './FocusMixin.js';
import { FormatMixin } from './FormatMixin.js';
import { FormControlMixin } from './FormControlMixin.js';
import { InteractionStateMixin } from './InteractionStateMixin.js'; // applies FocusMixin
/* eslint-disable wc/guard-super-call */
// TODO: Add submitted prop to InteractionStateMixin.
/**
* `LionField`: wraps <input>, <textarea>, <select> and other interactable elements.
* Also it would follow a nice hierarchy: lion-form -> lion-fieldset -> lion-field
@ -26,17 +22,12 @@ import { InteractionStateMixin } from './InteractionStateMixin.js'; // applies F
*
* @customElement lion-field
*/
// @ts-expect-error base constructors same return type
export class LionField extends FormControlMixin(
InteractionStateMixin(
FocusMixin(FormatMixin(ValidateMixin(DisabledMixin(SlotMixin(LitElement))))),
),
InteractionStateMixin(FocusMixin(FormatMixin(ValidateMixin(SlotMixin(LitElement))))),
) {
static get properties() {
return {
submitted: {
// make sure validation can be triggered based on observer
type: Boolean,
},
autocomplete: {
type: String,
reflect: true,
@ -47,6 +38,10 @@ export class LionField extends FormControlMixin(
};
}
get _inputNode() {
return /** @type {HTMLInputElement} */ (super._inputNode); // casts type
}
/** @type {number} */
get selectionStart() {
const native = this._inputNode;
@ -85,6 +80,7 @@ export class LionField extends FormControlMixin(
// if not yet connected to dom can't change the value
if (this._inputNode) {
this._setValueAndPreserveCaret(value);
/** @type {string | undefined} */
this.__value = undefined;
} else {
this.__value = value;
@ -98,11 +94,16 @@ export class LionField extends FormControlMixin(
constructor() {
super();
this.name = '';
this.submitted = false;
/** @type {string | undefined} */
this.autocomplete = undefined;
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
/** @type {any} */
this._initialModelValue = this.modelValue;
}
@ -118,6 +119,9 @@ export class LionField extends FormControlMixin(
this._inputNode.removeEventListener('change', this._onChange);
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
@ -131,14 +135,12 @@ export class LionField extends FormControlMixin(
}
if (changedProperties.has('autocomplete')) {
this._inputNode.autocomplete = this.autocomplete;
this._inputNode.autocomplete = /** @type {string} */ (this.autocomplete);
}
}
resetInteractionState() {
if (super.resetInteractionState) {
super.resetInteractionState();
}
this.submitted = false;
}
@ -147,19 +149,15 @@ export class LionField extends FormControlMixin(
this.resetInteractionState();
}
/**
* Clears modelValue.
* Interaction states are not cleared (use resetInteractionState for this)
*/
clear() {
if (super.clear) {
// Let validationMixin and interactionStateMixin clear their
// invalid and dirty/touched states respectively
super.clear();
}
this.modelValue = ''; // can't set null here, because IE11 treats it as a string
}
_onChange() {
if (super._onChange) {
super._onChange();
}
this.dispatchEvent(
new CustomEvent('user-input-changed', {
bubbles: true,

View file

@ -2,9 +2,18 @@ import { dedupeMixin } from '@lion/core';
import { FormRegistrarMixin } from '../registration/FormRegistrarMixin.js';
import { InteractionStateMixin } from '../InteractionStateMixin.js';
export const ChoiceGroupMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line
/**
* @typedef {import('../../types/choice-group/ChoiceGroupMixinTypes').ChoiceGroupMixin} ChoiceGroupMixin
* @typedef {import('../../types/FormControlMixinTypes').FormControlHost} FormControlHost
* @typedef {import('../../types/registration/FormRegistrarMixinTypes').ElementWithParentFormGroup} ElementWithParentFormGroup
* @typedef {FormControlHost & HTMLElement & {__parentFormGroup?:HTMLElement, checked?:boolean}} FormControl
*/
/**
* @type {ChoiceGroupMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
const ChoiceGroupMixinImplementation = superclass =>
class ChoiceGroupMixin extends FormRegistrarMixin(InteractionStateMixin(superclass)) {
static get properties() {
return {
@ -12,7 +21,6 @@ export const ChoiceGroupMixin = dedupeMixin(
* @desc When false (default), modelValue and serializedValue will reflect the
* currently selected choice (usually a string). When true, modelValue will and
* serializedValue will be an array of strings.
* @type {boolean}
*/
multipleChoice: {
type: Boolean,
@ -30,13 +38,19 @@ export const ChoiceGroupMixin = dedupeMixin(
}
set modelValue(value) {
/**
* @param {{ modelValue: { value: any; }; }} el
* @param {any} val
*/
const checkCondition = (el, val) => el.modelValue.value === val;
if (this.__isInitialModelValue) {
this.__isInitialModelValue = false;
this.registrationComplete.then(() => {
this._setCheckedElements(value, (el, val) => el.modelValue.value === val);
this._setCheckedElements(value, checkCondition);
});
} else {
this._setCheckedElements(value, (el, val) => el.modelValue.value === val);
this._setCheckedElements(value, checkCondition);
}
}
@ -57,13 +71,19 @@ export const ChoiceGroupMixin = dedupeMixin(
}
set serializedValue(value) {
/**
* @param {{ serializedValue: { value: any; }; }} el
* @param {string} val
*/
const checkCondition = (el, val) => el.serializedValue.value === val;
if (this.__isInitialSerializedValue) {
this.__isInitialSerializedValue = false;
this.registrationComplete.then(() => {
this._setCheckedElements(value, (el, val) => el.serializedValue.value === val);
this._setCheckedElements(value, checkCondition);
});
} else {
this._setCheckedElements(value, (el, val) => el.serializedValue.value === val);
this._setCheckedElements(value, checkCondition);
}
}
@ -76,13 +96,19 @@ export const ChoiceGroupMixin = dedupeMixin(
}
set formattedValue(value) {
/**
* @param {{ formattedValue: string }} el
* @param {string} val
*/
const checkCondition = (el, val) => el.formattedValue === val;
if (this.__isInitialFormattedValue) {
this.__isInitialFormattedValue = false;
this.registrationComplete.then(() => {
this._setCheckedElements(value, (el, val) => el.formattedValue === val);
this._setCheckedElements(value, checkCondition);
});
} else {
this._setCheckedElements(value, (el, val) => el.formattedValue === val);
this._setCheckedElements(value, checkCondition);
}
}
@ -94,6 +120,7 @@ export const ChoiceGroupMixin = dedupeMixin(
this.__isInitialModelValue = true;
this.__isInitialSerializedValue = true;
this.__isInitialFormattedValue = true;
/** @type {Promise<any> & {done?:boolean}} */
this.registrationComplete = new Promise((resolve, reject) => {
this.__resolveRegistrationComplete = resolve;
this.__rejectRegistrationComplete = reject;
@ -124,9 +151,7 @@ export const ChoiceGroupMixin = dedupeMixin(
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
if (this.registrationComplete.done === false) {
this.__rejectRegistrationComplete();
@ -135,6 +160,8 @@ export const ChoiceGroupMixin = dedupeMixin(
/**
* @override from FormRegistrarMixin
* @param {FormControl} child
* @param {number} indexToInsertAt
*/
addFormElement(child, indexToInsertAt) {
this._throwWhenInvalidChildModelValue(child);
@ -153,6 +180,7 @@ export const ChoiceGroupMixin = dedupeMixin(
/**
* @override
* @param {string} property
*/
_getFromAllFormElements(property, filterCondition = () => true) {
// For modelValue, serializedValue and formattedValue, an exception should be made,
@ -167,8 +195,12 @@ export const ChoiceGroupMixin = dedupeMixin(
return this.formElements.filter(filterCondition).map(el => el.property);
}
/**
* @param {FormControl} child
*/
_throwWhenInvalidChildModelValue(child) {
if (
// @ts-expect-error
typeof child.modelValue.checked !== 'boolean' ||
!Object.prototype.hasOwnProperty.call(child.modelValue, 'value')
) {
@ -196,6 +228,9 @@ export const ChoiceGroupMixin = dedupeMixin(
return false;
}
/**
* @param {CustomEvent & {target:FormControl}} ev
*/
_checkSingleChoiceElements(ev) {
const { target } = ev;
if (target.checked === false) return;
@ -208,7 +243,7 @@ export const ChoiceGroupMixin = dedupeMixin(
choice.checked = false; // eslint-disable-line no-param-reassign
}
});
this.__triggerCheckedValueChanged();
// this.__triggerCheckedValueChanged();
}
_getCheckedElements() {
@ -216,6 +251,10 @@ export const ChoiceGroupMixin = dedupeMixin(
return this.formElements.filter(el => el.checked && !el.disabled);
}
/**
* @param {string | any[]} value
* @param {Function} check
*/
_setCheckedElements(value, check) {
for (let i = 0; i < this.formElements.length; i += 1) {
if (this.multipleChoice) {
@ -236,6 +275,9 @@ export const ChoiceGroupMixin = dedupeMixin(
}
}
/**
* @param {FormControl} child
*/
__delegateNameAttribute(child) {
if (!child.name || child.name === this.name) {
// eslint-disable-next-line no-param-reassign
@ -253,6 +295,7 @@ export const ChoiceGroupMixin = dedupeMixin(
/**
* @override FormControlMixin
* @param {CustomEvent} ev
*/
_onBeforeRepropagateChildrenValues(ev) {
// Normalize target, since we might receive 'portal events' (from children in a modal,
@ -269,5 +312,6 @@ export const ChoiceGroupMixin = dedupeMixin(
this.__setChoiceGroupTouched();
this.requestUpdate('modelValue');
}
},
);
};
export const ChoiceGroupMixin = dedupeMixin(ChoiceGroupMixinImplementation);

View file

@ -1,10 +1,29 @@
/* eslint-disable class-methods-use-this */
import { css, html, nothing } from '@lion/core';
import { css, html, nothing, dedupeMixin } from '@lion/core';
import { FormatMixin } from '../FormatMixin.js';
export const ChoiceInputMixin = superclass =>
// eslint-disable-next-line
/**
* @typedef {import('../../types/FormControlMixinTypes').FormControlHost} FormControlHost
* @typedef {FormControlHost & HTMLElement & {__parentFormGroup?:HTMLElement, checked?:boolean}} FormControl
*/
/**
* @typedef {import('../../types/choice-group/ChoiceInputMixinTypes').ChoiceInputMixin} ChoiceInputMixin
* @typedef {import('../../types/choice-group/ChoiceInputMixinTypes').ChoiceInputModelValue} ChoiceInputModelValue
*/
/**
* @param {ChoiceInputModelValue} nw\
* @param {{value?:any, checked?:boolean}} old
*/
const hasChanged = (nw, old = {}) => nw.value !== old.value || nw.checked !== old.checked;
/**
* @type {ChoiceInputMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
const ChoiceInputMixinImplementation = superclass =>
class ChoiceInputMixin extends FormatMixin(superclass) {
static get properties() {
return {
@ -25,7 +44,7 @@ export const ChoiceInputMixin = superclass =>
*/
modelValue: {
type: Object,
hasChanged: (nw, old = {}) => nw.value !== old.value || nw.checked !== old.checked,
hasChanged,
},
/**
* The value property of the modelValue. It provides an easy interface for storing
@ -44,10 +63,15 @@ export const ChoiceInputMixin = superclass =>
set choiceValue(value) {
this.requestUpdate('choiceValue', this.choiceValue);
if (this.modelValue.value !== value) {
/** @type {ChoiceInputModelValue} */
this.modelValue = { value, checked: this.modelValue.checked };
}
}
/**
* @param {string} name
* @param {any} oldValue
*/
requestUpdateInternal(name, oldValue) {
super.requestUpdateInternal(name, oldValue);
@ -62,6 +86,9 @@ export const ChoiceInputMixin = superclass =>
}
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
if (changedProperties.has('checked')) {
@ -71,6 +98,9 @@ export const ChoiceInputMixin = superclass =>
}
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('modelValue')) {
@ -118,9 +148,7 @@ export const ChoiceInputMixin = superclass =>
render() {
return html`
<slot name="input"></slot>
<div class="choice-field__graphic-container">
${this._choiceGraphicTemplate()}
</div>
<div class="choice-field__graphic-container">${this._choiceGraphicTemplate()}</div>
<div class="choice-field__label">
<slot name="label"></slot>
</div>
@ -148,10 +176,16 @@ export const ChoiceInputMixin = superclass =>
this.checked = !this.checked;
}
/**
* @param {boolean} checked
*/
__syncModelCheckedToChecked(checked) {
this.checked = checked;
}
/**
* @param {any} checked
*/
__syncCheckedToModel(checked) {
this.modelValue = { value: this.choiceValue, checked };
}
@ -160,7 +194,8 @@ export const ChoiceInputMixin = superclass =>
// ._inputNode might not be available yet(slot content)
// or at all (no reliance on platform construct, in case of [role=option])
if (this._inputNode) {
this._inputNode.checked = this.checked;
/** @type {HTMLInputElement} */
(this._inputNode).checked = this.checked;
}
}
@ -178,8 +213,12 @@ export const ChoiceInputMixin = superclass =>
* @override
* hasChanged is designed for async (updated) callback, also check for sync
* (requestUpdateInternal) callback
* @param {{ modelValue:unknown }} newV
* @param {{ modelValue:unknown }} [oldV]
*/
// @ts-expect-error
_onModelValueChanged({ modelValue }, { modelValue: old }) {
// @ts-expect-error
if (this.constructor._classProperties.get('modelValue').hasChanged(modelValue, old)) {
super._onModelValueChanged({ modelValue });
}
@ -195,8 +234,8 @@ export const ChoiceInputMixin = superclass =>
}
/**
* @override
* Overridden from FormatMixin, since a different modelValue is used for choice inputs.
* @override Overridden from FormatMixin, since a different modelValue is used for choice inputs.
* @param {ChoiceInputModelValue } modelValue
*/
formatter(modelValue) {
return modelValue && modelValue.value !== undefined ? modelValue.value : modelValue;
@ -216,3 +255,5 @@ export const ChoiceInputMixin = superclass =>
*/
_syncValueUpwards() {}
};
export const ChoiceInputMixin = dedupeMixin(ChoiceInputMixinImplementation);

View file

@ -5,6 +5,11 @@ export class FormElementsHaveNoError extends Validator {
return 'FormElementsHaveNoError';
}
/**
* @param {unknown} [value]
* @param {string | undefined} [options]
* @param {{ node: any }} config
*/
// eslint-disable-next-line class-methods-use-this
execute(value, options, config) {
const hasError = config.node._anyFormElementHasFeedbackFor('error');

View file

@ -7,6 +7,15 @@ import { ValidateMixin } from '../validate/ValidateMixin.js';
import { getAriaElementsInRightDomOrder } from '../utils/getAriaElementsInRightDomOrder.js';
import { FormElementsHaveNoError } from './FormElementsHaveNoError.js';
/**
* @typedef {import('../../types/form-group/FormGroupMixinTypes').FormGroupMixin} FormGroupMixin
* @typedef {import('../../types/form-group/FormGroupMixinTypes').FormGroupHost} FormGroupHost
* @typedef {import('../../types/FormControlMixinTypes').FormControlHost} FormControlHost
* @typedef {import('../../types/registration/FormRegisteringMixinTypes').FormRegisteringHost} FormRegisteringHost
* @typedef {import('../../types/registration/FormRegistrarMixinTypes').ElementWithParentFormGroup} ElementWithParentFormGroup
* @typedef {FormControlHost & HTMLElement & {__parentFormGroup?: HTMLElement, checked?: boolean, disabled: boolean, hasFeedbackFor: string[], makeRequestToBeDisabled: Function }} FormControl
*/
/**
* @desc Form group mixin serves as the basis for (sub) forms. Designed to be put on
* elements with [role="group|radiogroup"] (think of checkbox-group, radio-group, fieldset).
@ -15,10 +24,11 @@ import { FormElementsHaveNoError } from './FormElementsHaveNoError.js';
* It is designed to be used on top of FormRegistrarMixin and ChoiceGroupMixin.
* Also, it is th basis of the LionFieldset element (which supports name based retrieval of
* children via formElements and the automatic grouping of formElements via '[]').
*
* @type {FormGroupMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
export const FormGroupMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow
const FormGroupMixinImplementation = superclass =>
class FormGroupMixin extends FormRegistrarMixin(
FormControlMixin(ValidateMixin(DisabledMixin(SlotMixin(superclass)))),
) {
@ -132,7 +142,7 @@ export const FormGroupMixin = dedupeMixin(
this.addEventListener('validate-performed', this.__onChildValidatePerformed);
this.defaultValidators = [new FormElementsHaveNoError()];
/** @type {Promise<any> & {done?:boolean}} */
this.registrationComplete = new Promise((resolve, reject) => {
this.__resolveRegistrationComplete = resolve;
this.__rejectRegistrationComplete = reject;
@ -164,9 +174,7 @@ export const FormGroupMixin = dedupeMixin(
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
if (this.__hasActiveOutsideClickHandling) {
document.removeEventListener('click', this._checkForOutsideClick);
@ -194,6 +202,9 @@ export const FormGroupMixin = dedupeMixin(
});
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
@ -219,8 +230,11 @@ export const FormGroupMixin = dedupeMixin(
}
}
/**
* @param {Event} event
*/
_checkForOutsideClick(event) {
const outsideGroupClicked = !this.contains(event.target);
const outsideGroupClicked = !this.contains(/** @type {Node} */ (event.target));
if (outsideGroupClicked) {
this.touched = true;
}
@ -301,15 +315,18 @@ export const FormGroupMixin = dedupeMixin(
});
}
_getFromAllFormElements(property, filterCondition = el => !el.disabled) {
/**
* @param {string} property
*/
_getFromAllFormElements(property, filterFn = (/** @type {FormControl} */ el) => !el.disabled) {
const result = {};
this.formElements._keys().forEach(name => {
const elem = this.formElements[name];
if (elem instanceof FormControlsCollection) {
result[name] = elem.filter(el => filterCondition(el)).map(el => el[property]);
} else if (filterCondition(elem)) {
result[name] = elem.filter(el => filterFn(el)).map(el => el[property]);
} else if (filterFn(elem)) {
if (typeof elem._getFromAllFormElements === 'function') {
result[name] = elem._getFromAllFormElements(property, filterCondition);
result[name] = elem._getFromAllFormElements(property, filterFn);
} else {
result[name] = elem[property];
}
@ -318,17 +335,28 @@ export const FormGroupMixin = dedupeMixin(
return result;
}
/**
* @param {string | number} property
* @param {any} value
*/
_setValueForAllFormElements(property, value) {
this.formElements.forEach(el => {
el[property] = value; // eslint-disable-line no-param-reassign
});
}
/**
* @param {string} property
* @param {{ [x: string]: any; }} values
*/
_setValueMapForAllFormElements(property, values) {
if (values && typeof values === 'object') {
Object.keys(values).forEach(name => {
if (Array.isArray(this.formElements[name])) {
this.formElements[name].forEach((el, index) => {
this.formElements[name].forEach((
/** @type {FormControl} */ el,
/** @type {number} */ index,
) => {
el[property] = values[name][index]; // eslint-disable-line no-param-reassign
});
}
@ -339,19 +367,25 @@ export const FormGroupMixin = dedupeMixin(
}
}
/**
* @param {string} property
*/
_anyFormElementHas(property) {
return Object.keys(this.formElements).some(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].some(el => !!el[property]);
return this.formElements[name].some((/** @type {FormControl} */ el) => !!el[property]);
}
return !!this.formElements[name][property];
});
}
/**
* @param {string} state one of ValidateHost.validationTypes
*/
_anyFormElementHasFeedbackFor(state) {
return Object.keys(this.formElements).some(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].some(el => {
return this.formElements[name].some((/** @type {FormControl} */ el) => {
return Boolean(el.hasFeedbackFor && el.hasFeedbackFor.includes(state));
});
}
@ -362,10 +396,13 @@ export const FormGroupMixin = dedupeMixin(
});
}
/**
* @param {string} property
*/
_everyFormElementHas(property) {
return Object.keys(this.formElements).every(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].every(el => !!el[property]);
return this.formElements[name].every((/** @type {FormControl} */ el) => !!el[property]);
}
return !!this.formElements[name][property];
});
@ -376,9 +413,10 @@ export const FormGroupMixin = dedupeMixin(
* - react on modelValue change, which says something about the validity as a whole
* (at least two checkboxes for instance) and nothing about the children's values
* - children validity states have changed, so fieldset needs to update itself based on that
* @param {Event} ev
*/
__onChildValidatePerformed(ev) {
if (ev && this.isRegisteredFormElement(ev.target)) {
if (ev && this.isRegisteredFormElement(/** @type {FormControl} */ (ev.target))) {
this.validate();
}
}
@ -387,6 +425,9 @@ export const FormGroupMixin = dedupeMixin(
this.focused = this._anyFormElementHas('focused');
}
/**
* @param {Event} ev
*/
_onFocusOut(ev) {
const lastEl = this.formElements[this.formElements.length - 1];
if (ev.target === lastEl) {
@ -399,14 +440,16 @@ export const FormGroupMixin = dedupeMixin(
this.dirty = this._anyFormElementHas('dirty');
}
/**
* @param {FormControl} child
*/
__linkChildrenMessagesToParent(child) {
// aria-describedby of (nested) children
let parent = this;
const unTypedThis = /** @type {unknown} */ (this);
let parent = /** @type {FormControlHost & { __parentFormGroup:any }} */ (unTypedThis);
const ctor = /** @type {typeof FormGroupMixin} */ (this.constructor);
while (parent) {
this.constructor._addDescriptionElementIdsToField(
child,
parent._getAriaDescriptionElements(),
);
ctor._addDescriptionElementIdsToField(child, parent._getAriaDescriptionElements());
// Also check if the newly added child needs to refer grandparents
parent = parent.__parentFormGroup;
}
@ -416,11 +459,12 @@ export const FormGroupMixin = dedupeMixin(
* @override of FormRegistrarMixin.
* @desc Connects ValidateMixin and DisabledMixin
* On top of this, error messages of children are linked to their parents
* @param {FormControl} child
* @param {number} indexToInsertAt
*/
addFormElement(child, indexToInsertAt) {
super.addFormElement(child, indexToInsertAt);
if (this.disabled) {
// eslint-disable-next-line no-param-reassign
child.makeRequestToBeDisabled();
}
// TODO: Unlink in removeFormElement
@ -439,8 +483,8 @@ export const FormGroupMixin = dedupeMixin(
/**
* Add aria-describedby to child element(field), so that it points to feedback/help-text of
* parent(fieldset)
* @param {LionField} field - the child: lion-field/lion-input/lion-textarea
* @param {array} descriptionElements - description elements like feedback and help-text
* @param {FormControl} field - the child: lion-field/lion-input/lion-textarea
* @param {HTMLElement[]} descriptionElements - description elements like feedback and help-text
*/
static _addDescriptionElementIdsToField(field, descriptionElements) {
const orderedEls = getAriaElementsInRightDomOrder(descriptionElements, { reverse: true });
@ -453,10 +497,12 @@ export const FormGroupMixin = dedupeMixin(
/**
* @override of FormRegistrarMixin. Connects ValidateMixin
* @param {FormRegisteringHost} el
*/
removeFormElement(...args) {
super.removeFormElement(...args);
removeFormElement(el) {
super.removeFormElement(el);
this.validate({ clearCurrentResult: true });
}
},
);
};
export const FormGroupMixin = dedupeMixin(FormGroupMixinImplementation);

View file

@ -2,6 +2,8 @@ import { dedupeMixin } from '@lion/core';
/**
* @typedef {import('../../types/registration/FormRegisteringMixinTypes').FormRegisteringMixin} FormRegisteringMixin
* @typedef {import('../../types/registration/FormRegistrarMixinTypes').ElementWithParentFormGroup} ElementWithParentFormGroup
* @typedef {import('../../types/registration/FormRegistrarMixinTypes').FormRegistrarHost} FormRegistrarHost
*/
/**
@ -10,11 +12,17 @@ import { dedupeMixin } from '@lion/core';
* This Mixin registers a form element to a Registrar
*
* @type {FormRegisteringMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<HTMLElement>} superclass
*/
const FormRegisteringMixinImplementation = superclass =>
class FormRegisteringMixin extends superclass {
class extends superclass {
/** @type {FormRegistrarHost | undefined} */
__parentFormGroup;
connectedCallback() {
// @ts-expect-error check it anyway, because could be lit-element extension
if (super.connectedCallback) {
// @ts-expect-error check it anyway, because could be lit-element extension
super.connectedCallback();
}
this.dispatchEvent(
@ -26,7 +34,9 @@ const FormRegisteringMixinImplementation = superclass =>
}
disconnectedCallback() {
// @ts-expect-error check it anyway, because could be lit-element extension
if (super.disconnectedCallback) {
// @ts-expect-error check it anyway, because could be lit-element extension
super.disconnectedCallback();
}
if (this.__parentFormGroup) {

View file

@ -1,13 +1,17 @@
// eslint-disable-next-line max-classes-per-file
import { dedupeMixin } from '@lion/core';
import { FormRegisteringMixin } from './FormRegisteringMixin.js';
import { FormControlsCollection } from './FormControlsCollection.js';
// TODO: rename .formElements to .formControls? (or .$controls ?)
import { FormRegisteringMixin } from './FormRegisteringMixin.js';
/**
* @typedef {import('../../types/registration/FormRegistrarMixinTypes').FormRegistrarMixin} FormRegistrarMixin
* @typedef {import('../../types/registration/FormRegistrarMixinTypes').ElementWithParentFormGroup} ElementWithParentFormGroup
* @typedef {import('../../types/registration/FormRegisteringMixinTypes').FormRegisteringHost} FormRegisteringHost
*/
/**
* @typedef {import('../../types/FormControlMixinTypes').FormControlHost} FormControlHost
* @typedef {FormControlHost & HTMLElement & {__parentFormGroup?:HTMLElement, checked?:boolean}} FormControl
*/
/**
@ -19,10 +23,11 @@ import { FormControlsCollection } from './FormControlsCollection.js';
* For choice groups, the value will only stay an array.
* See FormControlsCollection for more information
* @type {FormRegistrarMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
const FormRegistrarMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormRegistrarMixin extends FormRegisteringMixin(superclass) {
class extends FormRegisteringMixin(superclass) {
static get properties() {
return {
/**
@ -45,7 +50,11 @@ const FormRegistrarMixinImplementation = superclass =>
this._isFormOrFieldset = false;
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
this.addEventListener('form-element-register', this._onRequestToAddFormElement);
this.addEventListener(
'form-element-register',
/** @type {EventListenerOrEventListenerObject} */ (this._onRequestToAddFormElement),
);
}
/**
@ -57,7 +66,7 @@ const FormRegistrarMixinImplementation = superclass =>
}
/**
* @param {ElementWithParentFormGroup} child the child element (field)
* @param {FormControl} child the child element (field)
* @param {number} indexToInsertAt index to insert the form element at
*/
addFormElement(child, indexToInsertAt) {
@ -74,12 +83,12 @@ const FormRegistrarMixinImplementation = superclass =>
// 2. Add children as object key
if (this._isFormOrFieldset) {
// @ts-ignore
const { name } = child; // FIXME: <-- ElementWithParentFormGroup should become LionFieldWithParentFormGroup so that "name" exists
const { name } = child;
if (!name) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError('You need to define a name');
}
// @ts-expect-error
if (name === this.name) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(`You can not have the same name "${name}" as your parent`);
@ -106,7 +115,7 @@ const FormRegistrarMixinImplementation = superclass =>
}
/**
* @param {ElementWithParentFormGroup} child the child element (field)
* @param {FormRegisteringHost} child the child element (field)
*/
removeFormElement(child) {
// 1. Handle array based children
@ -117,7 +126,7 @@ const FormRegistrarMixinImplementation = superclass =>
// 2. Handle name based object keys
if (this._isFormOrFieldset) {
// @ts-ignore
// @ts-expect-error
const { name } = child; // FIXME: <-- ElementWithParentFormGroup should become LionFieldWithParentFormGroup so that "name" exists
if (name.substr(-2) === '[]' && this.formElements[name]) {
const idx = this.formElements[name].indexOf(child);

View file

@ -17,10 +17,11 @@ import { dedupeMixin } from '@lion/core';
* </my-portal>
* // my-field will be registered within my-form
* @type {FormRegistrarPortalMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<HTMLElement>} superclass
*/
const FormRegistrarPortalMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormRegistrarPortalMixin extends superclass {
class extends superclass {
constructor() {
super();
/** @type {(FormRegistrarPortalHost & HTMLElement) | undefined} */
@ -30,7 +31,8 @@ const FormRegistrarPortalMixinImplementation = superclass =>
);
this.addEventListener(
'form-element-register',
this.__redispatchEventForFormRegistrarPortalMixin,
/** @type {EventListenerOrEventListenerObject} */ (this
.__redispatchEventForFormRegistrarPortalMixin),
);
}

View file

@ -21,9 +21,10 @@ import { dedupeMixin } from '@lion/core';
* Whenever the implementation of `requestUpdateInternal` changes (this happened in the past for
* `requestUpdate`) we only have to change our abstraction instead of all our components
* @type {SyncUpdatableMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
const SyncUpdatableMixinImplementation = superclass =>
class SyncUpdatable extends superclass {
class extends superclass {
constructor() {
super();
// Namespace for this mixin that guarantees naming clashes will not occur...
@ -52,6 +53,7 @@ const SyncUpdatableMixinImplementation = superclass =>
* @param {*} oldValue
*/
static __syncUpdatableHasChanged(name, newValue, oldValue) {
// @ts-expect-error FIXME: Typescript bug, superclass static method not availabe from static context
const properties = this._classProperties;
if (properties.get(name) && properties.get(name).hasChanged) {
return properties.get(name).hasChanged(newValue, oldValue);
@ -61,7 +63,8 @@ const SyncUpdatableMixinImplementation = superclass =>
__syncUpdatableInitialize() {
const ns = this.__SyncUpdatableNamespace;
const ctor = /** @type {SyncUpdatableMixin & SyncUpdatable} */ (this.constructor);
const ctor = /** @type {typeof SyncUpdatableMixin & typeof import('../../types/utils/SyncUpdatableMixinTypes').SyncUpdatableHost} */ (this
.constructor);
ns.initialized = true;
// Empty queue...
@ -84,7 +87,8 @@ const SyncUpdatableMixinImplementation = superclass =>
this.__SyncUpdatableNamespace = this.__SyncUpdatableNamespace || {};
const ns = this.__SyncUpdatableNamespace;
const ctor = /** @type {SyncUpdatableMixin & SyncUpdatable} */ (this.constructor);
const ctor = /** @type {typeof SyncUpdatableMixin & typeof import('../../types/utils/SyncUpdatableMixinTypes').SyncUpdatableHost} */ (this
.constructor);
// Before connectedCallback: queue
if (!ns.connected) {

View file

@ -1,36 +0,0 @@
// TODO: this method has to be removed when EventTarget polyfill is available on IE11
// TODO: move to core and apply everywhere?
// TODO: pascalCase this filename?
/**
* @param {HTMLElement} instance
*/
export function fakeExtendsEventTarget(instance) {
const delegate = document.createDocumentFragment();
/**
* @param {string} type
* @param {EventListener} listener
* @param {Object} opts
*/
const delegatedMethodAdd = (type, listener, opts) =>
delegate.addEventListener(type, listener, opts);
/**
* @param {Event|CustomEvent} event
*/
const delegatedMethodDispatch = event => delegate.dispatchEvent(event);
/**
* @param {string} type
* @param {EventListener} listener
* @param {Object} opts
*/
const delegatedMethodRemove = (type, listener, opts) =>
delegate.removeEventListener(type, listener, opts);
/* eslint-disable no-param-reassign */
instance.addEventListener = delegatedMethodAdd;
instance.dispatchEvent = delegatedMethodDispatch;
instance.removeEventListener = delegatedMethodRemove;
/* eslint-enable no-param-reassign */
}

View file

@ -1,5 +1,14 @@
import { html, LitElement } from '@lion/core';
/**
* @typedef {import('../validate/Validator').Validator} Validator
*
* @typedef {Object} messageMap
* @property {string | Node} message
* @property {string} type
* @property {Validator} [validator]
*/
/**
* @desc Takes care of accessible rendering of error messages
* Should be used in conjunction with FormControl having ValidateMixin applied
@ -7,23 +16,27 @@ import { html, LitElement } from '@lion/core';
export class LionValidationFeedback extends LitElement {
static get properties() {
return {
/**
* @property {FeedbackData} feedbackData
*/
feedbackData: Array,
feedbackData: { attribute: false },
};
}
/**
* @overridable
* @param {Object} opts
* @param {string | Node} opts.message message or feedback node
* @param {string} [opts.type]
* @param {Validator} [opts.validator]
*/
// eslint-disable-next-line class-methods-use-this
_messageTemplate({ message }) {
return message;
}
updated() {
super.updated();
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (this.feedbackData && this.feedbackData[0]) {
this.setAttribute('type', this.feedbackData[0].type);
this.currentType = this.feedbackData[0].type;
@ -31,7 +44,8 @@ export class LionValidationFeedback extends LitElement {
if (this.currentType === 'success') {
this.removeMessage = window.setTimeout(() => {
this.removeAttribute('type');
this.feedbackData = '';
/** @type {messageMap[]} */
this.feedbackData = [];
}, 3000);
}
} else if (this.currentType !== 'success') {

View file

@ -8,11 +8,14 @@ import { Validator } from './Validator.js';
*/
export class ResultValidator extends Validator {
/**
* @param {object} context
* @param {Validator[]} context.validationResult
* @param {Validator[]} context.prevValidationResult
* @param {Validator[]} context.validators
* @returns {Feedback[]}
* @param {Object} context
* @param {Validator[]} context.regularValidationResult
* @param {Validator[] | undefined} context.prevValidationResult
* @param {Validator[]} [context.validators]
* @returns {boolean}
*/
executeOnResults({ validationResult, prevValidationResult, validators }) {} // eslint-disable-line
// eslint-disable-next-line no-unused-vars, class-methods-use-this
executeOnResults({ regularValidationResult, prevValidationResult, validators }) {
return true;
}
}

View file

@ -1,17 +1,25 @@
/* eslint-disable class-methods-use-this, camelcase, no-param-reassign, max-classes-per-file */
import { dedupeMixin, ScopedElementsMixin, SlotMixin } from '@lion/core';
import { dedupeMixin, ScopedElementsMixin, SlotMixin, DisabledMixin } from '@lion/core';
// TODO: make form-core independent from localize
import { localize } from '@lion/localize';
import { LionValidationFeedback } from './LionValidationFeedback.js';
import { ResultValidator } from './ResultValidator.js';
import { Unparseable } from './Unparseable.js';
import { AsyncQueue } from '../utils/AsyncQueue.js';
import { pascalCase } from '../utils/pascalCase.js';
import { SyncUpdatableMixin } from '../utils/SyncUpdatableMixin.js';
import { LionValidationFeedback } from './LionValidationFeedback.js';
import { ResultValidator } from './ResultValidator.js';
import { Unparseable } from './Unparseable.js';
import { Validator } from './Validator.js';
import { Required } from './validators/Required.js';
import { FormControlMixin } from '../FormControlMixin.js';
/**
* @typedef {import('../../types/validate/ValidateMixinTypes').ValidateMixin} ValidateMixin
*/
/**
* @param {any[]} array1
* @param {any[]} array2
*/
function arrayDiff(array1 = [], array2 = []) {
return array1.filter(x => !array2.includes(x)).concat(array2.filter(x => !array1.includes(x)));
}
@ -20,52 +28,42 @@ function arrayDiff(array1 = [], array2 = []) {
* @desc Handles all validation, based on modelValue changes. It has no knowledge about dom and
* UI. All error visibility, dom interaction and accessibility are handled in FeedbackMixin.
*
* @event error-state-changed fires when FormControl goes from non-error to error state and vice versa
* @event error-changed fires when the Validator(s) leading to the error state, change
* @type {ValidateMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
export const ValidateMixin = dedupeMixin(
superclass =>
export const ValidateMixinImplementation = superclass =>
// eslint-disable-next-line no-unused-vars, no-shadow
class ValidateMixin extends SyncUpdatableMixin(SlotMixin(ScopedElementsMixin(superclass))) {
class extends FormControlMixin(
SyncUpdatableMixin(DisabledMixin(SlotMixin(ScopedElementsMixin(superclass)))),
) {
static get scopedElements() {
const scopedElementsCtor = /** @type {typeof import('@open-wc/scoped-elements/src/types').ScopedElementsHost} */ (super
.constructor);
return {
...super.scopedElements,
...scopedElementsCtor.scopedElements,
'lion-validation-feedback': LionValidationFeedback,
};
}
static get properties() {
return {
/**
* @desc List of all Validator instances applied to FormControl
* @type {Validator[]}
* @example
* FormControl.validators = [new Required(), new MinLength(3, { type: 'warning' })];
*/
validators: Array,
validators: { attribute: false },
hasFeedbackFor: {
type: Array,
},
hasFeedbackFor: { attribute: false },
shouldShowFeedbackFor: {
type: Array,
},
shouldShowFeedbackFor: { attribute: false },
showsFeedbackFor: {
type: Array,
attribute: 'shows-feedback-for',
reflect: true,
converter: {
fromAttribute: value => value.split(','),
toAttribute: value => value.join(','),
fromAttribute: /** @param {string} value */ value => value.split(','),
toAttribute: /** @param {[]} value */ value => value.join(','),
},
},
validationStates: {
type: Object,
// hasChanged: this._hasObjectChanged,
},
validationStates: { attribute: false },
/**
* @desc flag that indicates whether async validation is pending
@ -76,30 +74,17 @@ export const ValidateMixin = dedupeMixin(
reflect: true,
},
/**
* @desc value that al validation revolves around: once changed (usually triggered by
* end user entering input), it will automatically trigger validation.
*/
modelValue: Object,
/**
* @desc specialized fields (think of input-date and input-email) can have preconfigured
* validators.
*/
defaultValidators: Array,
defaultValidators: { attribute: false },
/**
* Subclassers can enable this to show multiple feedback messages at the same time
* By default, just like the platform, only one message (with highest prio) is visible.
*/
_visibleMessagesAmount: Number,
/**
* @type {Promise<string>|string} will be passed as an argument to the `.getMessage`
* method of a Validator. When filled in, this field name can be used to enhance
* error messages.
*/
fieldName: String,
_visibleMessagesAmount: { attribute: false },
};
}
@ -115,36 +100,22 @@ export const ValidateMixin = dedupeMixin(
* Adds "._feedbackNode" as described below
*/
get slots() {
/**
* FIXME: Ugly workaround https://github.com/microsoft/TypeScript/issues/40110
* @callback getScopedTagName
* @param {string} tagName
* @returns {string}
*
* @typedef {Object} ScopedElementsObj
* @property {getScopedTagName} getScopedTagName
*/
const ctor = /** @type {typeof ValidateMixin & ScopedElementsObj} */ (this.constructor);
return {
...super.slots,
feedback: () =>
document.createElement(this.constructor.getScopedTagName('lion-validation-feedback')),
feedback: () => document.createElement(ctor.getScopedTagName('lion-validation-feedback')),
};
}
/**
* @overridable
* @type {Element} _feedbackNode:
* Gets a `FeedbackData` object as its input.
* This element can be a custom made (web) component that renders messages in accordance with
* the implemented Design System. For instance, it could add an icon in front of a message.
* The _feedbackNode is only responsible for the visual rendering part, it should NOT contain
* state. All state will be determined by the outcome of `FormControl.filterFeeback()`.
* FormControl delegates to individual sub elements and decides who renders what.
* For instance, FormControl itself is responsible for reflecting error-state and error-show
* to its host element.
* This means filtering out messages should happen in FormControl and NOT in `_feedbackNode`
*
* - gets a FeedbackData object as input
* - should know about the FeedbackMessage types('error', 'success' etc.) that the FormControl
* (having ValidateMixin applied) returns
* - renders result and
*
*/
get _feedbackNode() {
return this.querySelector('[slot=feedback]');
}
get _allValidators() {
return [...this.validators, ...this.defaultValidators];
}
@ -152,14 +123,22 @@ export const ValidateMixin = dedupeMixin(
constructor() {
super();
/** @type {string[]} */
this.hasFeedbackFor = [];
/** @type {string[]} */
this.shouldShowFeedbackFor = [];
/** @type {string[]} */
this.showsFeedbackFor = [];
/** @type {Object.<string, Object.<string, boolean>>} */
this.validationStates = {};
this._visibleMessagesAmount = 1;
this.isPending = false;
/** @type {Validator[]} */
this.validators = [];
/** @type {Validator[]} */
@ -191,12 +170,19 @@ export const ValidateMixin = dedupeMixin(
localize.removeEventListener('localeChanged', this._updateFeedbackComponent);
}
/**
* @param {import('lit-element').PropertyValues} changedProperties
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__validateInitialized = true;
this.validate();
}
/**
* @param {string} name
* @param {?} oldValue
*/
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
if (name === 'validators') {
@ -215,7 +201,7 @@ export const ValidateMixin = dedupeMixin(
// This can't be reflected asynchronously in Safari
// Screen reader output should be in sync with visibility of error messages
if (this._inputNode) {
this._inputNode.setAttribute('aria-invalid', this._hasFeedbackVisibleFor('error'));
this._inputNode.setAttribute('aria-invalid', `${this._hasFeedbackVisibleFor('error')}`);
// this._inputNode.setCustomValidity(this._validationMessage || '');
}
@ -261,6 +247,8 @@ export const ValidateMixin = dedupeMixin(
*
* Situations A.2 and A.3 are not mutually exclusive and can be triggered within one validate()
* call. Situation B will occur after every call.
*
* @param {{ clearCurrentResult?: boolean }} [opts]
*/
async validate({ clearCurrentResult } = {}) {
if (this.disabled) {
@ -300,7 +288,7 @@ export const ValidateMixin = dedupeMixin(
const value =
this.modelValue instanceof Unparseable ? this.modelValue.viewValue : this.modelValue;
/** @type {Validator} */
/** @type {Validator | undefined} */
const requiredValidator = this._allValidators.find(v => v instanceof Required);
/**
@ -327,12 +315,14 @@ export const ValidateMixin = dedupeMixin(
const /** @type {Validator[]} */ filteredValidators = this._allValidators.filter(
v => !(v instanceof ResultValidator) && !(v instanceof Required),
);
const /** @type {Validator[]} */ syncValidators = filteredValidators.filter(
v => !v.constructor.async,
);
const /** @type {Validator[]} */ asyncValidators = filteredValidators.filter(
v => v.constructor.async,
);
const /** @type {Validator[]} */ syncValidators = filteredValidators.filter(v => {
const vCtor = /** @type {typeof Validator} */ (v.constructor);
return !vCtor.async;
});
const /** @type {Validator[]} */ asyncValidators = filteredValidators.filter(v => {
const vCtor = /** @type {typeof Validator} */ (v.constructor);
return vCtor.async;
});
/**
* 2. Synchronous validators
@ -350,6 +340,8 @@ export const ValidateMixin = dedupeMixin(
/**
* @desc step A2, calls __finishValidation
* @param {Validator[]} syncValidators
* @param {unknown} value
* @param {{ hasAsync: boolean }} opts
*/
__executeSyncValidators(syncValidators, value, { hasAsync }) {
if (syncValidators.length) {
@ -362,14 +354,13 @@ export const ValidateMixin = dedupeMixin(
/**
* @desc step A3, calls __finishValidation
* @param {Validator[]} filteredValidators all Validators except required and ResultValidators
* @param {Validator[]} asyncValidators all Validators except required and ResultValidators
* @param {?} value
*/
async __executeAsyncValidators(asyncValidators, value) {
if (asyncValidators.length) {
this.isPending = true;
const resultPromises = asyncValidators.map(v =>
v.execute(value, v.param, { node: this }),
);
const resultPromises = asyncValidators.map(v => v.execute(value, v.param, { node: this }));
const booleanResults = await Promise.all(resultPromises);
this.__asyncValidationResult = booleanResults
.map((r, i) => asyncValidators[i]) // Create an array of Validators
@ -384,10 +375,10 @@ export const ValidateMixin = dedupeMixin(
* @param {Validator[]} regularValidationResult result of steps 1-3
*/
__executeResultValidators(regularValidationResult) {
/** @type {ResultValidator[]} */
const resultValidators = this._allValidators.filter(
v => !v.constructor.async && v instanceof ResultValidator,
);
const resultValidators = /** @type {ResultValidator[]} */ (this._allValidators.filter(v => {
const vCtor = /** @type {typeof Validator} */ (v.constructor);
return !vCtor.async && v instanceof ResultValidator;
}));
return resultValidators.filter(v =>
v.executeOnResults({
@ -404,18 +395,18 @@ export const ValidateMixin = dedupeMixin(
* If not, we have nothing left to wait for.
*/
__finishValidation({ source, hasAsync }) {
const /** @type {Validator[]} */ syncAndAsyncOutcome = [
...this.__syncValidationResult,
...this.__asyncValidationResult,
];
const syncAndAsyncOutcome = [...this.__syncValidationResult, ...this.__asyncValidationResult];
// if we have any ResultValidators left, now is the time to run them...
const resultOutCome = this.__executeResultValidators(syncAndAsyncOutcome);
/** @typedef {Validator[]} TotalValidationResult */
this.__validationResult = [...resultOutCome, ...syncAndAsyncOutcome];
// this._storeResultsOnInstance(this.__validationResult);
const validationStates = this.constructor.validationTypes.reduce(
const ctor = /** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (this
.constructor);
/** @type {Object.<string, Object.<string, boolean>>} */
const validationStates = ctor.validationTypes.reduce(
(acc, type) => ({ ...acc, [type]: {} }),
{},
);
@ -423,7 +414,8 @@ export const ValidateMixin = dedupeMixin(
if (!validationStates[v.type]) {
validationStates[v.type] = {};
}
validationStates[v.type][v.constructor.validatorName] = true;
const vCtor = /** @type {typeof Validator} */ (v.constructor);
validationStates[v.type][vCtor.validatorName] = true;
});
this.validationStates = validationStates;
@ -432,15 +424,20 @@ export const ValidateMixin = dedupeMixin(
/** private event that should be listened to by LionFieldSet */
this.dispatchEvent(new Event('validate-performed', { bubbles: true }));
if (source === 'async' || !hasAsync) {
if (this.__validateCompleteResolve) {
this.__validateCompleteResolve();
}
}
}
__clearValidationResults() {
this.__syncValidationResult = [];
this.__asyncValidationResult = [];
}
/**
* @param {Event|CustomEvent} e
*/
__onValidatorUpdated(e) {
if (e.type === 'param-changed' || e.type === 'config-changed') {
this.validate();
@ -451,7 +448,11 @@ export const ValidateMixin = dedupeMixin(
const events = ['param-changed', 'config-changed'];
if (this.__prevValidators) {
this.__prevValidators.forEach(v => {
events.forEach(e => v.removeEventListener(e, this.__onValidatorUpdated));
events.forEach(e => {
if (v.removeEventListener) {
v.removeEventListener(e, this.__onValidatorUpdated);
}
});
v.onFormControlDisconnect(this);
});
}
@ -464,31 +465,35 @@ export const ValidateMixin = dedupeMixin(
console.error(errorMessage, this);
throw new Error(errorMessage);
}
if (this.constructor.validationTypes.indexOf(v.type) === -1) {
const ctor = /** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (this
.constructor);
if (ctor.validationTypes.indexOf(v.type) === -1) {
const vCtor = /** @type {typeof Validator} */ (v.constructor);
// throws in constructor are not visible to end user so we do both
const errorMessage = `This component does not support the validator type "${v.type}" used in "${v.constructor.validatorName}". You may change your validators type or add it to the components "static get validationTypes() {}".`;
const errorMessage = `This component does not support the validator type "${v.type}" used in "${vCtor.validatorName}". You may change your validators type or add it to the components "static get validationTypes() {}".`;
// eslint-disable-next-line no-console
console.error(errorMessage, this);
throw new Error(errorMessage);
}
events.forEach(e => v.addEventListener(e, this.__onValidatorUpdated));
events.forEach(e => {
if (v.addEventListener) {
v.addEventListener(e, this.__onValidatorUpdated);
}
});
v.onFormControlConnect(this);
});
this.__prevValidators = this._allValidators;
}
static _hasObjectChanged(result, prevResult) {
return JSON.stringify(result) !== JSON.stringify(prevResult);
}
/**
* @param {?} v
*/
__isEmpty(v) {
if (typeof this._isEmpty === 'function') {
return this._isEmpty(v);
}
return (
this.modelValue === null ||
typeof this.modelValue === 'undefined' ||
this.modelValue === ''
this.modelValue === null || typeof this.modelValue === 'undefined' || this.modelValue === ''
);
}
@ -498,7 +503,7 @@ export const ValidateMixin = dedupeMixin(
/**
* @typedef {object} FeedbackMessage
* @property {string} message this
* @property {string | Node} message this
* @property {string} type will be 'error' for messages from default Validators. Could be
* 'warning', 'info' etc. for Validators with custom types. Needed as a directive for
* feedbackNode how to render a message of a certain type
@ -510,7 +515,7 @@ export const ValidateMixin = dedupeMixin(
/**
* @param {Validator[]} validators list of objects having a .getMessage method
* @return {FeedbackMessage[]}
* @return {Promise.<FeedbackMessage[]>}
*/
async __getFeedbackMessages(validators) {
let fieldName = await this.fieldName;
@ -544,7 +549,8 @@ export const ValidateMixin = dedupeMixin(
* - we set aria-invalid="true" in case hasErrorVisible is true
*/
_updateFeedbackComponent() {
if (!this._feedbackNode) {
const { _feedbackNode } = this;
if (!_feedbackNode) {
return;
}
@ -560,37 +566,28 @@ export const ValidateMixin = dedupeMixin(
});
const messageMap = await this.__getFeedbackMessages(this.__prioritizedResult);
this._feedbackNode.feedbackData = messageMap.length ? messageMap : [];
_feedbackNode.feedbackData = messageMap.length ? messageMap : [];
});
} else {
this.__feedbackQueue.add(async () => {
this._feedbackNode.feedbackData = [];
_feedbackNode.feedbackData = [];
});
}
this.feedbackComplete = this.__feedbackQueue.complete;
}
/**
* Show the validity feedback when one of the following conditions is met:
*
* - submitted
* If the form is submitted, always show the error message.
*
* - prefilled
* the user already filled in something, or the value is prefilled
* when the form is initially rendered.
*
* - touched && dirty
* When a user starts typing for the first time in a field with for instance `required`
* validation, error message should not be shown until a field becomes `touched`
* (a user leaves(blurs) a field).
* When a user enters a field without altering the value(making it `dirty`),
* an error message shouldn't be shown either.
* Show the validity feedback when returning true, don't show when false
* @param {string} type
*/
_showFeedbackConditionFor(/* type */) {
return (this.touched && this.dirty) || this.prefilled || this.submitted;
// eslint-disable-next-line no-unused-vars
_showFeedbackConditionFor(type) {
return true;
}
/**
* @param {string} type
*/
_hasFeedbackVisibleFor(type) {
return (
this.hasFeedbackFor &&
@ -600,6 +597,7 @@ export const ValidateMixin = dedupeMixin(
);
}
/** @param {import('lit-element').PropertyValues} changedProperties */
updated(changedProperties) {
super.updated(changedProperties);
@ -607,31 +605,41 @@ export const ValidateMixin = dedupeMixin(
changedProperties.has('shouldShowFeedbackFor') ||
changedProperties.has('hasFeedbackFor')
) {
this.showsFeedbackFor = this.constructor.validationTypes
const ctor = /** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (this
.constructor);
// Necessary typecast because types aren't smart enough to understand that we filter out undefined
this.showsFeedbackFor = /** @type {string[]} */ (ctor.validationTypes
.map(type => (this._hasFeedbackVisibleFor(type) ? type : undefined))
.filter(_ => !!_);
.filter(_ => !!_));
this._updateFeedbackComponent();
}
}
_updateShouldShowFeedbackFor() {
this.shouldShowFeedbackFor = this.constructor.validationTypes
const ctor = /** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (this
.constructor);
// Necessary typecast because types aren't smart enough to understand that we filter out undefined
this.shouldShowFeedbackFor = /** @type {string[]} */ (ctor.validationTypes
.map(type => (this._showFeedbackConditionFor(type) ? type : undefined))
.filter(_ => !!_);
.filter(_ => !!_));
}
/**
* @overridable
* @desc Orders all active validators in this.__validationResult. Can
* also filter out occurrences (based on interaction states)
* @returns {Validator[]} ordered list of Validators with feedback messages visible to the
* @param {{ validationResult: Validator[] }} opts
* @return {Validator[]} ordered list of Validators with feedback messages visible to the
* end user
*/
_prioritizeAndFilterFeedback({ validationResult }) {
const types = this.constructor.validationTypes;
const ctor = /** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (this
.constructor);
const types = ctor.validationTypes;
// Sort all validators based on the type provided.
const res = validationResult.sort((a, b) => types.indexOf(a.type) - types.indexOf(b.type));
return res.slice(0, this._visibleMessagesAmount);
}
},
);
};
export const ValidateMixin = dedupeMixin(ValidateMixinImplementation);

View file

@ -1,10 +1,16 @@
import { fakeExtendsEventTarget } from '../utils/fakeExtendsEventTarget.js';
export class Validator {
/**
*
* @param {?} [param]
* @param {Object.<string,?>} [config]
*/
constructor(param, config) {
fakeExtendsEventTarget(this);
this.__fakeExtendsEventTarget();
/** @type {?} */
this.__param = param;
/** @type {Object.<string,?>} */
this.__config = config || {};
this.type = (config && config.type) || 'error'; // Default type supported by ValidateMixin
}
@ -19,22 +25,28 @@ export class Validator {
/**
* @desc The function that returns a Boolean
* @param {string|Date|Number|object} modelValue
* @param {object} param
* @param {?} [modelValue]
* @param {?} [param]
* @param {{}} [config]
* @returns {Boolean|Promise<Boolean>}
*/
execute(/* modelValue, param */) {
if (!this.validatorName) {
// eslint-disable-next-line no-unused-vars, class-methods-use-this
execute(modelValue, param, config) {
const ctor = /** @type {typeof Validator} */ (this.constructor);
if (!ctor.validatorName) {
throw new Error(
'A validator needs to have a name! Please set it via "static get validatorName() { return \'IsCat\'; }"',
);
}
return true;
}
set param(p) {
this.__param = p;
if (this.dispatchEvent) {
this.dispatchEvent(new Event('param-changed'));
}
}
get param() {
return this.__param;
@ -42,8 +54,10 @@ export class Validator {
set config(c) {
this.__config = c;
if (this.dispatchEvent) {
this.dispatchEvent(new Event('config-changed'));
}
}
get config() {
return this.__config;
@ -51,16 +65,18 @@ export class Validator {
/**
* @overridable
* @param {object} data
* @param {*} data.modelValue
* @param {string} data.fieldName
* @param {*} data.params
* @param {string} data.type
* @returns {string|Node|Promise<stringOrNode>|() => stringOrNode)}
* @param {object} [data]
* @param {*} [data.modelValue]
* @param {string} [data.fieldName]
* @param {HTMLElement} [data.formControl]
* @param {*} [data.params]
* @param {string|undefined} [data.type]
* @returns {Promise<string|Node>}
*/
async _getMessage(data) {
const ctor = /** @type {typeof Validator} */ (this.constructor);
const composedData = {
name: this.constructor.validatorName,
name: ctor.validatorName,
type: this.type,
params: this.param,
config: this.config,
@ -75,29 +91,32 @@ export class Validator {
.config.getMessage}`,
);
}
return this.constructor.getMessage(composedData);
return ctor.getMessage(composedData);
}
/**
* @overridable
* @param {object} data
* @param {*} data.modelValue
* @param {string} data.fieldName
* @param {*} data.params
* @param {string} data.type
* @returns {string|Node|Promise<stringOrNode>|() => stringOrNode)}
* @param {object} [data]
* @param {*} [data.modelValue]
* @param {string} [data.fieldName]
* @param {*} [data.params]
* @param {string} [data.type]
* @param {Object.<string,?>} [data.config]
* @param {string} [data.name]
* @returns {Promise<string|Node>}
*/
static async getMessage(/* data */) {
// eslint-disable-next-line no-unused-vars
static async getMessage(data) {
return `Please configure an error message for "${this.name}" by overriding "static async getMessage()"`;
}
/**
* @param {FormControl} formControl
* @param {HTMLElement} formControl
*/
onFormControlConnect(formControl) {} // eslint-disable-line
/**
* @param {FormControl} formControl
* @param {HTMLElement} formControl
*/
onFormControlDisconnect(formControl) {} // eslint-disable-line
@ -111,6 +130,38 @@ export class Validator {
* - Or, when a webworker was started, its process could be aborted and then restarted.
*/
abortExecution() {} // eslint-disable-line
__fakeExtendsEventTarget() {
const delegate = document.createDocumentFragment();
/**
*
* @param {string} type
* @param {EventListener} listener
* @param {Object} [opts]
*/
const delegatedAddEventListener = (type, listener, opts) =>
delegate.addEventListener(type, listener, opts);
/**
* @param {string} type
* @param {EventListener} listener
* @param {Object} [opts]
*/
const delegatedRemoveEventListener = (type, listener, opts) =>
delegate.removeEventListener(type, listener, opts);
/**
* @param {Event|CustomEvent} event
*/
const delegatedDispatchEvent = event => delegate.dispatchEvent(event);
this.addEventListener = delegatedAddEventListener;
this.removeEventListener = delegatedRemoveEventListener;
this.dispatchEvent = delegatedDispatchEvent;
}
}
// For simplicity, a default validator only handles one state:

View file

@ -1,14 +1,29 @@
import { ResultValidator } from '../ResultValidator.js';
/**
* @typedef {import('../Validator').Validator} Validator
*/
export class DefaultSuccess extends ResultValidator {
/**
* @param {...any} args
*/
constructor(...args) {
super(...args);
this.type = 'success';
}
/**
*
* @param {Object} context
* @param {Validator[]} context.regularValidationResult
* @param {Validator[]} context.prevValidationResult
* @returns {boolean}
*/
// eslint-disable-next-line class-methods-use-this
executeOnResults({ regularValidationResult, prevValidationResult }) {
const errorOrWarning = v => v.type === 'error' || v.type === 'warning';
const errorOrWarning = /** @param {Validator} v */ v =>
v.type === 'error' || v.type === 'warning';
const hasErrorOrWarning = !!regularValidationResult.filter(errorOrWarning).length;
const prevHadErrorOrWarning = !!prevValidationResult.filter(errorOrWarning).length;
return !hasErrorOrWarning && prevHadErrorOrWarning;

View file

@ -3,6 +3,9 @@
import { normalizeDateTime } from '@lion/localize';
import { Validator } from '../Validator.js';
/**
* @param {?} value
*/
function isDate(value) {
return (
Object.prototype.toString.call(value) === '[object Date]' && !Number.isNaN(value.getTime())
@ -14,6 +17,9 @@ export class IsDate extends Validator {
return 'IsDate';
}
/**
* @param {?} value
*/
// eslint-disable-next-line class-methods-use-this
execute(value) {
let hasError = false;
@ -29,6 +35,9 @@ export class MinDate extends Validator {
return 'MinDate';
}
/**
* @param {?} value
*/
execute(value, min = this.param) {
let hasError = false;
if (!isDate(value) || value < normalizeDateTime(min)) {
@ -43,6 +52,9 @@ export class MaxDate extends Validator {
return 'MaxDate';
}
/**
* @param {?} value
*/
execute(value, max = this.param) {
let hasError = false;
if (!isDate(value) || value > normalizeDateTime(max)) {
@ -57,6 +69,9 @@ export class MinMaxDate extends Validator {
return 'MinMaxDate';
}
/**
* @param {?} value
*/
execute(value, { min = 0, max = 0 } = this.param) {
let hasError = false;
if (!isDate(value) || value < normalizeDateTime(min) || value > normalizeDateTime(max)) {
@ -71,6 +86,9 @@ export class IsDateDisabled extends Validator {
return 'IsDateDisabled';
}
/**
* @param {?} value
*/
execute(value, isDisabledFn = this.param) {
let hasError = false;
if (!isDate(value) || isDisabledFn(value)) {

View file

@ -15,6 +15,9 @@ export class IsNumber extends Validator {
return 'IsNumber';
}
/**
* @param {?} value
*/
// eslint-disable-next-line class-methods-use-this
execute(value) {
let isEnabled = false;
@ -30,6 +33,9 @@ export class MinNumber extends Validator {
return 'MinNumber';
}
/**
* @param {?} value
*/
execute(value, min = this.param) {
let isEnabled = false;
if (!isNumber(value) || value < min) {
@ -44,6 +50,9 @@ export class MaxNumber extends Validator {
return 'MaxNumber';
}
/**
* @param {?} value
*/
execute(value, max = this.param) {
let isEnabled = false;
if (!isNumber(value) || value > max) {
@ -58,6 +67,9 @@ export class MinMaxNumber extends Validator {
return 'MinMaxNumber';
}
/**
* @param {?} value
*/
execute(value, { min = 0, max = 0 } = this.param) {
let isEnabled = false;
if (!isNumber(value) || value < min || value > max) {

View file

@ -1,5 +1,9 @@
import { Validator } from '../Validator.js';
/**
* @typedef {import('../../../types/FormControlMixinTypes.js').FormControlHost} FormControlHost
*/
export class Required extends Validator {
static get validatorName() {
return 'Required';
@ -11,6 +15,9 @@ export class Required extends Validator {
* FormControl.__isEmpty / FormControl._isEmpty.
*/
/**
* @param {FormControlHost & HTMLElement} formControl
*/
// eslint-disable-next-line class-methods-use-this
onFormControlConnect(formControl) {
if (formControl._inputNode) {
@ -18,6 +25,9 @@ export class Required extends Validator {
}
}
/**
* @param {FormControlHost & HTMLElement} formControl
*/
// eslint-disable-next-line class-methods-use-this
onFormControlDisconnect(formControl) {
if (formControl._inputNode) {

View file

@ -1,6 +1,9 @@
/* eslint-disable max-classes-per-file */
import { Validator } from '../Validator.js';
/**
* @param {?} value
*/
const isString = value => typeof value === 'string';
export class IsString extends Validator {
@ -8,6 +11,9 @@ export class IsString extends Validator {
return 'IsString';
}
/**
* @param {?} value
*/
// eslint-disable-next-line class-methods-use-this
execute(value) {
let hasError = false;
@ -23,6 +29,9 @@ export class EqualsLength extends Validator {
return 'EqualsLength';
}
/**
* @param {?} value
*/
execute(value, length = this.param) {
let hasError = false;
if (!isString(value) || value.length !== length) {
@ -37,6 +46,9 @@ export class MinLength extends Validator {
return 'MinLength';
}
/**
* @param {?} value
*/
execute(value, min = this.param) {
let hasError = false;
if (!isString(value) || value.length < min) {
@ -51,6 +63,9 @@ export class MaxLength extends Validator {
return 'MaxLength';
}
/**
* @param {?} value
*/
execute(value, max = this.param) {
let hasError = false;
if (!isString(value) || value.length > max) {
@ -65,6 +80,9 @@ export class MinMaxLength extends Validator {
return 'MinMaxLength';
}
/**
* @param {?} value
*/
execute(value, { min = 0, max = 0 } = this.param) {
let hasError = false;
if (!isString(value) || value.length < min || value.length > max) {
@ -80,6 +98,9 @@ export class IsEmail extends Validator {
return 'IsEmail';
}
/**
* @param {?} value
*/
// eslint-disable-next-line class-methods-use-this
execute(value) {
let hasError = false;
@ -90,12 +111,19 @@ export class IsEmail extends Validator {
}
}
/**
* @param {?} value
* @param {RegExp} pattern
*/
const hasPattern = (value, pattern) => pattern.test(value);
export class Pattern extends Validator {
static get validatorName() {
return 'Pattern';
}
/**
* @param {?} value
*/
// eslint-disable-next-line class-methods-use-this
execute(value, pattern = this.param) {
if (!(pattern instanceof RegExp)) {

View file

@ -17,6 +17,9 @@ export class AlwaysValid extends Validator {
return 'AlwaysValid';
}
/**
* @return {Promise<boolean> | boolean}
*/
execute() {
const showMessage = false;
return showMessage;
@ -28,7 +31,10 @@ export class AsyncAlwaysValid extends AlwaysValid {
return true;
}
execute() {
/**
* @return {Promise<boolean>}
*/
async execute() {
return true;
}
}
@ -38,6 +44,9 @@ export class AsyncAlwaysInvalid extends AlwaysValid {
return true;
}
/**
* @return {Promise<boolean>}
*/
async execute() {
return false;
}

View file

@ -6,28 +6,35 @@ import { FormRegistrarPortalMixin } from '../src/registration/FormRegistrarPorta
/**
* @typedef {import('../types/registration/FormRegistrarMixinTypes').FormRegistrarHost} FormRegistrarHost
* @typedef {import('../types/registration/FormRegistrarMixinTypes').FormRegistrarMixin} FormRegistrarMixin
*/
/**
*
* @param {Object} customConfig
* @param {string} customConfig.suffix
* @param {string} customConfig.parentTagString
* @param {string} customConfig.childTagString
* @param {string} customConfig.portalTagString
* @typedef {Object} customConfig
* @property {typeof HTMLElement | typeof import('@lion/core').UpdatingElement | typeof LitElement} [baseElement]
* @property {string} [customConfig.suffix]
* @property {string} [customConfig.parentTagString]
* @property {string} [customConfig.childTagString]
* @property {string} [customConfig.portalTagString]
*/
/**
* @param {customConfig} customConfig
*/
export const runRegistrationSuite = customConfig => {
const cfg = {
// @ts-expect-error https://github.com/microsoft/TypeScript/issues/38535
baseElement: HTMLElement,
...customConfig,
};
describe(`FormRegistrationMixins ${cfg.suffix}`, () => {
// @ts-expect-error base constructors same return type & type cannot be assigned like this
class RegistrarClass extends FormRegistrarMixin(cfg.baseElement) {}
cfg.parentTagString = defineCE(RegistrarClass);
// @ts-expect-error base constructors same return type & type cannot be assigned like this
class RegisteringClass extends FormRegisteringMixin(cfg.baseElement) {}
cfg.childTagString = defineCE(RegisteringClass);
// @ts-expect-error base constructors same return type & type cannot be assigned like this
class PortalClass extends FormRegistrarPortalMixin(cfg.baseElement) {}
cfg.portalTagString = defineCE(PortalClass);
@ -84,6 +91,7 @@ export const runRegistrationSuite = customConfig => {
});
it('works for components that have a delayed render', async () => {
// @ts-expect-error base constructors same return type
class PerformUpdate extends FormRegistrarMixin(LitElement) {
async performUpdate() {
await new Promise(resolve => setTimeout(() => resolve(), 10));

View file

@ -2,17 +2,18 @@ import { LitElement } from '@lion/core';
import { aTimeout, defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
import sinon from 'sinon';
import { FormatMixin } from '../src/FormatMixin.js';
// FIXME: revert once validate is typed
// import { Unparseable, Validator } from '../index.js';
import { Unparseable, Validator } from '../index.js';
/**
* @typedef {import('../types/FormatMixinTypes').FormatHost} FormatHost
* @typedef {{ _inputNode: HTMLElement }} inputNodeHost
* @typedef {{ errorState: boolean, hasFeedbackFor: string[], validators: ?[] }} validateHost // FIXME: replace with ValidateMixinHost once typed
* @typedef {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
*/
// @ts-expect-error base constructor same return type
class FormatClass extends FormatMixin(LitElement) {
get _inputNode() {
return /** @type {HTMLInputElement} */ (super._inputNode); // casts type
}
render() {
return html`<slot name="input"></slot>`;
}
@ -29,15 +30,10 @@ class FormatClass extends FormatMixin(LitElement) {
}
return '';
}
get _inputNode() {
return this.querySelector('input');
}
}
/**
*
* @param {FormatClass & inputNodeHost} formControl
* @param {FormatClass} formControl
* @param {?} newViewValue
*/
function mimicUserInput(formControl, newViewValue) {
@ -46,7 +42,7 @@ function mimicUserInput(formControl, newViewValue) {
}
/**
* @param {{tagString?: string, modelValueType: modelValueType}} [customConfig]
* @param {{tagString?: string, modelValueType?: modelValueType}} [customConfig]
*/
export function runFormatMixinSuite(customConfig) {
const cfg = {
@ -95,7 +91,7 @@ export function runFormatMixinSuite(customConfig) {
let elem;
/** @type {FormatClass} */
let nonFormat;
/** @type {FormatClass & inputNodeHost} */
/** @type {FormatClass} */
let fooFormat;
before(async () => {
@ -128,7 +124,7 @@ export function runFormatMixinSuite(customConfig) {
});
it('fires `model-value-changed` for every change on the input', async () => {
const formatEl = /** @type {FormatClass & inputNodeHost} */ (await fixture(
const formatEl = /** @type {FormatClass} */ (await fixture(
html`<${elem}><input slot="input"></${elem}>`,
));
@ -215,7 +211,7 @@ export function runFormatMixinSuite(customConfig) {
it('synchronizes _inputNode.value as a fallback mechanism', async () => {
// Note that in lion-field, the attribute would be put on <lion-field>, not on <input>
const formatElem = /** @type {FormatClass & inputNodeHost} */ (await fixture(html`
const formatElem = /** @type {FormatClass} */ (await fixture(html`
<${elem}
value="string"
.formatter=${/** @param {string} value */ value => `foo: ${value}`}
@ -237,7 +233,7 @@ export function runFormatMixinSuite(customConfig) {
});
it('reflects back formatted value to user on leave', async () => {
const formatEl = /** @type {FormatClass & inputNodeHost} */ (await fixture(html`
const formatEl = /** @type {FormatClass} */ (await fixture(html`
<${elem} .formatter="${/** @param {string} value */ value => `foo: ${value}`}">
<input slot="input" />
</${elem}>
@ -255,14 +251,14 @@ export function runFormatMixinSuite(customConfig) {
});
it('reflects back .formattedValue immediately when .modelValue changed imperatively', async () => {
const el = /** @type {FormatClass & inputNodeHost & validateHost} */ (await fixture(html`
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem} .formatter="${/** @param {string} value */ value => `foo: ${value}`}">
<input slot="input" />
</${elem}>
`));
// The FormatMixin can be used in conjunction with the ValidateMixin, in which case
// it can hold errorState (affecting the formatting)
el.errorState = true;
el.hasFeedbackFor = ['error'];
// users types value 'test'
mimicUserInput(el, 'test');
@ -274,6 +270,7 @@ export function runFormatMixinSuite(customConfig) {
});
it('works if there is no underlying _inputNode', async () => {
// @ts-expect-error base constructor same return type
const tagNoInputString = defineCE(class extends FormatMixin(LitElement) {});
const tagNoInput = unsafeStatic(tagNoInputString);
expect(async () => {
@ -300,7 +297,9 @@ export function runFormatMixinSuite(customConfig) {
it('should have formatOptions available in formatter', async () => {
const formatterSpy = sinon.spy(value => `foo: ${value}`);
const generatedViewValue = generateValueBasedOnType({ viewValue: true });
const generatedViewValue = /** @type {string} */ (generateValueBasedOnType({
viewValue: true,
}));
await fixture(html`
<${elem} value="${generatedViewValue}" .formatter="${formatterSpy}"
.formatOptions="${{ locale: 'en-GB', decimalSeparator: '-' }}">
@ -319,7 +318,7 @@ export function runFormatMixinSuite(customConfig) {
/** @type {?} */
const generatedValue = generateValueBasedOnType();
const parserSpy = sinon.spy();
const el = /** @type {FormatClass & inputNodeHost} */ (await fixture(html`
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem} .parser="${parserSpy}">
<input slot="input" value="${generatedValue}">
</${elem}>
@ -335,7 +334,7 @@ export function runFormatMixinSuite(customConfig) {
});
it('will not return Unparseable when empty strings are inputted', async () => {
const el = /** @type {FormatClass & inputNodeHost} */ (await fixture(html`
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem}>
<input slot="input" value="string">
</${elem}>
@ -359,7 +358,7 @@ export function runFormatMixinSuite(customConfig) {
toggleValue: true,
});
const el = /** @type {FormatClass & inputNodeHost & validateHost} */ (await fixture(html`
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem} .formatter=${formatterSpy}>
<input slot="input" value="${generatedViewValue}">
</${elem}>
@ -371,7 +370,7 @@ export function runFormatMixinSuite(customConfig) {
// Setting hasError = true is not enough if the element has errorValidators (uses ValidateMixin)
// that set hasError back to false when the user input is mimicked.
/* const AlwaysInvalid = class extends Validator {
const AlwaysInvalid = class extends Validator {
static get validatorName() {
return 'AlwaysInvalid';
}
@ -379,9 +378,9 @@ export function runFormatMixinSuite(customConfig) {
execute() {
return true;
}
}; */
// @ts-ignore // FIXME: remove this ignore after Validator class is typed
// el.validators = [new AlwaysInvalid()];
};
el.validators = [new AlwaysInvalid()];
mimicUserInput(el, generatedViewValueAlt);
expect(formatterSpy.callCount).to.equal(1);
@ -398,19 +397,21 @@ export function runFormatMixinSuite(customConfig) {
});
describe('Unparseable values', () => {
// it('should convert to Unparseable when wrong value inputted by user', async () => {
// const el = /** @type {FormatClass & inputNodeHost} */ (await fixture(html`
// <${elem} .parser=${viewValue => Number(viewValue) || undefined}
// >
// <input slot="input">
// </${elem}>
// `));
// mimicUserInput(el, 'test');
// expect(el.modelValue).to.be.an.instanceof(Unparseable);
// });
it('should convert to Unparseable when wrong value inputted by user', async () => {
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem} .parser=${
/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined
}
>
<input slot="input">
</${elem}>
`));
mimicUserInput(el, 'test');
expect(el.modelValue).to.be.an.instanceof(Unparseable);
});
it('should preserve the viewValue when not parseable', async () => {
const el = /** @type {FormatClass & inputNodeHost} */ (await fixture(html`
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem}
.parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined}
>
@ -422,17 +423,17 @@ export function runFormatMixinSuite(customConfig) {
expect(el.value).to.equal('test');
});
// it('should display the viewValue when modelValue is of type Unparseable', async () => {
// const el = /** @type {FormatClass} */ (await fixture(html`
// <${elem}
// .parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined}
// >
// <input slot="input">
// </${elem}>
// `));
// el.modelValue = new Unparseable('foo');
// expect(el.value).to.equal('foo');
// });
it('should display the viewValue when modelValue is of type Unparseable', async () => {
const el = /** @type {FormatClass} */ (await fixture(html`
<${elem}
.parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined}
>
<input slot="input">
</${elem}>
`));
el.modelValue = new Unparseable('foo');
expect(el.value).to.equal('foo');
});
});
});
}

View file

@ -10,6 +10,8 @@ import {
} from '@open-wc/testing';
import sinon from 'sinon';
import { InteractionStateMixin } from '../src/InteractionStateMixin.js';
import { ValidateMixin } from '../src/validate/ValidateMixin.js';
import { MinLength } from '../src/validate/validators/StringValidators.js';
/**
* @param {{tagString?: string, allowedModelValueTypes?: Array.<ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor>}} [customConfig]
@ -22,7 +24,8 @@ export function runInteractionStateMixinSuite(customConfig) {
};
describe(`InteractionStateMixin`, async () => {
class IState extends InteractionStateMixin(LitElement) {
// @ts-expect-error base constructors same return type
class IState extends InteractionStateMixin(ValidateMixin(LitElement)) {
connectedCallback() {
super.connectedCallback();
this.tabIndex = 0;
@ -207,8 +210,41 @@ export function runInteractionStateMixinSuite(customConfig) {
expect(el.prefilled).to.be.true;
});
describe('Validation integration with states', () => {
it('has .shouldShowFeedbackFor indicating for which type to show messages', async () => {
const el = /** @type {IState} */ (await fixture(html`
<${tag}></${tag}>
`));
expect(el.shouldShowFeedbackFor).to.deep.equal([]);
el.submitted = true;
await el.updateComplete;
expect(el.shouldShowFeedbackFor).to.deep.equal(['error']);
});
it('keeps the feedback component in sync', async () => {
const el = /** @type {IState} */ (await fixture(html`
<${tag} .validators=${[new MinLength(3)]}></${tag}>
`));
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData).to.deep.equal([]);
// has error but does not show/forward to component as showCondition is not met
el.modelValue = '1';
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData).to.deep.equal([]);
el.submitted = true;
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData?.length).to.equal(1);
});
});
describe('SubClassers', () => {
it('can override the `_leaveEvent`', async () => {
// @ts-expect-error base constructor same return type
class IStateCustomBlur extends InteractionStateMixin(LitElement) {
constructor() {
super();

View file

@ -17,6 +17,9 @@ import {
AsyncAlwaysValid,
} from '../test-helpers.js';
/**
* @param {{tagString?: string | null, lightDom?: string}} [customConfig]
*/
export function runValidateMixinSuite(customConfig) {
const cfg = {
tagString: null,
@ -24,32 +27,19 @@ export function runValidateMixinSuite(customConfig) {
};
const lightDom = cfg.lightDom || '';
const tagString =
cfg.tagString ||
defineCE(
class extends ValidateMixin(LitElement) {
static get properties() {
return { modelValue: String };
}
},
);
const tag = unsafeStatic(tagString);
const withInputTagString =
cfg.tagString ||
defineCE(
class extends ValidateMixin(LitElement) {
// @ts-expect-error base constructor same return type
class ValidateElement extends ValidateMixin(LitElement) {
connectedCallback() {
super.connectedCallback();
this.appendChild(document.createElement('input'));
const inputNode = document.createElement('input');
inputNode.slot = 'input';
this.appendChild(inputNode);
}
}
get _inputNode() {
return this.querySelector('input');
}
},
);
const withInputTag = unsafeStatic(withInputTagString);
const tagString = cfg.tagString || defineCE(ValidateElement);
const tag = unsafeStatic(tagString);
describe('ValidateMixin', () => {
/**
@ -78,10 +68,11 @@ export function runValidateMixinSuite(customConfig) {
it('throws and console.errors if adding not Validator instances to the validators array', async () => {
// we throw and console error as constructor throw are not visible to the end user
const stub = sinon.stub(console, 'error');
const el = await fixture(html`<${tag}></${tag}>`);
const el = /** @type {ValidateElement} */ (await fixture(html`<${tag}></${tag}>`));
const errorMessage =
'Validators array only accepts class instances of Validator. Type "array" found.';
expect(() => {
// @ts-expect-error putting the wrong value on purpose
el.validators = [[new Required()]];
}).to.throw(errorMessage);
expect(stub.args[0][0]).to.equal(errorMessage);
@ -89,6 +80,7 @@ export function runValidateMixinSuite(customConfig) {
const errorMessage2 =
'Validators array only accepts class instances of Validator. Type "string" found.';
expect(() => {
// @ts-expect-error because we purposely put a wrong type
el.validators = ['required'];
}).to.throw(errorMessage2);
expect(stub.args[1][0]).to.equal(errorMessage2);
@ -110,7 +102,7 @@ export function runValidateMixinSuite(customConfig) {
return 'MajorValidator';
}
}
const el = await fixture(html`<${tag}></${tag}>`);
const el = /** @type {ValidateElement} */ (await fixture(html`<${tag}></${tag}>`));
expect(() => {
el.validators = [new MajorValidator()];
}).to.throw(errorMessage);
@ -120,21 +112,21 @@ export function runValidateMixinSuite(customConfig) {
});
it('validates on initialization (once form field has bootstrapped/initialized)', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[new Required()]}
>${lightDom}</${tag}>
`);
`));
expect(el.hasFeedbackFor).to.deep.equal(['error']);
});
it('revalidates when ".modelValue" changes', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[new AlwaysValid()]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`);
`));
const validateSpy = sinon.spy(el, 'validate');
el.modelValue = 'x';
@ -142,12 +134,12 @@ export function runValidateMixinSuite(customConfig) {
});
it('revalidates when ".validators" changes', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[new AlwaysValid()]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`);
`));
const validateSpy = sinon.spy(el, 'validate');
el.validators = [new MinLength(3)];
@ -155,12 +147,12 @@ export function runValidateMixinSuite(customConfig) {
});
it('clears current results when ".modelValue" changes', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[new AlwaysValid()]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`);
`));
const clearSpy = sinon.spy(el, '__clearValidationResults');
const validateSpy = sinon.spy(el, 'validate');
@ -180,9 +172,9 @@ export function runValidateMixinSuite(customConfig) {
it('firstly checks for empty values', async () => {
const alwaysValid = new AlwaysValid();
const alwaysValidExecuteSpy = sinon.spy(alwaysValid, 'execute');
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} .validators=${[alwaysValid]}>${lightDom}</${tag}>
`);
`));
const isEmptySpy = sinon.spy(el, '__isEmpty');
const validateSpy = sinon.spy(el, 'validate');
el.modelValue = '';
@ -197,9 +189,9 @@ export function runValidateMixinSuite(customConfig) {
});
it('secondly checks for synchronous Validators: creates RegularValidationResult', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} .validators=${[new AlwaysValid()]}>${lightDom}</${tag}>
`);
`));
const isEmptySpy = sinon.spy(el, '__isEmpty');
const syncSpy = sinon.spy(el, '__executeSyncValidators');
el.modelValue = 'nonEmpty';
@ -207,11 +199,11 @@ export function runValidateMixinSuite(customConfig) {
});
it('thirdly schedules asynchronous Validators: creates RegularValidationResult', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} .validators=${[new AlwaysValid(), new AsyncAlwaysValid()]}>
${lightDom}
</${tag}>
`);
`));
const syncSpy = sinon.spy(el, '__executeSyncValidators');
const asyncSpy = sinon.spy(el, '__executeAsyncValidators');
el.modelValue = 'nonEmpty';
@ -225,12 +217,12 @@ export function runValidateMixinSuite(customConfig) {
}
}
let el = await fixture(html`
let el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[new AlwaysValid(), new MyResult()]}>
${lightDom}
</${tag}>
`);
`));
const syncSpy = sinon.spy(el, '__executeSyncValidators');
const resultSpy2 = sinon.spy(el, '__executeResultValidators');
@ -257,11 +249,11 @@ export function runValidateMixinSuite(customConfig) {
describe('Finalization', () => {
it('fires private "validate-performed" event on every cycle', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} .validators=${[new AlwaysValid(), new AsyncAlwaysInvalid()]}>
${lightDom}
</${tag}>
`);
`));
const cbSpy = sinon.spy();
el.addEventListener('validate-performed', cbSpy);
el.modelValue = 'nonEmpty';
@ -269,11 +261,11 @@ export function runValidateMixinSuite(customConfig) {
});
it('resolves ".validateComplete" Promise', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} .validators=${[new AsyncAlwaysInvalid()]}>
${lightDom}
</${tag}>
`);
`));
el.modelValue = 'nonEmpty';
const validateResolveSpy = sinon.spy(el, '__validateCompleteResolve');
await el.validateComplete;
@ -284,8 +276,16 @@ export function runValidateMixinSuite(customConfig) {
describe('Validator Integration', () => {
class IsCat extends Validator {
/**
* @param {...any} args
*/
constructor(...args) {
super(...args);
/**
*
* @param {string} modelValue
* @param {{number: number}} [param]
*/
this.execute = (modelValue, param) => {
const validateString = param && param.number ? `cat${param.number}` : 'cat';
const showError = modelValue !== validateString;
@ -299,6 +299,9 @@ export function runValidateMixinSuite(customConfig) {
}
class OtherValidator extends Validator {
/**
* @param {...any} args
*/
constructor(...args) {
super(...args);
this.execute = () => true;
@ -318,6 +321,7 @@ export function runValidateMixinSuite(customConfig) {
.modelValue=${'model'}
>${lightDom}</${tag}>
`);
// @ts-expect-error weird sinon type error..
expect(otherValidatorSpy.calledWith('model')).to.be.true;
});
@ -330,6 +334,7 @@ export function runValidateMixinSuite(customConfig) {
.modelValue=${new Unparseable('view')}
>${lightDom}</${tag}>
`);
// @ts-expect-error weird sinon type error..
expect(otherValidatorSpy.calledWith('view')).to.be.true;
});
@ -355,13 +360,14 @@ export function runValidateMixinSuite(customConfig) {
.modelValue=${'cat'}
>${lightDom}</${tag}>
`);
// @ts-expect-error another sinon type problem
expect(executeSpy.args[0][2].node).to.equal(el);
});
it('Validators will not be called on empty values', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} .validators=${[new IsCat()]}>${lightDom}</${tag}>
`);
`));
el.modelValue = 'cat';
expect(el.validationStates.error.IsCat).to.be.undefined;
@ -374,12 +380,12 @@ export function runValidateMixinSuite(customConfig) {
it('Validators get retriggered on parameter change', async () => {
const isCatValidator = new IsCat('Felix');
const catSpy = sinon.spy(isCatValidator, 'execute');
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[isCatValidator]}
.modelValue=${'cat'}
>${lightDom}</${tag}>
`);
`));
el.modelValue = 'cat';
expect(catSpy.callCount).to.equal(1);
isCatValidator.param = 'Garfield';
@ -388,7 +394,9 @@ export function runValidateMixinSuite(customConfig) {
});
describe('Async Validator Integration', () => {
/** @type {Promise<any>} */
let asyncVPromise;
/** @type {function} */
let asyncVResolve;
beforeEach(() => {
@ -421,36 +429,36 @@ export function runValidateMixinSuite(customConfig) {
// default execution trigger is keyup (think of password availability backend)
// can configure execution trigger (blur, etc?)
it('handles "execute" functions returning promises', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.modelValue=${'dog'}
.validators=${[new IsAsyncCat()]}>
${lightDom}
</${tag}>
`);
`));
const validator = el.validators[0];
expect(validator instanceof Validator).to.be.true;
expect(el.hasFeedbackFor).to.deep.equal([]);
asyncVResolve();
await aTimeout();
await aTimeout(0);
expect(el.hasFeedbackFor).to.deep.equal(['error']);
});
it('sets ".isPending/[is-pending]" when validation is in progress', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} .modelValue=${'dog'}>${lightDom}</${tag}>
`);
`));
expect(el.isPending).to.be.false;
expect(el.hasAttribute('is-pending')).to.be.false;
el.validators = [new IsAsyncCat()];
expect(el.isPending).to.be.true;
await aTimeout();
await aTimeout(0);
expect(el.hasAttribute('is-pending')).to.be.true;
asyncVResolve();
await aTimeout();
await aTimeout(0);
expect(el.isPending).to.be.false;
expect(el.hasAttribute('is-pending')).to.be.false;
});
@ -460,11 +468,11 @@ export function runValidateMixinSuite(customConfig) {
const asyncV = new IsAsyncCat();
const asyncVExecuteSpy = sinon.spy(asyncV, 'execute');
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} .modelValue=${'dog'}>
${lightDom}
</${tag}>
`);
`));
// debounce started
el.validators = [asyncV];
expect(asyncVExecuteSpy.called).to.equal(0);
@ -473,7 +481,7 @@ export function runValidateMixinSuite(customConfig) {
expect(asyncVExecuteSpy.called).to.equal(1);
// New validation cycle. Now change modelValue inbetween, so validation is retriggered.
asyncVExecuteSpy.reset();
asyncVExecuteSpy.resetHistory();
el.modelValue = 'dogger';
expect(asyncVExecuteSpy.called).to.equal(0);
el.modelValue = 'doggerer';
@ -488,13 +496,13 @@ export function runValidateMixinSuite(customConfig) {
it.skip('cancels and reschedules async validation on ".modelValue" change', async () => {
const asyncV = new IsAsyncCat();
const asyncVAbortSpy = sinon.spy(asyncV, 'abort');
const asyncVAbortSpy = sinon.spy(asyncV, 'abortExecution');
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} .modelValue=${'dog'}>
${lightDom}
</${tag}>
`);
`));
// debounce started
el.validators = [asyncV];
expect(asyncVAbortSpy.called).to.equal(0);
@ -508,16 +516,19 @@ export function runValidateMixinSuite(customConfig) {
const asyncV = new IsAsyncCat();
const asyncVExecuteSpy = sinon.spy(asyncV, 'execute');
const el = await fixture(html`
const el = /** @type {ValidateElement & { isFocused: boolean }} */ (await fixture(html`
<${tag}
.isFocused=${true}
.modelValue=${'dog'}
.validators=${[asyncV]}
.asyncValidateOn=${({ formControl }) => !formControl.isFocused}
.asyncValidateOn=${
/** @param {{ formControl: { isFocused: boolean } }} opts */ ({ formControl }) =>
!formControl.isFocused
}
>
${lightDom}
</${tag}>
`);
`));
expect(asyncVExecuteSpy.called).to.equal(0);
el.isFocused = false;
@ -527,35 +538,38 @@ export function runValidateMixinSuite(customConfig) {
});
describe('ResultValidator Integration', () => {
let MySuccessResultValidator;
let withSuccessTagString;
let withSuccessTag;
before(() => {
MySuccessResultValidator = class extends ResultValidator {
const MySuccessResultValidator = class extends ResultValidator {
/**
* @param {...any} args
*/
constructor(...args) {
super(...args);
this.type = 'success';
}
/**
*
* @param {{ regularValidationResult: Validator[], prevValidationResult: Validator[]}} param0
*/
// eslint-disable-next-line class-methods-use-this
executeOnResults({ regularValidationResult, prevValidationResult }) {
const errorOrWarning = v => v.type === 'error' || v.type === 'warning';
const errorOrWarning = /** @param {Validator} v */ v =>
v.type === 'error' || v.type === 'warning';
const hasErrorOrWarning = !!regularValidationResult.filter(errorOrWarning).length;
const prevHadErrorOrWarning = !!prevValidationResult.filter(errorOrWarning).length;
return !hasErrorOrWarning && prevHadErrorOrWarning;
}
};
withSuccessTagString = defineCE(
const withSuccessTagString = defineCE(
// @ts-expect-error
class extends ValidateMixin(LitElement) {
static get validationTypes() {
return [...super.validationTypes, 'success'];
}
},
);
withSuccessTag = unsafeStatic(withSuccessTagString);
});
const withSuccessTag = unsafeStatic(withSuccessTagString);
it('calls ResultValidators after regular validators', async () => {
const resultValidator = new MySuccessResultValidator();
@ -589,12 +603,12 @@ export function runValidateMixinSuite(customConfig) {
const resultValidator = new MySuccessResultValidator();
const resultValidateSpy = sinon.spy(resultValidator, 'executeOnResults');
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${withSuccessTag}
.validators=${[new MinLength(3), resultValidator]}
.modelValue=${'myValue'}
>${lightDom}</${withSuccessTag}>
`);
`));
const prevValidationResult = el.__prevValidationResult;
const regularValidationResult = [
...el.__syncValidationResult,
@ -619,26 +633,26 @@ export function runValidateMixinSuite(customConfig) {
const validator = new AlwaysInvalid();
const resultV = new AlwaysInvalidResult();
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[validator, resultV]}
.modelValue=${'myValue'}
>${lightDom}</${tag}>
`);
`));
const /** @type {TotalValidationResult} */ totalValidationResult = el.__validationResult;
const totalValidationResult = el.__validationResult;
expect(totalValidationResult).to.eql([resultV, validator]);
});
});
describe('Required Validator integration', () => {
it('will result in erroneous state when form control is empty', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[new Required()]}
.modelValue=${''}
>${lightDom}</${tag}>
`);
`));
expect(el.validationStates.error.Required).to.be.true;
expect(el.hasFeedbackFor).to.deep.equal(['error']);
@ -648,13 +662,13 @@ export function runValidateMixinSuite(customConfig) {
});
it('calls private ".__isEmpty" by default', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[new Required()]}
.modelValue=${''}
>${lightDom}</${tag}>
`);
const validator = el.validators.find(v => v instanceof Required);
`));
const validator = /** @type {Validator} */ (el.validators.find(v => v instanceof Required));
const executeSpy = sinon.spy(validator, 'execute');
const privateIsEmptySpy = sinon.spy(el, '__isEmpty');
el.modelValue = null;
@ -663,21 +677,22 @@ export function runValidateMixinSuite(customConfig) {
});
it('calls "._isEmpty" when provided (useful for different modelValues)', async () => {
const customRequiredTagString = defineCE(
class extends ValidateMixin(LitElement) {
// @ts-expect-error base constructor same return type
class _isEmptyValidate extends ValidateMixin(LitElement) {
_isEmpty() {
// @ts-expect-error
return this.modelValue.model === '';
}
},
);
}
const customRequiredTagString = defineCE(_isEmptyValidate);
const customRequiredTag = unsafeStatic(customRequiredTagString);
const el = await fixture(html`
const el = /** @type {_isEmptyValidate} */ (await fixture(html`
<${customRequiredTag}
.validators=${[new Required()]}
.modelValue=${{ model: 'foo' }}
>${lightDom}</${customRequiredTag}>
`);
`));
const providedIsEmptySpy = sinon.spy(el, '_isEmpty');
el.modelValue = { model: '' };
@ -688,32 +703,34 @@ export function runValidateMixinSuite(customConfig) {
it('prevents other Validators from being called when input is empty', async () => {
const alwaysInvalid = new AlwaysInvalid();
const alwaysInvalidSpy = sinon.spy(alwaysInvalid, 'execute');
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[new Required(), alwaysInvalid]}
.modelValue=${''}
>${lightDom}</${tag}>
`);
`));
expect(alwaysInvalidSpy.callCount).to.equal(0); // __isRequired returned false (invalid)
el.modelValue = 'foo';
expect(alwaysInvalidSpy.callCount).to.equal(1); // __isRequired returned true (valid)
});
it('adds [aria-required="true"] to "._inputNode"', async () => {
const el = await fixture(html`
<${withInputTag}
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[new Required()]}
.modelValue=${''}
>${lightDom}</${withInputTag}>
`);
expect(el._inputNode.getAttribute('aria-required')).to.equal('true');
>${lightDom}</${tag}>
`));
console.log(el._inputNode);
expect(el._inputNode?.getAttribute('aria-required')).to.equal('true');
el.validators = [];
expect(el._inputNode.getAttribute('aria-required')).to.be.null;
expect(el._inputNode?.getAttribute('aria-required')).to.be.null;
});
});
describe('Default (preconfigured) Validators', () => {
const preconfTagString = defineCE(
// @ts-expect-error base constructor same return type
class extends ValidateMixin(LitElement) {
constructor() {
super();
@ -724,11 +741,11 @@ export function runValidateMixinSuite(customConfig) {
const preconfTag = unsafeStatic(preconfTagString);
it('can be stored for custom inputs', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${preconfTag}
.validators=${[new MinLength(3)]}
.modelValue=${'12'}
></${preconfTag}>`);
></${preconfTag}>`));
expect(el.validationStates.error.AlwaysInvalid).to.be.true;
expect(el.validationStates.error.MinLength).to.be.true;
@ -736,6 +753,7 @@ export function runValidateMixinSuite(customConfig) {
it('can be altered by App Developers', async () => {
const altPreconfTagString = defineCE(
// @ts-expect-error base constructor same return type
class extends ValidateMixin(LitElement) {
constructor() {
super();
@ -745,10 +763,10 @@ export function runValidateMixinSuite(customConfig) {
);
const altPreconfTag = unsafeStatic(altPreconfTagString);
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${altPreconfTag}
.modelValue=${'12'}
></${altPreconfTag}>`);
></${altPreconfTag}>`));
expect(el.validationStates.error.MinLength).to.be.true;
el.defaultValidators[0].param = 2;
@ -756,10 +774,10 @@ export function runValidateMixinSuite(customConfig) {
});
it('can be requested via "._allValidators" getter', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${preconfTag}
.validators=${[new MinLength(3)]}
></${preconfTag}>`);
></${preconfTag}>`));
expect(el.validators.length).to.equal(1);
expect(el.defaultValidators.length).to.equal(1);
@ -776,11 +794,11 @@ export function runValidateMixinSuite(customConfig) {
describe('State storage and reflection', () => {
it('stores validity of individual Validators in ".validationStates.error[validator.validatorName]"', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.modelValue=${'a'}
.validators=${[new MinLength(3), new AlwaysInvalid()]}
>${lightDom}</${tag}>`);
>${lightDom}</${tag}>`));
expect(el.validationStates.error.MinLength).to.be.true;
expect(el.validationStates.error.AlwaysInvalid).to.be.true;
@ -791,11 +809,11 @@ export function runValidateMixinSuite(customConfig) {
});
it('removes "non active" states whenever modelValue becomes undefined', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${[new MinLength(3)]}
>${lightDom}</${tag}>
`);
`));
el.modelValue = 'a';
expect(el.hasFeedbackFor).to.deep.equal(['error']);
expect(el.validationStates.error).to.not.eql({});
@ -807,11 +825,11 @@ export function runValidateMixinSuite(customConfig) {
it('clears current validation results when validators array updated', async () => {
const validators = [new Required()];
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.validators=${validators}
>${lightDom}</${tag}>
`);
`));
expect(el.hasFeedbackFor).to.deep.equal(['error']);
expect(el.validationStates.error).to.eql({ Required: true });
@ -827,13 +845,13 @@ export function runValidateMixinSuite(customConfig) {
describe('Events', () => {
it('fires "showsFeedbackForChanged" event async after feedbackData got synced to feedbackElement', async () => {
const spy = sinon.spy();
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.validators=${[new MinLength(7)]}
@showsFeedbackForChanged=${spy};
>${lightDom}</${tag}>
`);
`));
el.modelValue = 'a';
await el.updateComplete;
expect(spy).to.have.callCount(1);
@ -849,13 +867,13 @@ export function runValidateMixinSuite(customConfig) {
it('fires "showsFeedbackFor{type}Changed" event async when type visibility changed', async () => {
const spy = sinon.spy();
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.validators=${[new MinLength(7)]}
@showsFeedbackForErrorChanged=${spy};
>${lightDom}</${tag}>
`);
`));
el.modelValue = 'a';
await el.updateComplete;
expect(spy).to.have.callCount(1);
@ -873,19 +891,25 @@ export function runValidateMixinSuite(customConfig) {
describe('Accessibility', () => {
it.skip('calls "._inputNode.setCustomValidity(errorMessage)"', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.modelValue=${'123'}
.validators=${[new MinLength(3, { message: 'foo' })]}>
<input slot="input">
</${tag}>`);
const spy = sinon.spy(el.inputElement, 'setCustomValidity');
</${tag}>`));
if (el._inputNode) {
// @ts-expect-error
const spy = sinon.spy(el._inputNode, 'setCustomValidity');
el.modelValue = '';
expect(spy.callCount).to.be(1);
expect(spy.callCount).to.equal(1);
// @ts-expect-error needs to be rewritten to new API
expect(el.validationMessage).to.be('foo');
el.modelValue = '123';
expect(spy.callCount).to.be(2);
expect(spy.callCount).to.equal(2);
// @ts-expect-error needs to be rewritten to new API
expect(el.validationMessage).to.be('');
}
});
// TODO: check with open a11y issues and find best solution here
@ -895,6 +919,7 @@ export function runValidateMixinSuite(customConfig) {
describe('Extensibility: Custom Validator types', () => {
const customTypeTagString = defineCE(
// @ts-expect-error base constructor same return type
class extends ValidateMixin(LitElement) {
static get validationTypes() {
return [...super.validationTypes, 'x', 'y'];
@ -904,7 +929,7 @@ export function runValidateMixinSuite(customConfig) {
const customTypeTag = unsafeStatic(customTypeTagString);
it('supports additional validationTypes in .hasFeedbackFor', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${customTypeTag}
.validators=${[
new MinLength(2, { type: 'x' }),
@ -913,7 +938,7 @@ export function runValidateMixinSuite(customConfig) {
]}
.modelValue=${'1234'}
>${lightDom}</${customTypeTag}>
`);
`));
expect(el.hasFeedbackFor).to.deep.equal([]);
el.modelValue = '123'; // triggers y
@ -927,7 +952,7 @@ export function runValidateMixinSuite(customConfig) {
});
it('supports additional validationTypes in .validationStates', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${customTypeTag}
.validators=${[
new MinLength(2, { type: 'x' }),
@ -936,7 +961,7 @@ export function runValidateMixinSuite(customConfig) {
]}
.modelValue=${'1234'}
>${lightDom}</${customTypeTag}>
`);
`));
expect(el.validationStates).to.eql({
x: {},
error: {},
@ -965,33 +990,9 @@ export function runValidateMixinSuite(customConfig) {
});
});
// we no longer have a flag for when the error message got displayed - not really useful right?
it.skip('only shows highest prio "has{Type}Visible" flag by default', async () => {
const el = await fixture(html`
<${customTypeTag}
.validators=${[
new MinLength(2, { type: 'x' }),
new MinLength(3), // implicit 'error type'
new MinLength(4, { type: 'y' }),
]}
.modelValue=${'1234'}
>${lightDom}</${customTypeTag}>
`);
expect(el.hasYVisible).to.be.false;
expect(el.hasErrorVisible).to.be.false;
expect(el.hasXVisible).to.be.false;
el.modelValue = '1'; // triggers y, x and error
await el.feedbackComplete;
expect(el.hasYVisible).to.be.false;
// Only shows message with highest prio (determined in el.constructor.validationTypes)
expect(el.hasErrorVisible).to.be.true;
expect(el.hasXVisible).to.be.false;
});
it('orders feedback based on provided "validationTypes"', async () => {
// we set submitted to always show error message in the test
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${customTypeTag}
.submitted=${true}
._visibleMessagesAmount=${Infinity}
@ -1002,16 +1003,17 @@ export function runValidateMixinSuite(customConfig) {
]}
.modelValue=${'1'}
>${lightDom}</${customTypeTag}>
`);
`));
await el.feedbackComplete;
const resultOrder = el._feedbackNode.feedbackData.map(v => v.type);
const feedbackNode = /** @type {import('../src/validate/LionValidationFeedback').LionValidationFeedback} */ (el._feedbackNode);
const resultOrder = feedbackNode.feedbackData?.map(v => v.type);
expect(resultOrder).to.deep.equal(['error', 'x', 'y']);
el.modelValue = '12';
await el.updateComplete;
await el.feedbackComplete;
const resultOrder2 = el._feedbackNode.feedbackData.map(v => v.type);
const resultOrder2 = feedbackNode.feedbackData?.map(v => v.type);
expect(resultOrder2).to.deep.equal(['error', 'y']);
});
@ -1025,8 +1027,8 @@ export function runValidateMixinSuite(customConfig) {
describe('Subclassers', () => {
describe('Adding new Validator types', () => {
it('can add helpers for validation types', async () => {
const elTagString = defineCE(
class extends ValidateMixin(LitElement) {
// @ts-expect-error base constructor same return type
class ValidateHasX extends ValidateMixin(LitElement) {
static get validationTypes() {
return [...super.validationTypes, 'x'];
}
@ -1038,18 +1040,18 @@ export function runValidateMixinSuite(customConfig) {
get hasXVisible() {
return this.showsFeedbackFor.includes('x');
}
},
);
}
const elTagString = defineCE(ValidateHasX);
const elTag = unsafeStatic(elTagString);
// we set submitted to always show errors
const el = await fixture(html`
const el = /** @type {ValidateHasX} */ (await fixture(html`
<${elTag}
.submitted=${true}
.validators=${[new MinLength(2, { type: 'x' })]}
.modelValue=${'1'}
>${lightDom}</${elTag}>
`);
`));
await el.feedbackComplete;
expect(el.hasX).to.be.true;
expect(el.hasXVisible).to.be.true;
@ -1062,17 +1064,27 @@ export function runValidateMixinSuite(customConfig) {
});
it('can fire custom events if needed', async () => {
/**
*
* @param {string[]} array1
* @param {string[]} array2
*/
function arrayDiff(array1 = [], array2 = []) {
return array1
.filter(x => !array2.includes(x))
.concat(array2.filter(x => !array1.includes(x)));
}
const elTagString = defineCE(
// @ts-expect-error base constructor same return type
class extends ValidateMixin(LitElement) {
static get validationTypes() {
return [...super.validationTypes, 'x'];
}
/**
* @param {string} name
* @param {?} oldValue
*/
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
if (name === 'hasFeedbackFor') {
@ -1088,14 +1100,14 @@ export function runValidateMixinSuite(customConfig) {
const spy = sinon.spy();
// we set prefilled to always show errors
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${elTag}
.prefilled=${true}
@hasFeedbackForXChanged=${spy}
.validators=${[new MinLength(2, { type: 'x' })]}
.modelValue=${'1'}
>${lightDom}</${elTag}>
`);
`));
expect(spy).to.have.callCount(1);
el.modelValue = '1';
expect(spy).to.have.callCount(1);
@ -1112,6 +1124,7 @@ export function runValidateMixinSuite(customConfig) {
// TODO: add this test on FormControl layer
it('reconsiders feedback visibility when interaction states changed', async () => {
const elTagString = defineCE(
// @ts-expect-error base constructor same return type
class extends ValidateMixin(LitElement) {
static get properties() {
return {
@ -1129,12 +1142,12 @@ export function runValidateMixinSuite(customConfig) {
},
);
const elTag = unsafeStatic(elTagString);
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${elTag}
.validators=${[new AlwaysInvalid()]}
.modelValue=${'myValue'}
>${lightDom}</${elTag}>
`);
`));
const spy = sinon.spy(el, '_updateFeedbackComponent');
let counter = 0;
@ -1153,43 +1166,6 @@ export function runValidateMixinSuite(customConfig) {
expect(spy.callCount).to.equal(counter);
}
});
// already shown how to add it yourself
it.skip('supports multiple "has{Type}Visible" flags', async () => {
const elTagString = defineCE(
class extends ValidateMixin(LitElement) {
static get validationTypes() {
return [...super.validationTypes, 'x', 'y'];
}
constructor() {
super();
this._visibleMessagesAmount = Infinity;
}
},
);
const elTag = unsafeStatic(elTagString);
const el = await fixture(html`
<${elTag}
.validators=${[
new MinLength(2, { type: 'x' }),
new MinLength(3), // implicit 'error type'
new MinLength(4, { type: 'y' }),
]}
.modelValue=${'1234'}
>${lightDom}</${elTag}>
`);
expect(el.hasYVisible).to.be.false;
expect(el.hasErrorVisible).to.be.false;
expect(el.hasXVisible).to.be.false;
el.modelValue = '1'; // triggers y
await el.feedbackComplete;
expect(el.hasYVisible).to.be.true;
expect(el.hasErrorVisible).to.be.true;
expect(el.hasXVisible).to.be.true; // only shows message with highest
});
});
describe('Changing feedback messages globally', () => {

View file

@ -8,84 +8,67 @@ import { AlwaysInvalid } from '../test-helpers.js';
export function runValidateMixinFeedbackPart() {
describe('Validity Feedback', () => {
let tagString;
let tag;
let ContainsLowercaseA;
const lightDom = '';
beforeEach(() => {
localizeTearDown();
});
before(() => {
tagString = defineCE(
class extends ValidateMixin(LitElement) {
static get properties() {
return {
modelValue: { type: String },
submitted: { type: Boolean },
};
}
// @ts-expect-error base constructor same return type
class ValidateElement extends ValidateMixin(LitElement) {
connectedCallback() {
super.connectedCallback();
this.appendChild(document.createElement('input'));
const inputNode = document.createElement('input');
inputNode.slot = 'input';
this.appendChild(inputNode);
}
}
get _inputNode() {
return this.querySelector('input');
}
},
);
tag = unsafeStatic(tagString);
const tagString = defineCE(ValidateElement);
const tag = unsafeStatic(tagString);
ContainsLowercaseA = class extends Validator {
class ContainsLowercaseA extends Validator {
static get validatorName() {
return 'ContainsLowercaseA';
}
/**
* @param {?} modelValue
*/
execute(modelValue) {
const hasError = !modelValue.includes('a');
return hasError;
}
};
}
class ContainsCat extends Validator {
static get validatorName() {
return 'ContainsCat';
}
/**
* @param {?} modelValue
*/
execute(modelValue) {
const hasError = !modelValue.includes('cat');
return hasError;
}
}
AlwaysInvalid.getMessage = () => 'Message for AlwaysInvalid';
MinLength.getMessage = () =>
AlwaysInvalid.getMessage = async () => 'Message for AlwaysInvalid';
MinLength.getMessage = async () =>
localize.locale === 'de-DE' ? 'Nachricht für MinLength' : 'Message for MinLength';
ContainsLowercaseA.getMessage = () => 'Message for ContainsLowercaseA';
ContainsCat.getMessage = () => 'Message for ContainsCat';
});
ContainsLowercaseA.getMessage = async () => 'Message for ContainsLowercaseA';
ContainsCat.getMessage = async () => 'Message for ContainsCat';
const lightDom = '';
afterEach(() => {
sinon.restore();
});
it('has .shouldShowFeedbackFor indicating for which type to show messages', async () => {
const el = await fixture(html`
<${tag}>${lightDom}</${tag}>
`);
expect(el.shouldShowFeedbackFor).to.deep.equal([]);
el.submitted = true;
await el.updateComplete;
expect(el.shouldShowFeedbackFor).to.deep.equal(['error']);
});
it('has .showsFeedbackFor indicating for which type it actually shows messages', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} submitted .validators=${[new MinLength(3)]}>${lightDom}</${tag}>
`);
`));
el.modelValue = 'a';
await el.feedbackComplete;
@ -97,22 +80,22 @@ export function runValidateMixinFeedbackPart() {
});
it('reflects .showsFeedbackFor as attribute joined with "," to be used as a style hook', async () => {
const elTagString = defineCE(
class extends ValidateMixin(LitElement) {
// @ts-expect-error base constructors same return type
class ValidateElementCustomTypes extends ValidateMixin(LitElement) {
static get validationTypes() {
return [...super.validationTypes, 'x'];
}
},
);
}
const elTagString = defineCE(ValidateElementCustomTypes);
const elTag = unsafeStatic(elTagString);
const el = await fixture(html`
const el = /** @type {ValidateElementCustomTypes} */ (await fixture(html`
<${elTag}
.submitted=${true}
.validators=${[
new MinLength(2, { type: 'x' }),
new MinLength(3, { type: 'error' }),
]}>${lightDom}</${elTag}>
`);
`));
el.modelValue = '1';
await el.updateComplete;
@ -134,30 +117,30 @@ export function runValidateMixinFeedbackPart() {
});
it('passes a message to the "._feedbackNode"', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.modelValue=${'cat'}
>${lightDom}</${tag}>
`);
`));
expect(el._feedbackNode.feedbackData).to.deep.equal([]);
el.validators = [new AlwaysInvalid()];
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData[0].message).to.equal('Message for AlwaysInvalid');
expect(el._feedbackNode.feedbackData?.[0].message).to.equal('Message for AlwaysInvalid');
});
it('has configurable feedback visibility hook', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.modelValue=${'cat'}
.validators=${[new AlwaysInvalid()]}
>${lightDom}</${tag}>
`);
`));
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData[0].message).to.equal('Message for AlwaysInvalid');
expect(el._feedbackNode.feedbackData?.[0].message).to.equal('Message for AlwaysInvalid');
el._prioritizeAndFilterFeedback = () => []; // filter out all errors
await el.validate();
await el.updateComplete;
@ -166,20 +149,21 @@ export function runValidateMixinFeedbackPart() {
});
it('writes prioritized result to "._feedbackNode" based on Validator order', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.modelValue=${'cat'}
.validators=${[new AlwaysInvalid(), new MinLength(4)]}
>${lightDom}</${tag}>
`);
`));
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData[0].message).to.equal('Message for AlwaysInvalid');
expect(el._feedbackNode.feedbackData?.[0].message).to.equal('Message for AlwaysInvalid');
});
it('renders validation result to "._feedbackNode" when async messages are resolved', async () => {
let unlockMessage;
/** @type {function} FIXME: find better way to type this kind of pattern */
let unlockMessage = () => {};
const messagePromise = new Promise(resolve => {
unlockMessage = resolve;
});
@ -189,23 +173,26 @@ export function runValidateMixinFeedbackPart() {
return 'this ends up in "._feedbackNode"';
};
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.modelValue=${'cat'}
.validators=${[new AlwaysInvalid()]}
>${lightDom}</${tag}>
`);
`));
expect(el._feedbackNode.feedbackData).to.be.undefined;
unlockMessage();
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData[0].message).to.equal('this ends up in "._feedbackNode"');
expect(el._feedbackNode.feedbackData?.[0].message).to.equal(
'this ends up in "._feedbackNode"',
);
});
// N.B. this replaces the 'config.hideFeedback' option we had before...
it('renders empty result when Validator.getMessage() returns "null"', async () => {
let unlockMessage;
/** @type {function} FIXME: find better way to type this kind of pattern */
let unlockMessage = () => {};
const messagePromise = new Promise(resolve => {
unlockMessage = resolve;
});
@ -215,47 +202,63 @@ export function runValidateMixinFeedbackPart() {
return 'this ends up in "._feedbackNode"';
};
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.modelValue=${'cat'}
.validators=${[new AlwaysInvalid()]}
>${lightDom}</${tag}>
`);
`));
expect(el._feedbackNode.feedbackData).to.be.undefined;
unlockMessage();
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData[0].message).to.equal('this ends up in "._feedbackNode"');
expect(el._feedbackNode.feedbackData?.[0].message).to.equal(
'this ends up in "._feedbackNode"',
);
});
it('supports custom element to render feedback', async () => {
const customFeedbackTagString = defineCE(
class extends LitElement {
class ValidateElementCustomRender extends LitElement {
static get properties() {
return {
feedbackData: Array,
feedbackData: { attribute: false },
};
}
constructor() {
super();
/**
* @typedef {Object} messageMap
* @property {string | Node} message
* @property {string} type
* @property {Validator} [validator]
*/
/** @type {messageMap[]} */
this.feedbackData = [];
}
render() {
const name =
this.feedbackData && this.feedbackData[0]
? this.feedbackData[0].validator.constructor.validatorName
: '';
let name = '';
if (this.feedbackData && this.feedbackData.length > 0) {
const ctor = /** @type {typeof Validator} */ (this.feedbackData[0]?.validator
.constructor);
name = ctor.validatorName;
}
return html`Custom for ${name}`;
}
},
);
}
const customFeedbackTagString = defineCE(ValidateElementCustomRender);
const customFeedbackTag = unsafeStatic(customFeedbackTagString);
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.validators=${[new ContainsLowercaseA(), new AlwaysInvalid()]}>
<${customFeedbackTag} slot="feedback"><${customFeedbackTag}>
</${tag}>
`);
`));
expect(el._feedbackNode.localName).to.equal(customFeedbackTagString);
@ -273,66 +276,46 @@ export function runValidateMixinFeedbackPart() {
});
it('supports custom messages in Validator instance configuration object', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.validators=${[new MinLength(3, { getMessage: () => 'custom via config' })]}
>${lightDom}</${tag}>
`);
`));
el.modelValue = 'a';
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData[0].message).to.equal('custom via config');
});
it('keeps the feedback component in sync', async () => {
const el = await fixture(html`
<${tag} .validators=${[new MinLength(3)]}>${lightDom}</${tag}>
`);
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData).to.deep.equal([]);
// has error but does not show/forward to component as showCondition is not met
el.modelValue = '1';
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData).to.deep.equal([]);
el.submitted = true;
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData.length).to.equal(1);
expect(el._feedbackNode.feedbackData?.[0].message).to.equal('custom via config');
});
it('updates the feedback component when locale changes', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.validators=${[new MinLength(3)]}
.modelValue=${'1'}
>${lightDom}</${tag}>
`);
`));
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData.length).to.equal(1);
expect(el._feedbackNode.feedbackData[0].message).to.equal('Message for MinLength');
expect(el._feedbackNode.feedbackData?.length).to.equal(1);
expect(el._feedbackNode.feedbackData?.[0].message).to.equal('Message for MinLength');
localize.locale = 'de-DE';
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData[0].message).to.equal('Nachricht für MinLength');
expect(el._feedbackNode.feedbackData?.[0].message).to.equal('Nachricht für MinLength');
});
it('shows success message after fixing an error', async () => {
const elTagString = defineCE(
class extends ValidateMixin(LitElement) {
// @ts-expect-error base constructor same return type
class ValidateElementCustomTypes extends ValidateMixin(LitElement) {
static get validationTypes() {
return ['error', 'success'];
}
},
);
}
const elTagString = defineCE(ValidateElementCustomTypes);
const elTag = unsafeStatic(elTagString);
const el = await fixture(html`
const el = /** @type {ValidateElementCustomTypes} */ (await fixture(html`
<${elTag}
.submitted=${true}
.validators=${[
@ -340,28 +323,28 @@ export function runValidateMixinFeedbackPart() {
new DefaultSuccess(null, { getMessage: () => 'This is a success message' }),
]}
>${lightDom}</${elTag}>
`);
`));
el.modelValue = 'a';
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData[0].message).to.equal('Message for MinLength');
expect(el._feedbackNode.feedbackData?.[0].message).to.equal('Message for MinLength');
el.modelValue = 'abcd';
await el.updateComplete;
await el.feedbackComplete;
expect(el._feedbackNode.feedbackData[0].message).to.equal('This is a success message');
expect(el._feedbackNode.feedbackData?.[0].message).to.equal('This is a success message');
});
describe('Accessibility', () => {
it('sets [aria-invalid="true"] to "._inputNode" when there is an error', async () => {
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
submitted
.validators=${[new Required()]}
.modelValue=${'a'}
>${lightDom}</${tag}>
`);
`));
const inputNode = el._inputNode;
expect(inputNode.getAttribute('aria-invalid')).to.equal('false');
@ -379,25 +362,27 @@ export function runValidateMixinFeedbackPart() {
describe('Meta data', () => {
it('".getMessage()" gets a reference to formControl, params, modelValue and type', async () => {
const elTagString = defineCE(
class extends ValidateMixin(LitElement) {
// @ts-expect-error base constructor same return type
class ValidateElementCustomTypes extends ValidateMixin(LitElement) {
static get validationTypes() {
return ['error', 'x'];
}
},
);
}
const elTagString = defineCE(ValidateElementCustomTypes);
const elTag = unsafeStatic(elTagString);
let el;
const constructorValidator = new MinLength(4, { type: 'x' }); // type to prevent duplicates
const constructorMessageSpy = sinon.spy(constructorValidator.constructor, 'getMessage');
const ctorValidator = /** @type {typeof MinLength} */ (constructorValidator.constructor);
const constructorMessageSpy = sinon.spy(ctorValidator, 'getMessage');
el = await fixture(html`
el = /** @type {ValidateElementCustomTypes} */ (await fixture(html`
<${elTag}
.submitted=${true}
.validators=${[constructorValidator]}
.modelValue=${'cat'}
>${lightDom}</${elTag}>
`);
`));
await el.updateComplete;
await el.feedbackComplete;
expect(constructorMessageSpy.args[0][0]).to.eql({
@ -413,13 +398,13 @@ export function runValidateMixinFeedbackPart() {
const instanceMessageSpy = sinon.spy();
const instanceValidator = new MinLength(4, { getMessage: instanceMessageSpy });
el = await fixture(html`
el = /** @type {ValidateElementCustomTypes} */ (await fixture(html`
<${elTag}
.submitted=${true}
.validators=${[instanceValidator]}
.modelValue=${'cat'}
>${lightDom}</${elTag}>
`);
`));
await el.updateComplete;
await el.feedbackComplete;
expect(instanceMessageSpy.args[0][0]).to.eql({
@ -437,16 +422,17 @@ export function runValidateMixinFeedbackPart() {
it('".getMessage()" gets .fieldName defined on instance', async () => {
const constructorValidator = new MinLength(4);
const spy = sinon.spy(constructorValidator.constructor, 'getMessage');
const ctorValidator = /** @type {typeof MinLength} */ (constructorValidator.constructor);
const spy = sinon.spy(ctorValidator, 'getMessage');
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.validators=${[constructorValidator]}
.modelValue=${'cat'}
.fieldName=${new Promise(resolve => resolve('myField'))}
>${lightDom}</${tag}>
`);
`));
await el.updateComplete;
await el.feedbackComplete;
expect(spy.args[0][0]).to.eql({
@ -465,22 +451,23 @@ export function runValidateMixinFeedbackPart() {
const constructorValidator = new MinLength(4, {
fieldName: new Promise(resolve => resolve('myFieldViaCfg')),
});
const spy = sinon.spy(constructorValidator.constructor, 'getMessage');
const ctorValidator = /** @type {typeof MinLength} */ (constructorValidator.constructor);
const spy = sinon.spy(ctorValidator, 'getMessage');
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.validators=${[constructorValidator]}
.modelValue=${'cat'}
.fieldName=${new Promise(resolve => resolve('myField'))}
>${lightDom}</${tag}>
`);
`));
await el.updateComplete;
await el.feedbackComplete;
// ignore fieldName Promise as it will always be unique
const compare = spy.args[0][0];
delete compare.config.fieldName;
delete compare?.config.fieldName;
expect(compare).to.eql({
config: {},
params: 4,
@ -503,13 +490,13 @@ export function runValidateMixinFeedbackPart() {
* The Queue system solves this by queueing the updateFeedbackComponent tasks and
* await them one by one.
*/
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag}
.submitted=${true}
.validators=${[new MinLength(3)]}
.modelValue=${'1'}
>${lightDom}</${tag}>
`);
`));
el.modelValue = '12345';
await el.updateComplete;

File diff suppressed because it is too large Load diff

View file

@ -3,14 +3,11 @@ import { defineCE, expect, fixture, html, oneEvent, unsafeStatic } from '@open-w
import { FocusMixin } from '../src/FocusMixin.js';
describe('FocusMixin', () => {
// @ts-expect-error base constructors same return type
class Focusable extends FocusMixin(LitElement) {
render() {
return html`<slot name="input"></slot>`;
}
get _inputNode() {
return this.querySelector('input');
}
}
const tagString = defineCE(Focusable);

View file

@ -1,20 +1,14 @@
import { expect, html, defineCE, unsafeStatic, fixture } from '@open-wc/testing';
import { LitElement, SlotMixin } from '@lion/core';
import { LitElement } from '@lion/core';
import sinon from 'sinon';
import { FormControlMixin } from '../src/FormControlMixin.js';
import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js';
describe('FormControlMixin', () => {
const inputSlot = '<input slot="input" />';
class FormControlMixinClass extends FormControlMixin(SlotMixin(LitElement)) {
static get properties() {
return {
modelValue: {
type: String,
},
};
}
}
// @ts-expect-error base constructor same return type
class FormControlMixinClass extends FormControlMixin(LitElement) {}
const tagString = defineCE(FormControlMixinClass);
const tag = unsafeStatic(tagString);
@ -207,17 +201,10 @@ describe('FormControlMixin', () => {
});
describe('Model-value-changed event propagation', () => {
// @ts-expect-error base constructor same return type
const FormControlWithRegistrarMixinClass = class extends FormControlMixin(
FormRegistrarMixin(SlotMixin(LitElement)),
) {
static get properties() {
return {
modelValue: {
type: String,
},
};
}
};
FormRegistrarMixin(LitElement),
) {};
const groupElem = defineCE(FormControlWithRegistrarMixinClass);
const groupTag = unsafeStatic(groupElem);

View file

@ -1,19 +1,30 @@
import { html, LitElement } from '@lion/core';
import '@lion/fieldset/lion-fieldset.js';
// @ts-expect-error
import { LionInput } from '@lion/input';
import '@lion/fieldset/lion-fieldset.js';
import { FormGroupMixin, Required } from '@lion/form-core';
import { expect, fixture } from '@open-wc/testing';
import { ChoiceGroupMixin } from '../../src/choice-group/ChoiceGroupMixin.js';
import { ChoiceInputMixin } from '../../src/choice-group/ChoiceInputMixin.js';
// import { LionField } from '../../src/LionField.js';
// class InputField extends LionField {
// get slots() {
// return {
// ...super.slots,
// input: () => document.createElement('input'),
// };
// }
// }
describe('ChoiceGroupMixin', () => {
before(() => {
class ChoiceInput extends ChoiceInputMixin(LionInput) {}
// @ts-expect-error base constructors same return type
customElements.define('choice-group-input', ChoiceInput);
// @ts-expect-error
class ChoiceGroup extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {}
customElements.define('choice-group', ChoiceGroup);
// @ts-expect-error
class ChoiceGroupMultiple extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {
constructor() {
super();
@ -21,16 +32,15 @@ describe('ChoiceGroupMixin', () => {
}
}
customElements.define('choice-group-multiple', ChoiceGroupMultiple);
});
it('has a single modelValue representing the currently checked radio value', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender">
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group>
`);
`));
expect(el.modelValue).to.equal('female');
el.formElements[0].checked = true;
expect(el.modelValue).to.equal('male');
@ -39,13 +49,13 @@ describe('ChoiceGroupMixin', () => {
});
it('has a single formattedValue representing the currently checked radio value', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender">
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group>
`);
`));
expect(el.formattedValue).to.equal('female');
el.formElements[0].checked = true;
expect(el.formattedValue).to.equal('male');
@ -54,16 +64,16 @@ describe('ChoiceGroupMixin', () => {
});
it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender">
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group>
`);
const invalidChild = await fixture(html`
`));
const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group-input .modelValue=${'Lara'}></choice-group-input>
`);
`));
expect(() => {
el.addFormElement(invalidChild);
@ -73,35 +83,35 @@ describe('ChoiceGroupMixin', () => {
});
it('automatically sets the name property of child radios to its own name', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender">
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group>
`);
`));
expect(el.formElements[0].name).to.equal('gender');
expect(el.formElements[1].name).to.equal('gender');
const validChild = await fixture(html`
const validChild = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group-input .choiceValue=${'male'}></choice-group-input>
`);
`));
el.appendChild(validChild);
expect(el.formElements[2].name).to.equal('gender');
});
it('throws if a child element with a different name than the group tries to register', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender">
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group>
`);
`));
const invalidChild = await fixture(html`
const invalidChild = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group-input name="foo" .choiceValue=${'male'}></choice-group-input>
`);
`));
expect(() => {
el.addFormElement(invalidChild);
@ -111,39 +121,39 @@ describe('ChoiceGroupMixin', () => {
});
it('can set initial modelValue on creation', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender" .modelValue=${'other'}>
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'}></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group>
`);
`));
expect(el.modelValue).to.equal('other');
expect(el.formElements[2].checked).to.be.true;
});
it('can set initial serializedValue on creation', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender" .serializedValue=${'other'}>
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'}></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group>
`);
`));
expect(el.serializedValue).to.equal('other');
expect(el.formElements[2].checked).to.be.true;
});
it('can set initial formattedValue on creation', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender" .formattedValue=${'other'}>
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'}></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group>
`);
`));
expect(el.formattedValue).to.equal('other');
expect(el.formElements[2].checked).to.be.true;
@ -152,12 +162,12 @@ describe('ChoiceGroupMixin', () => {
it('can handle complex data via choiceValue', async () => {
const date = new Date(2018, 11, 24, 10, 33, 30, 0);
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="data">
<choice-group-input .choiceValue=${{ some: 'data' }}></choice-group-input>
<choice-group-input .choiceValue=${date} checked></choice-group-input>
</choice-group>
`);
`));
expect(el.modelValue).to.equal(date);
el.formElements[0].checked = true;
@ -165,12 +175,12 @@ describe('ChoiceGroupMixin', () => {
});
it('can handle 0 and empty string as valid values', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="data">
<choice-group-input .choiceValue=${0} checked></choice-group-input>
<choice-group-input .choiceValue=${''}></choice-group-input>
</choice-group>
`);
`));
expect(el.modelValue).to.equal(0);
el.formElements[1].checked = true;
@ -178,7 +188,7 @@ describe('ChoiceGroupMixin', () => {
});
it('can check a radio by supplying an available modelValue', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender">
<choice-group-input .modelValue="${{ value: 'male', checked: false }}"></choice-group-input>
<choice-group-input
@ -188,7 +198,7 @@ describe('ChoiceGroupMixin', () => {
.modelValue="${{ value: 'other', checked: false }}"
></choice-group-input>
</choice-group>
`);
`));
expect(el.modelValue).to.equal('female');
el.modelValue = 'other';
@ -197,7 +207,7 @@ describe('ChoiceGroupMixin', () => {
it('expect child nodes to only fire one model-value-changed event per instance', async () => {
let counter = 0;
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group
name="gender"
@model-value-changed=${() => {
@ -208,7 +218,7 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .modelValue=${{ value: 'female', checked: true }}></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group>
`);
`));
counter = 0; // reset after setup which may result in different results
@ -231,60 +241,60 @@ describe('ChoiceGroupMixin', () => {
});
it('can be required', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender" .validators=${[new Required()]}>
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input
.choiceValue=${{ subObject: 'satisfies required' }}
></choice-group-input>
</choice-group>
`);
`));
expect(el.hasFeedbackFor).to.include('error');
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).to.have.a.property('Required');
expect(el.validationStates.error).to.exist;
expect(el.validationStates.error.Required).to.exist;
el.formElements[0].checked = true;
expect(el.hasFeedbackFor).not.to.include('error');
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).not.to.have.a.property('Required');
expect(el.validationStates.error).to.exist;
expect(el.validationStates.error.Required).to.not.exist;
el.formElements[1].checked = true;
expect(el.hasFeedbackFor).not.to.include('error');
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).not.to.have.a.property('Required');
expect(el.validationStates.error).to.exist;
expect(el.validationStates.error.Required).to.not.exist;
});
it('returns serialized value', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender">
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'}></choice-group-input>
</choice-group>
`);
`));
el.formElements[0].checked = true;
expect(el.serializedValue).to.deep.equal('male');
});
it('returns serialized value on unchecked state', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group name="gender">
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'}></choice-group-input>
</choice-group>
`);
`));
expect(el.serializedValue).to.deep.equal('');
});
describe('multipleChoice', () => {
it('has a single modelValue representing all currently checked values', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group-multiple name="gender[]">
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group-multiple>
`);
`));
expect(el.modelValue).to.eql(['female']);
el.formElements[0].checked = true;
@ -294,13 +304,13 @@ describe('ChoiceGroupMixin', () => {
});
it('has a single serializedValue representing all currently checked values', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group-multiple name="gender[]">
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group-multiple>
`);
`));
expect(el.serializedValue).to.eql(['female']);
el.formElements[0].checked = true;
@ -310,13 +320,13 @@ describe('ChoiceGroupMixin', () => {
});
it('has a single formattedValue representing all currently checked values', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group-multiple name="gender[]">
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group-multiple>
`);
`));
expect(el.formattedValue).to.eql(['female']);
el.formElements[0].checked = true;
@ -326,13 +336,13 @@ describe('ChoiceGroupMixin', () => {
});
it('can check multiple checkboxes by setting the modelValue', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group-multiple name="gender[]">
<choice-group-input .choiceValue=${'male'}></choice-group-input>
<choice-group-input .choiceValue=${'female'}></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group-multiple>
`);
`));
el.modelValue = ['male', 'other'];
expect(el.modelValue).to.eql(['male', 'other']);
@ -341,13 +351,13 @@ describe('ChoiceGroupMixin', () => {
});
it('unchecks non-matching checkboxes when setting the modelValue', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<choice-group-multiple name="gender[]">
<choice-group-input .choiceValue=${'male'} checked></choice-group-input>
<choice-group-input .choiceValue=${'female'}></choice-group-input>
<choice-group-input .choiceValue=${'other'} checked></choice-group-input>
</choice-group-multiple>
`);
`));
expect(el.modelValue).to.eql(['male', 'other']);
expect(el.formElements[0].checked).to.be.true;
@ -362,7 +372,7 @@ describe('ChoiceGroupMixin', () => {
describe('Integration with a parent form/fieldset', () => {
it('will serialize all children with their serializedValue', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceGroup} */ (await fixture(html`
<lion-fieldset>
<choice-group name="gender">
<choice-group-input .choiceValue=${'male'} checked disabled></choice-group-input>
@ -370,13 +380,13 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group>
</lion-fieldset>
`);
`));
expect(el.serializedValue).to.eql({
gender: 'female',
});
const choiceGroupEl = el.querySelector('[name="gender"]');
const choiceGroupEl = /** @type {ChoiceGroup} */ (el.querySelector('[name="gender"]'));
choiceGroupEl.multipleChoice = true;
expect(el.serializedValue).to.eql({
gender: ['female'],

View file

@ -1,28 +1,46 @@
import { html } from '@lion/core';
// @ts-expect-error
import { LionInput } from '@lion/input';
import { Required } from '@lion/form-core';
import { expect, fixture } from '@open-wc/testing';
import sinon from 'sinon';
import { ChoiceInputMixin } from '../../src/choice-group/ChoiceInputMixin.js';
/**
* @typedef {import('../../types/choice-group/ChoiceInputMixinTypes').ChoiceInputHost} ChoiceInputHost
*/
// class InputField extends LionField {
// get slots() {
// return {
// ...super.slots,
// input: () => document.createElement('input'),
// };
// }
// }
describe('ChoiceInputMixin', () => {
before(() => {
class ChoiceInput extends ChoiceInputMixin(LionInput) {
connectedCallback() {
if (super.connectedCallback) super.connectedCallback();
/** @typedef {Element & ChoiceClass} ChoiceInput */
class ChoiceClass extends ChoiceInputMixin(LionInput) {
constructor() {
super();
this.type = 'checkbox'; // could also be 'radio', should be tested in integration test
}
}
customElements.define('choice-input', ChoiceInput);
});
// @ts-expect-error base constructors same return type
customElements.define('choice-input', ChoiceClass);
it('is hidden when attribute hidden is true', async () => {
const el = await fixture(html`<choice-input hidden></choice-input>`);
const el = /** @type {ChoiceInput} */ (await fixture(
html`<choice-input hidden></choice-input>`,
));
expect(el).not.to.be.displayed;
});
it('has choiceValue', async () => {
const el = await fixture(html`<choice-input .choiceValue=${'foo'}></choice-input>`);
const el = /** @type {ChoiceInput} */ (await fixture(
html`<choice-input .choiceValue=${'foo'}></choice-input>`,
));
expect(el.choiceValue).to.equal('foo');
expect(el.modelValue).to.deep.equal({
@ -34,7 +52,9 @@ describe('ChoiceInputMixin', () => {
it('can handle complex data via choiceValue', async () => {
const date = new Date(2018, 11, 24, 10, 33, 30, 0);
const el = await fixture(html`<choice-input .choiceValue=${date}></choice-input>`);
const el = /** @type {ChoiceInput} */ (await fixture(
html`<choice-input .choiceValue=${date}></choice-input>`,
));
expect(el.choiceValue).to.equal(date);
expect(el.modelValue.value).to.equal(date);
@ -42,14 +62,14 @@ describe('ChoiceInputMixin', () => {
it('fires one "model-value-changed" event if choiceValue or checked state or modelValue changed', async () => {
let counter = 0;
const el = await fixture(html`
const el = /** @type {ChoiceInput} */ (await fixture(html`
<choice-input
@model-value-changed=${() => {
counter += 1;
}}
.choiceValue=${'foo'}
></choice-input>
`);
`));
expect(counter).to.equal(1); // undefined to set value
el.checked = true;
@ -67,7 +87,7 @@ describe('ChoiceInputMixin', () => {
it('fires one "user-input-changed" event after user interaction', async () => {
let counter = 0;
const el = await fixture(html`
const el = /** @type {ChoiceInput} */ (await fixture(html`
<choice-input
@user-input-changed="${() => {
counter += 1;
@ -75,7 +95,7 @@ describe('ChoiceInputMixin', () => {
>
<input slot="input" />
</choice-input>
`);
`));
expect(counter).to.equal(0);
// Here we try to mimic user interaction by firing browser events
const nativeInput = el._inputNode;
@ -86,31 +106,31 @@ describe('ChoiceInputMixin', () => {
});
it('can be required', async () => {
const el = await fixture(html`
const el = /** @type {ChoiceInput} */ (await fixture(html`
<choice-input .choiceValue=${'foo'} .validators=${[new Required()]}></choice-input>
`);
`));
expect(el.hasFeedbackFor).to.include('error');
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).to.have.a.property('Required');
expect(el.validationStates.error).to.exist;
expect(el.validationStates.error.Required).to.exist;
el.checked = true;
expect(el.hasFeedbackFor).not.to.include('error');
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).not.to.have.a.property('Required');
expect(el.validationStates.error).to.exist;
expect(el.validationStates.error.Required).not.to.exist;
});
describe('Checked state synchronization', () => {
it('synchronizes checked state initially (via attribute or property)', async () => {
const el = await fixture(`<choice-input></choice-input>`);
const el = /** @type {ChoiceInput} */ (await fixture(`<choice-input></choice-input>`));
expect(el.checked).to.equal(false, 'initially unchecked');
const precheckedElementAttr = await fixture(html`
const precheckedElementAttr = /** @type {ChoiceInput} */ (await fixture(html`
<choice-input .checked=${true}></choice-input>
`);
`));
expect(precheckedElementAttr.checked).to.equal(true, 'initially checked via attribute');
});
it('can be checked and unchecked programmatically', async () => {
const el = await fixture(`<choice-input></choice-input>`);
const el = /** @type {ChoiceInput} */ (await fixture(`<choice-input></choice-input>`));
expect(el.checked).to.be.false;
el.checked = true;
expect(el.checked).to.be.true;
@ -120,7 +140,7 @@ describe('ChoiceInputMixin', () => {
});
it('can be checked and unchecked via user interaction', async () => {
const el = await fixture(`<choice-input></choice-input>`);
const el = /** @type {ChoiceInput} */ (await fixture(`<choice-input></choice-input>`));
el._inputNode.click();
expect(el.checked).to.be.true;
el._inputNode.click();
@ -128,7 +148,9 @@ describe('ChoiceInputMixin', () => {
});
it('synchronizes modelValue to checked state and vice versa', async () => {
const el = await fixture(html`<choice-input .choiceValue=${'foo'}></choice-input>`);
const el = /** @type {ChoiceInput} */ (await fixture(
html`<choice-input .choiceValue=${'foo'}></choice-input>`,
));
expect(el.checked).to.be.false;
expect(el.modelValue).to.deep.equal({
checked: false,
@ -145,7 +167,9 @@ describe('ChoiceInputMixin', () => {
it('ensures optimal synchronize performance by preventing redundant computation steps', async () => {
/* we are checking private apis here to make sure we do not have cyclical updates
which can be quite common for these type of connected data */
const el = await fixture(html`<choice-input .choiceValue=${'foo'}></choice-input>`);
const el = /** @type {ChoiceInput} */ (await fixture(
html`<choice-input .choiceValue=${'foo'}></choice-input>`,
));
expect(el.checked).to.be.false;
const spyModelCheckedToChecked = sinon.spy(el, '__syncModelCheckedToChecked');
@ -168,13 +192,14 @@ describe('ChoiceInputMixin', () => {
});
it('synchronizes checked state to [checked] attribute for styling purposes', async () => {
/** @param {ChoiceInput} el */
const hasAttr = el => el.hasAttribute('checked');
const el = await fixture(`<choice-input></choice-input>`);
const elChecked = await fixture(html`
const el = /** @type {ChoiceInput} */ (await fixture(`<choice-input></choice-input>`));
const elChecked = /** @type {ChoiceInput} */ (await fixture(html`
<choice-input .checked=${true}>
<input slot="input" />
</choice-input>
`);
`));
// Initial values
expect(hasAttr(el)).to.equal(false, 'initial unchecked element');
@ -214,32 +239,38 @@ describe('ChoiceInputMixin', () => {
describe('Format/parse/serialize loop', () => {
it('creates a modelValue object like { checked: true, value: foo } on init', async () => {
const el = await fixture(html`<choice-input .choiceValue=${'foo'}></choice-input>`);
const el = /** @type {ChoiceInput} */ (await fixture(
html`<choice-input .choiceValue=${'foo'}></choice-input>`,
));
expect(el.modelValue).deep.equal({ value: 'foo', checked: false });
const elChecked = await fixture(html`
const elChecked = /** @type {ChoiceInput} */ (await fixture(html`
<choice-input .choiceValue=${'foo'} .checked=${true}></choice-input>
`);
`));
expect(elChecked.modelValue).deep.equal({ value: 'foo', checked: true });
});
it('creates a formattedValue based on modelValue.value', async () => {
const el = await fixture(`<choice-input></choice-input>`);
const el = /** @type {ChoiceInput} */ (await fixture(`<choice-input></choice-input>`));
expect(el.formattedValue).to.equal('');
const elementWithValue = await fixture(html`
const elementWithValue = /** @type {ChoiceInput} */ (await fixture(html`
<choice-input .choiceValue=${'foo'}></choice-input>
`);
`));
expect(elementWithValue.formattedValue).to.equal('foo');
});
});
describe('Interaction states', () => {
it('is considered prefilled when checked and not considered prefilled when unchecked', async () => {
const el = await fixture(html`<choice-input .checked=${true}></choice-input>`);
const el = /** @type {ChoiceInput} */ (await fixture(
html`<choice-input .checked=${true}></choice-input>`,
));
expect(el.prefilled).equal(true, 'checked element not considered prefilled');
const elUnchecked = await fixture(`<choice-input></choice-input>`);
const elUnchecked = /** @type {ChoiceInput} */ (await fixture(
`<choice-input></choice-input>`,
));
expect(elUnchecked.prefilled).equal(false, 'unchecked element not considered prefilled');
});
});

View file

@ -1,10 +1,10 @@
import { defineCE } from '@open-wc/testing';
import { runInteractionStateMixinSuite } from '../test-suites/InteractionStateMixin.suite.js';
import '../lion-field.js';
import { LionField } from '../src/LionField.js';
import { runFormatMixinSuite } from '../test-suites/FormatMixin.suite.js';
const fieldTagString = defineCE(
class extends customElements.get('lion-field') {
class extends LionField {
get slots() {
return {
...super.slots,
@ -18,7 +18,6 @@ const fieldTagString = defineCE(
describe('<lion-field> integrations', () => {
runInteractionStateMixinSuite({
tagString: fieldTagString,
suffix: 'lion-field',
});
runFormatMixinSuite({

View file

@ -0,0 +1,3 @@
import { runFormGroupMixinSuite } from '../../test-suites/form-group/FormGroupMixin.suite.js';
runFormGroupMixinSuite();

View file

@ -16,13 +16,21 @@ import '../lion-field.js';
/**
* @typedef {import('../src/LionField.js').LionField} LionField
* @typedef {import('../types/FormControlMixinTypes').FormControlHost} FormControlHost
* @typedef {FormControlHost & HTMLElement & {__parentFormGroup?:HTMLElement, checked?:boolean}} FormControl
*/
/** @typedef {HTMLElement & {shadowRoot: HTMLElement, assignedNodes: Function}} ShadowHTMLElement */
const tagString = 'lion-field';
const tag = unsafeStatic(tagString);
const inputSlotString = '<input slot="input" />';
const inputSlot = unsafeHTML(inputSlotString);
/**
* @param {import("../index.js").LionField} formControl
* @param {string} newViewValue
*/
function mimicUserInput(formControl, newViewValue) {
formControl.value = newViewValue; // eslint-disable-line no-param-reassign
formControl._inputNode.dispatchEvent(new CustomEvent('input', { bubbles: true }));
@ -32,10 +40,19 @@ beforeEach(() => {
localizeTearDown();
});
/**
* @param {HTMLElement} el
* @param {string} slot
*/
function getSlot(el, slot) {
const children = /** @type {any[]} */ (Array.from(el.children));
return children.find(child => child.slot === slot);
}
describe('<lion-field>', () => {
it(`puts a unique id "${tagString}-[hash]" on the native input`, async () => {
const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}</${tag}>`));
expect(Array.from(el.children).find(child => child.slot === 'input').id).to.equal(el._inputId);
expect(getSlot(el, 'input').id).to.equal(el._inputId);
});
it(`has a fieldName based on the label`, async () => {
@ -148,15 +165,15 @@ describe('<lion-field>', () => {
const el = /** @type {LionField} */ (await fixture(
html`<${tag} value="one">${inputSlot}</${tag}>`,
));
expect(Array.from(el.children).find(child => child.slot === 'input').value).to.equal('one');
expect(getSlot(el, 'input').value).to.equal('one');
});
it('delegates value property', async () => {
const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}</${tag}>`));
expect(Array.from(el.children).find(child => child.slot === 'input').value).to.equal('');
expect(getSlot(el, 'input').value).to.equal('');
el.value = 'one';
expect(el.value).to.equal('one');
expect(Array.from(el.children).find(child => child.slot === 'input').value).to.equal('one');
expect(getSlot(el, 'input').value).to.equal('one');
});
// This is necessary for security, so that _inputNodes autocomplete can be set to 'off'
@ -189,7 +206,7 @@ describe('<lion-field>', () => {
el.disabled = true;
await el.updateComplete;
await aTimeout();
await aTimeout(0);
expect(el._inputNode.hasAttribute('disabled')).to.equal(true);
const disabledel = /** @type {LionField} */ (await fixture(
@ -220,7 +237,7 @@ describe('<lion-field>', () => {
<span slot="feedback">No name entered</span>
</${tag}>
`));
const nativeInput = Array.from(el.children).find(child => child.slot === 'input');
const nativeInput = getSlot(el, 'input');
expect(nativeInput.getAttribute('aria-labelledby')).to.equal(`label-${el._inputId}`);
expect(nativeInput.getAttribute('aria-describedby')).to.contain(`help-text-${el._inputId}`);
@ -238,7 +255,7 @@ describe('<lion-field>', () => {
</${tag}>
`));
const nativeInput = Array.from(el.children).find(child => child.slot === 'input');
const nativeInput = getSlot(el, 'input');
expect(nativeInput.getAttribute('aria-labelledby')).to.contain(
`before-${el._inputId} after-${el._inputId}`,
);
@ -250,7 +267,7 @@ describe('<lion-field>', () => {
// TODO: Move test below to FormControlMixin.test.js.
it(`allows to add to aria description or label via addToAriaLabelledBy() and
addToAriaDescribedBy()`, async () => {
const wrapper = /** @type {LionField} */ (await fixture(html`
const wrapper = /** @type {HTMLElement} */ (await fixture(html`
<div id="wrapper">
<${tag}>
${inputSlot}
@ -260,7 +277,7 @@ describe('<lion-field>', () => {
<div id="additionalLabel"> This also needs to be read whenever the input has focus</div>
<div id="additionalDescription"> Same for this </div>
</div>`));
const el = wrapper.querySelector(tagString);
const el = /** @type {LionField} */ (wrapper.querySelector(tagString));
// wait until the field element is done rendering
await el.updateComplete;
await el.updateComplete;
@ -270,25 +287,33 @@ describe('<lion-field>', () => {
// 1. addToAriaLabel()
// Check if the aria attr is filled initially
expect(_inputNode.getAttribute('aria-labelledby')).to.contain(`label-${el._inputId}`);
el.addToAriaLabelledBy(wrapper.querySelector('#additionalLabel'));
const additionalLabel = /** @type {HTMLElement} */ (wrapper.querySelector(
'#additionalLabel',
));
el.addToAriaLabelledBy(additionalLabel);
const labelledbyAttr = /** @type {string} */ (_inputNode.getAttribute('aria-labelledby'));
// Now check if ids are added to the end (not overridden)
expect(_inputNode.getAttribute('aria-labelledby')).to.contain(`label-${el._inputId}`);
expect(labelledbyAttr).to.contain(`label-${el._inputId}`);
// Should be placed in the end
expect(
_inputNode.getAttribute('aria-labelledby').indexOf(`label-${el._inputId}`) <
_inputNode.getAttribute('aria-labelledby').indexOf('additionalLabel'),
labelledbyAttr.indexOf(`label-${el._inputId}`) < labelledbyAttr.indexOf('additionalLabel'),
);
// 2. addToAriaDescription()
// Check if the aria attr is filled initially
expect(_inputNode.getAttribute('aria-describedby')).to.contain(`feedback-${el._inputId}`);
el.addToAriaDescribedBy(wrapper.querySelector('#additionalDescription'));
const additionalDescription = /** @type {HTMLElement} */ (wrapper.querySelector(
'#additionalDescription',
));
el.addToAriaDescribedBy(additionalDescription);
const describedbyAttr = /** @type {string} */ (_inputNode.getAttribute('aria-describedby'));
// Now check if ids are added to the end (not overridden)
expect(_inputNode.getAttribute('aria-describedby')).to.contain(`feedback-${el._inputId}`);
expect(describedbyAttr).to.contain(`feedback-${el._inputId}`);
// Should be placed in the end
expect(
_inputNode.getAttribute('aria-describedby').indexOf(`feedback-${el._inputId}`) <
_inputNode.getAttribute('aria-describedby').indexOf('additionalDescription'),
describedbyAttr.indexOf(`feedback-${el._inputId}`) <
describedbyAttr.indexOf('additionalDescription'),
);
});
});
@ -310,6 +335,9 @@ describe('<lion-field>', () => {
return 'HasX';
}
/**
* @param {string} value
*/
execute(value) {
const result = value.indexOf('x') === -1;
return result;
@ -324,6 +352,10 @@ describe('<lion-field>', () => {
</${tag}>
`));
/**
* @param {import("../index.js").LionField} _sceneEl
* @param {{ index?: number; el: any; wantedShowsFeedbackFor: any; }} scenario
*/
const executeScenario = async (_sceneEl, scenario) => {
const sceneEl = _sceneEl;
sceneEl.resetInteractionState();
@ -372,6 +404,9 @@ describe('<lion-field>', () => {
return 'HasX';
}
/**
* @param {string} value
*/
execute(value) {
const result = value.indexOf('x') === -1;
return result;
@ -396,7 +431,7 @@ describe('<lion-field>', () => {
`));
expect(el.hasFeedbackFor).to.deep.equal(['error']);
expect(el.validationStates.error).to.have.a.property('HasX');
expect(el.validationStates.error.HasX).to.exist;
expect(disabledEl.hasFeedbackFor).to.deep.equal([]);
expect(disabledEl.validationStates.error).to.deep.equal({});
@ -408,6 +443,9 @@ describe('<lion-field>', () => {
return 'HasX';
}
/**
* @param {string} value
*/
execute(value) {
const result = value.indexOf('x') === -1;
return result;
@ -422,7 +460,7 @@ describe('<lion-field>', () => {
</${tag}>
`));
expect(el.hasFeedbackFor).to.deep.equal(['error']);
expect(el.validationStates.error).to.have.a.property('HasX');
expect(el.validationStates.error.HasX).to.exist;
el.disabled = true;
await el.updateComplete;
@ -437,10 +475,10 @@ describe('<lion-field>', () => {
>${inputSlot}</${tag}>
`));
expect(el.hasFeedbackFor).to.deep.equal(['error']);
expect(el.validationStates.error).to.have.a.property('Required');
expect(el.validationStates.error.Required).to.exist;
el.modelValue = 'cat';
expect(el.hasFeedbackFor).to.deep.equal([]);
expect(el.validationStates.error).not.to.have.a.property('Required');
expect(el.validationStates.error.Required).to.not.exist;
});
it('will only update formattedValue when valid on `user-input-changed`', async () => {
@ -450,6 +488,9 @@ describe('<lion-field>', () => {
return 'Bar';
}
/**
* @param {string} value
*/
execute(value) {
const hasError = value !== 'bar';
return hasError;
@ -502,8 +543,12 @@ describe('<lion-field>', () => {
'feedback',
];
names.forEach(slotName => {
el.querySelector(`[slot="${slotName}"]`).setAttribute('test-me', 'ok');
const slot = el.shadowRoot.querySelector(`slot[name="${slotName}"]`);
const slotLight = /** @type {HTMLElement} */ (el.querySelector(`[slot="${slotName}"]`));
slotLight.setAttribute('test-me', 'ok');
// @ts-expect-error
const slot = /** @type {ShadowHTMLElement} */ (el.shadowRoot.querySelector(
`slot[name="${slotName}"]`,
));
const assignedNodes = slot.assignedNodes();
expect(assignedNodes.length).to.equal(1);
expect(assignedNodes[0].getAttribute('test-me')).to.equal('ok');

View file

@ -8,6 +8,7 @@ describe('SyncUpdatableMixin', () => {
it('initializes all properties', async () => {
let hasCalledFirstUpdated = false;
let hasCalledUpdateSync = false;
// @ts-expect-error base constructors same return type
class UpdatableImplementation extends SyncUpdatableMixin(LitElement) {
static get properties() {
return {
@ -64,6 +65,7 @@ describe('SyncUpdatableMixin', () => {
it('guarantees Member Order Independence', async () => {
let hasCalledRunPropertyEffect = false;
// @ts-expect-error base constructors same return type
class UpdatableImplementation extends SyncUpdatableMixin(LitElement) {
static get properties() {
return {
@ -134,7 +136,7 @@ describe('SyncUpdatableMixin', () => {
let propChangedCount = 0;
let propUpdateSyncCount = 0;
// @ts-ignore the private override is on purpose
// @ts-expect-error the private override is on purpose
class UpdatableImplementation extends SyncUpdatableMixin(LitElement) {
static get properties() {
return {
@ -152,7 +154,6 @@ describe('SyncUpdatableMixin', () => {
* @param {*} oldValue
*/
requestUpdateInternal(name, oldValue) {
// @ts-ignore the private override is on purpose
super.requestUpdateInternal(name, oldValue);
if (name === 'prop') {
propChangedCount += 1;
@ -188,6 +189,7 @@ describe('SyncUpdatableMixin', () => {
describe('After firstUpdated', () => {
it('calls "updateSync" immediately when the observed property is changed (newValue !== oldValue)', async () => {
// @ts-expect-error
class UpdatableImplementation extends SyncUpdatableMixin(LitElement) {
static get properties() {
return {
@ -243,6 +245,7 @@ describe('SyncUpdatableMixin', () => {
describe('Features', () => {
// See: https://lit-element.polymer-project.org/guide/lifecycle#haschanged
it('supports "hasChanged" from UpdatingElement', async () => {
// @ts-expect-error base constructors same return type
class UpdatableImplementation extends SyncUpdatableMixin(LitElement) {
static get properties() {
return {

View file

@ -13,7 +13,7 @@ describe('Date Validation', () => {
it('provides new isDate() to allow only dates', () => {
let isEnabled;
const validator = new IsDate();
expect(validator.constructor.validatorName).to.equal('IsDate');
expect(IsDate.validatorName).to.equal('IsDate');
isEnabled = validator.execute(new Date());
expect(isEnabled).to.be.false;
@ -28,7 +28,7 @@ describe('Date Validation', () => {
it('provides new minDate(x) to allow only dates after min', () => {
let isEnabled;
const validator = new MinDate(new Date('2018/02/02'));
expect(validator.constructor.validatorName).to.equal('MinDate');
expect(MinDate.validatorName).to.equal('MinDate');
isEnabled = validator.execute(new Date('2018-02-03'));
expect(isEnabled).to.be.false;
@ -46,7 +46,7 @@ describe('Date Validation', () => {
it('provides maxDate() to allow only dates before max', () => {
let isEnabled;
const validator = new MaxDate(new Date('2018/02/02'));
expect(validator.constructor.validatorName).to.equal('MaxDate');
expect(MaxDate.validatorName).to.equal('MaxDate');
isEnabled = validator.execute(new Date('2018-02-01'));
expect(isEnabled).to.be.false;
@ -67,7 +67,7 @@ describe('Date Validation', () => {
min: new Date('2018/02/02'),
max: new Date('2018/02/04'),
});
expect(validator.constructor.validatorName).to.equal('MinMaxDate');
expect(MinMaxDate.validatorName).to.equal('MinMaxDate');
isEnabled = validator.execute(new Date('2018/02/03'));
expect(isEnabled).to.be.false;
@ -87,8 +87,8 @@ describe('Date Validation', () => {
it('provides new IsDateDisabled() to disable dates matching specified condition', () => {
let isDisabled;
const validator = new IsDateDisabled(d => d.getDate() === 3);
expect(validator.constructor.validatorName).to.equal('IsDateDisabled');
const validator = new IsDateDisabled(/** @param {Date} d */ d => d.getDate() === 3);
expect(IsDateDisabled.validatorName).to.equal('IsDateDisabled');
isDisabled = validator.execute(new Date('2018/02/04'));
expect(isDisabled).to.be.false;

View file

@ -11,7 +11,7 @@ describe('Number Validation', () => {
it('provides new IsNumber() to allow only numbers', () => {
let isEnabled;
const validator = new IsNumber();
expect(validator.constructor.validatorName).to.equal('IsNumber');
expect(IsNumber.validatorName).to.equal('IsNumber');
isEnabled = validator.execute(4);
expect(isEnabled).to.be.false;
@ -26,7 +26,7 @@ describe('Number Validation', () => {
it('provides new MinNumber(x) to allow only numbers longer then min', () => {
let isEnabled;
const validator = new MinNumber(3);
expect(validator.constructor.validatorName).to.equal('MinNumber');
expect(MinNumber.validatorName).to.equal('MinNumber');
isEnabled = validator.execute(3);
expect(isEnabled).to.be.false;
@ -38,7 +38,7 @@ describe('Number Validation', () => {
it('provides new MaxNumber(x) to allow only number shorter then max', () => {
let isEnabled;
const validator = new MaxNumber(3);
expect(validator.constructor.validatorName).to.equal('MaxNumber');
expect(MaxNumber.validatorName).to.equal('MaxNumber');
isEnabled = validator.execute(3);
expect(isEnabled).to.be.false;
@ -50,7 +50,7 @@ describe('Number Validation', () => {
it('provides new MinMaxNumber({ min: x, max: y}) to allow only numbers between min and max', () => {
let isEnabled;
const validator = new MinMaxNumber({ min: 2, max: 4 });
expect(validator.constructor.validatorName).to.equal('MinMaxNumber');
expect(MinMaxNumber.validatorName).to.equal('MinMaxNumber');
isEnabled = validator.execute(2);
expect(isEnabled).to.be.false;

View file

@ -3,18 +3,29 @@ import { ResultValidator } from '../../src/validate/ResultValidator.js';
import { Required } from '../../src/validate/validators/Required.js';
import { MinLength } from '../../src/validate/validators/StringValidators.js';
/**
* @typedef {import('../../src/validate/Validator').Validator} Validator
*/
describe('ResultValidator', () => {
it('has an "executeOnResults" function returning active state', async () => {
// This test shows the best practice of creating executeOnResults method
class MyResultValidator extends ResultValidator {
executeOnResults({ regularValidateResult, prevValidationResult }) {
const hasSuccess = regularValidateResult.length && !prevValidationResult.length;
return hasSuccess;
/**
*
* @param {Object} context
* @param {Validator[]} context.regularValidationResult
* @param {Validator[]} context.prevValidationResult
* @returns {boolean}
*/
executeOnResults({ regularValidationResult, prevValidationResult }) {
const hasSuccess = regularValidationResult.length && !prevValidationResult.length;
return !!hasSuccess;
}
}
expect(
new MyResultValidator().executeOnResults({
regularValidateResult: [new Required(), new MinLength(3)],
regularValidationResult: [new Required(), new MinLength(3)],
prevValidationResult: [],
}),
).to.be.true;

View file

@ -14,7 +14,7 @@ describe('String Validation', () => {
it('provides new IsString() to allow only strings', () => {
let isEnabled;
const validator = new IsString();
expect(validator.constructor.validatorName).to.equal('IsString');
expect(IsString.validatorName).to.equal('IsString');
isEnabled = validator.execute('foo');
expect(isEnabled).to.be.false;
@ -29,7 +29,7 @@ describe('String Validation', () => {
it('provides new EqualsLength(x) to allow only a specific string length', () => {
let isEnabled;
const validator = new EqualsLength(3);
expect(validator.constructor.validatorName).to.equal('EqualsLength');
expect(EqualsLength.validatorName).to.equal('EqualsLength');
isEnabled = validator.execute('foo');
expect(isEnabled).to.be.false;
@ -44,7 +44,7 @@ describe('String Validation', () => {
it('provides new MinLength(x) to allow only strings longer then min', () => {
let isEnabled;
const validator = new MinLength(3);
expect(validator.constructor.validatorName).to.equal('MinLength');
expect(MinLength.validatorName).to.equal('MinLength');
isEnabled = validator.execute('foo');
expect(isEnabled).to.be.false;
@ -56,7 +56,7 @@ describe('String Validation', () => {
it('provides new MaxLength(x) to allow only strings shorter then max', () => {
let isEnabled;
const validator = new MaxLength(3);
expect(validator.constructor.validatorName).to.equal('MaxLength');
expect(MaxLength.validatorName).to.equal('MaxLength');
isEnabled = validator.execute('foo');
expect(isEnabled).to.be.false;
@ -68,7 +68,7 @@ describe('String Validation', () => {
it('provides new MinMaxValidator({ min: x, max: y}) to allow only strings between min and max', () => {
let isEnabled;
const validator = new MinMaxLength({ min: 2, max: 4 });
expect(validator.constructor.validatorName).to.equal('MinMaxLength');
expect(MinMaxLength.validatorName).to.equal('MinMaxLength');
isEnabled = validator.execute('foo');
expect(isEnabled).to.be.false;
@ -83,7 +83,7 @@ describe('String Validation', () => {
it('provides new IsEmail() to allow only valid email formats', () => {
let isEnabled;
const validator = new IsEmail();
expect(validator.constructor.validatorName).to.equal('IsEmail');
expect(IsEmail.validatorName).to.equal('IsEmail');
isEnabled = validator.execute('foo@bar.com');
expect(isEnabled).to.be.false;
@ -116,7 +116,7 @@ describe('String Validation', () => {
it('provides new Pattern() to allow only valid patterns', () => {
let isEnabled;
let validator = new Pattern(/#LionRocks/);
expect(validator.constructor.validatorName).to.equal('Pattern');
expect(Pattern.validatorName).to.equal('Pattern');
isEnabled = validator.execute('#LionRocks');
expect(isEnabled).to.be.false;

View file

@ -1,9 +1,13 @@
import { expect, fixture, html, unsafeStatic, defineCE } from '@open-wc/testing';
import sinon from 'sinon';
import { LitElement } from '@lion/core';
import { defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
import sinon from 'sinon';
import { ValidateMixin } from '../../src/validate/ValidateMixin.js';
import { Validator } from '../../src/validate/Validator.js';
/**
* @param {function} method
* @param {string} errorMessage
*/
async function expectThrowsAsync(method, errorMessage) {
let error = null;
try {
@ -20,6 +24,10 @@ async function expectThrowsAsync(method, errorMessage) {
describe('Validator', () => {
it('has an "execute" function returning "shown" state', async () => {
class MyValidator extends Validator {
/**
* @param {string} [modelValue]
* @param {string} [param]
*/
execute(modelValue, param) {
const hasError = modelValue === 'test' && param === 'me';
return hasError;
@ -79,20 +87,24 @@ describe('Validator', () => {
});
it('has access to name, type, params, config in static get getMessage', () => {
let staticArgs;
let data;
class MyValidator extends Validator {
static get validatorName() {
return 'MyValidator';
}
static getMessage(...args) {
staticArgs = args;
/**
* @param {Object.<string,?>} _data
*/
static async getMessage(_data) {
data = _data;
return '';
}
}
const vali = new MyValidator('myParam', { my: 'config' });
vali._getMessage();
expect(staticArgs[0]).to.deep.equal({
expect(data).to.deep.equal({
name: 'MyValidator',
type: 'error',
params: 'myParam',
@ -103,7 +115,9 @@ describe('Validator', () => {
it('fires "param-changed" event on param change', async () => {
const vali = new Validator('foo');
const cb = sinon.spy(() => {});
if (vali.addEventListener) {
vali.addEventListener('param-changed', cb);
}
vali.param = 'bar';
expect(cb.callCount).to.equal(1);
});
@ -111,47 +125,41 @@ describe('Validator', () => {
it('fires "config-changed" event on config change', async () => {
const vali = new Validator('foo', { foo: 'bar' });
const cb = sinon.spy(() => {});
if (vali.addEventListener) {
vali.addEventListener('config-changed', cb);
}
vali.config = { bar: 'foo' };
expect(cb.callCount).to.equal(1);
});
it('has access to FormControl', async () => {
const lightDom = '';
const tagString = defineCE(
class extends ValidateMixin(LitElement) {
// @ts-expect-error base constructors same return type
class ValidateElement extends ValidateMixin(LitElement) {
static get properties() {
return { modelValue: String };
}
},
);
}
const tagString = defineCE(ValidateElement);
const tag = unsafeStatic(tagString);
class MyValidator extends Validator {
/**
* @param {string} modelValue
* @param {string} param
*/
execute(modelValue, param) {
const hasError = modelValue === 'forbidden' && param === 'values';
return hasError;
}
// eslint-disable-next-line
onFormControlConnect(formControl) {
// I could do something like:
// - add aria-required="true"
// - add type restriction for MaxLength(3, { isBlocking: true })
}
// eslint-disable-next-line
onFormControlDisconnect(formControl) {
// I will cleanup what I did in connect
}
}
const myVal = new MyValidator();
const connectSpy = sinon.spy(myVal, 'onFormControlConnect');
const disconnectSpy = sinon.spy(myVal, 'onFormControlDisconnect');
const el = await fixture(html`
const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} .validators=${[myVal]}>${lightDom}</${tag}>
`);
`));
expect(connectSpy.callCount).to.equal(1);
expect(connectSpy.calledWith(el)).to.equal(true);
@ -171,6 +179,9 @@ describe('Validator', () => {
it('supports customized types', async () => {
// This test shows the best practice of adding custom types
class MyValidator extends Validator {
/**
* @param {...any} args
*/
constructor(...args) {
super(...args);
this.type = 'my-type';

View file

@ -4,9 +4,15 @@ import sinon from 'sinon';
import '../../lion-validation-feedback.js';
import { AlwaysInvalid, AlwaysValid } from '../../test-helpers.js';
/**
* @typedef {import('../../src/validate/LionValidationFeedback').LionValidationFeedback} LionValidationFeedback
*/
describe('lion-validation-feedback', () => {
it('renders a validation message', async () => {
const el = await fixture(html`<lion-validation-feedback></lion-validation-feedback>`);
const el = /** @type {LionValidationFeedback} */ (await fixture(
html`<lion-validation-feedback></lion-validation-feedback>`,
));
expect(el).shadowDom.to.equal('');
el.feedbackData = [{ message: 'hello', type: 'error', validator: new AlwaysInvalid() }];
await el.updateComplete;
@ -14,7 +20,9 @@ describe('lion-validation-feedback', () => {
});
it('renders the validation type attribute', async () => {
const el = await fixture(html`<lion-validation-feedback></lion-validation-feedback>`);
const el = /** @type {LionValidationFeedback} */ (await fixture(
html`<lion-validation-feedback></lion-validation-feedback>`,
));
el.feedbackData = [{ message: 'hello', type: 'error', validator: new AlwaysInvalid() }];
await el.updateComplete;
expect(el.getAttribute('type')).to.equal('error');
@ -25,7 +33,9 @@ describe('lion-validation-feedback', () => {
});
it('success message clears after 3s', async () => {
const el = await fixture(html`<lion-validation-feedback></lion-validation-feedback>`);
const el = /** @type {LionValidationFeedback} */ (await fixture(
html`<lion-validation-feedback></lion-validation-feedback>`,
));
const clock = sinon.useFakeTimers();
@ -45,7 +55,9 @@ describe('lion-validation-feedback', () => {
});
it('does not clear error messages', async () => {
const el = await fixture(html`<lion-validation-feedback></lion-validation-feedback>`);
const el = /** @type {LionValidationFeedback} */ (await fixture(
html`<lion-validation-feedback></lion-validation-feedback>`,
));
const clock = sinon.useFakeTimers();

View file

@ -1,13 +1,8 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { LitElement } from '@lion/core';
import { FormControlHost } from './FormControlMixinTypes';
export declare class FocusHost {
static properties: {
focused: {
type: BooleanConstructor;
reflect: boolean;
};
};
focused: boolean;
connectedCallback(): void;
@ -23,6 +18,6 @@ export declare class FocusHost {
export declare function FocusImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<FocusHost> & FocusHost;
): T & Constructor<FocusHost> & FocusHost & Constructor<FormControlHost> & typeof FormControlHost;
export type FocusMixin = typeof FocusImplementation;

View file

@ -1,35 +1,16 @@
import { CSSResult, LitElement, nothing, TemplateResult } from '@lion/core';
import { SlotsMap, SlotHost } from '@lion/core/types/SlotMixinTypes';
import { Constructor } from '@open-wc/dedupe-mixin';
import { SlotsMap } from '@lion/core/types/SlotMixinTypes';
import { LitElement, CSSResult, TemplateResult, nothing } from '@lion/core';
import { DisabledHost } from '@lion/core/types/DisabledMixinTypes';
export class FormControlMixinHost {
static get properties(): {
name: {
type: StringConstructor;
reflect: boolean;
};
label: {
attribute: boolean;
};
helpText: {
type: StringConstructor;
attribute: string;
};
_ariaLabelledNodes: {
attribute: boolean;
};
_ariaDescribedNodes: {
attribute: boolean;
};
_repropagationRole: {
attribute: boolean;
};
_isRepropagationEndpoint: {
attribute: boolean;
};
};
import { LionValidationFeedback } from '../src/validate/LionValidationFeedback';
import { FormRegisteringHost } from './registration/FormRegisteringMixinTypes';
export class FormControlHost {
static get styles(): CSSResult | CSSResult[];
name: string;
modelValue: unknown;
set label(arg: string);
get label(): string;
__label: string | undefined;
@ -43,11 +24,12 @@ export class FormControlMixinHost {
get _inputNode(): HTMLElement;
get _labelNode(): HTMLElement;
get _helpTextNode(): HTMLElement;
get _feedbackNode(): HTMLElement;
get _feedbackNode(): LionValidationFeedback | undefined;
_inputId: string;
_ariaLabelledNodes: HTMLElement[];
_ariaDescribedNodes: HTMLElement[];
_repropagationRole: 'child' | 'choice-group' | 'fieldset';
_repropagationRole: string; // 'child' | 'choice-group' | 'fieldset';
_isRepropagationEndpoint: boolean;
connectedCallback(): void;
updated(changedProperties: import('lit-element').PropertyValues): void;
@ -99,6 +81,14 @@ export class FormControlMixinHost {
export declare function FormControlImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<FormControlMixinHost> & FormControlMixinHost;
): T &
Constructor<FormControlHost> &
FormControlHost &
Constructor<FormRegisteringHost> &
typeof FormRegisteringHost &
Constructor<DisabledHost> &
typeof DisabledHost &
Constructor<SlotHost> &
typeof SlotHost;
export type FormControlMixin = typeof FormControlImplementation;

View file

@ -1,5 +1,7 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { LitElement } from '@lion/core';
import { ValidateHost } from './validate/ValidateMixinTypes';
import { FormControlHost } from './FormControlMixinTypes';
export declare interface FormatOptions {
locale?: string;
@ -7,15 +9,6 @@ export declare interface FormatOptions {
}
export declare class FormatHost {
static properties: {
modelValue: { attribute: false };
formattedValue: { attribute: false };
serializedValue: { attribute: false };
formatOn: { attribute: false };
formatOptions: { attribute: false };
};
modelValue: unknown;
formattedValue: string;
serializedValue: string;
formatOn: string;
@ -32,7 +25,7 @@ export declare class FormatHost {
_calculateValues(opts: { source: 'model' | 'serialized' | 'formatted' | null }): void;
__callParser(value: string | undefined): object;
__callFormatter(): string;
_onModelValueChanged(args: { modelValue: unknown }[]): void;
_onModelValueChanged(arg: { modelValue: unknown }): void;
_dispatchModelValueChangedEvent(): void;
_syncValueUpwards(): void;
_reflectBackFormattedValueToUser(): void;
@ -47,6 +40,12 @@ export declare class FormatHost {
export declare function FormatImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<FormatHost> & FormatHost;
): T &
Constructor<FormatHost> &
FormatHost &
Constructor<ValidateHost> &
typeof ValidateHost &
Constructor<FormControlHost> &
typeof FormControlHost;
export type FormatMixin = typeof FormatImplementation;

View file

@ -1,27 +1,8 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { LitElement } from '@lion/core';
import { FormControlHost } from './FormControlMixinTypes';
export declare class InteractionStateHost {
static get properties(): {
touched: {
type: BooleanConstructor;
reflect: true;
};
dirty: {
type: BooleanConstructor;
reflect: true;
};
filled: {
type: BooleanConstructor;
reflect: true;
};
prefilled: {
attribute: false;
};
submitted: {
attribute: false;
};
};
prefilled: boolean;
filled: boolean;
touched: boolean;
@ -43,6 +24,10 @@ export declare class InteractionStateHost {
export declare function InteractionStateImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<InteractionStateHost> & InteractionStateHost;
): T &
Constructor<InteractionStateHost> &
InteractionStateHost &
Constructor<FormControlHost> &
typeof FormControlHost;
export type InteractionStateMixin = typeof InteractionStateImplementation;

View file

@ -0,0 +1,62 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { LitElement } from '@lion/core';
import { FormControlHost } from '../FormControlMixinTypes';
import { FormRegistrarHost } from '../registration/FormRegistrarMixinTypes';
import { InteractionStateHost } from '../InteractionStateMixinTypes';
export declare class ChoiceGroupHost {
multipleChoice: boolean;
connectedCallback(): void;
disconnectedCallback(): void;
get modelValue(): any;
set modelValue(value: any);
get serializedValue(): string;
set serializedValue(value: string);
get formattedValue(): string;
set formattedValue(value: string);
connectedCallback(): void;
disconnectedCallback(): void;
addFormElement(child: FormControlHost, indexToInsertAt: number): void;
_triggerInitialModelValueChangedEvent(): void;
_getFromAllFormElements(property: string, filterCondition: Function): void;
_throwWhenInvalidChildModelValue(child: FormControlHost): void;
_isEmpty(): void;
_checkSingleChoiceElements(ev: Event): void;
_getCheckedElements(): void;
_setCheckedElements(value: any, check: boolean): void;
__setChoiceGroupTouched(): void;
__delegateNameAttribute(child: FormControlHost): void;
_onBeforeRepropagateChildrenValues(ev: Event): void;
}
export declare function ChoiceGroupImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T &
Constructor<ChoiceGroupHost> &
ChoiceGroupHost &
Constructor<FormRegistrarHost> &
typeof FormRegistrarHost &
Constructor<InteractionStateHost> &
typeof InteractionStateHost;
export type ChoiceGroupMixin = typeof ChoiceGroupImplementation;

View file

@ -0,0 +1,69 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { CSSResult, LitElement, TemplateResult } from '@lion/core';
import { FormatHost } from '../FormatMixinTypes';
export interface ChoiceInputModelValue {
checked: boolean;
value: any;
}
export declare class ChoiceInputHost {
modelValue: ChoiceInputModelValue;
get choiceValue(): any;
set choiceValue(value: any);
protected requestUpdateInternal(name: string, oldValue: any): void;
firstUpdated(changedProperties: Map<string, any>): void;
updated(changedProperties: Map<string, any>): void;
static get styles(): CSSResult | CSSResult[];
render(): TemplateResult;
_choiceGraphicTemplate(): TemplateResult;
connectedCallback(): void;
disconnectedCallback(): void;
__toggleChecked(): void;
__syncModelCheckedToChecked(checked: boolean): void;
__syncCheckedToModel(checked: boolean): void;
__syncCheckedToInputElement(): void;
_proxyInputEvent(): void;
_onModelValueChanged(
newV: { modelValue: ChoiceInputModelValue },
oldV: { modelValue: ChoiceInputModelValue },
): void;
parser(): any;
formatter(modelValue: ChoiceInputModelValue): string;
_isEmpty(): void;
_syncValueUpwards(): void;
type: string;
_inputNode: HTMLInputElement;
}
export declare function ChoiceInputImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T &
Constructor<ChoiceInputHost> &
ChoiceInputHost &
Constructor<FormatHost> &
FormatHost &
HTMLElement;
export type ChoiceInputMixin = typeof ChoiceInputImplementation;

View file

@ -0,0 +1,43 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { LitElement } from '@lion/core';
import { DisabledHost } from '@lion/core/types/DisabledMixinTypes';
import { SlotHost } from '@lion/core/types/SlotMixinTypes';
import { FormControlHost } from '../FormControlMixinTypes';
import { FormRegistrarHost } from '../registration/FormRegistrarMixinTypes';
import { ValidateHost } from '../validate/ValidateMixinTypes';
export declare class FormGroupHost {
protected static _addDescriptionElementIdsToField(): void;
get _inputNode(): HTMLElement;
resetGroup(): void;
prefilled: boolean;
touched: boolean;
dirty: boolean;
submitted: boolean;
serializedValue: string;
modelValue: { [x: string]: any };
formattedValue: string;
children: Array<HTMLElement & FormControlHost>;
_initialModelValue: { [x: string]: any };
_setValueForAllFormElements(property: string, value: any): void;
resetInteractionState(): void;
clearGroup(): void;
}
export declare function FormGroupImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T &
Constructor<FormGroupHost> &
FormGroupHost &
Constructor<FormRegistrarHost> &
typeof FormRegistrarHost &
Constructor<FormControlHost> &
typeof FormControlHost &
Constructor<ValidateHost> &
typeof ValidateHost &
Constructor<DisabledHost> &
typeof DisabledHost &
Constructor<SlotHost> &
typeof SlotHost;
export type FormGroupMixin = typeof FormGroupImplementation;

View file

@ -1,12 +1,15 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { FormRegistrarHost } from './FormRegistrarMixinTypes';
import { LitElement } from '@lion/core';
export declare class FormRegisteringHost {
connectedCallback(): void;
disconnectedCallback(): void;
__parentFormGroup?: FormRegistrarHost;
}
export declare function FormRegisteringImplementation<T extends Constructor<HTMLElement>>(
export declare function FormRegisteringImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<FormRegisteringHost> & FormRegisteringHost;
): T & Constructor<FormRegisteringHost> & typeof FormRegisteringHost;
export type FormRegisteringMixin = typeof FormRegisteringImplementation;

View file

@ -1,27 +1,35 @@
import { Constructor } from '@open-wc/dedupe-mixin';
export declare class FormControlsCollection {
_keys(): string[];
}
import { FormControlsCollection } from '../../src/registration/FormControlsCollection';
import { FormRegisteringHost } from '../../types/registration/FormRegisteringMixinTypes';
import { FormControlHost } from '../../types/FormControlMixinTypes';
import { LitElement } from '@lion/core';
export declare class ElementWithParentFormGroup {
__parentFormGroup: FormRegistrarHost;
}
export declare class FormRegistrarHost {
static get properties(): {
_isFormOrFieldset: {
type: BooleanConstructor;
reflect: boolean;
};
};
_isFormOrFieldset: boolean;
formElements: FormControlsCollection;
addFormElement(child: HTMLElement & ElementWithParentFormGroup, indexToInsertAt: number): void;
formElements: FormControlsCollection & { [x: string]: any };
addFormElement(
child:
| (FormControlHost & ElementWithParentFormGroup)
| (FormControlHost & HTMLElement)
| (HTMLElement & ElementWithParentFormGroup),
indexToInsertAt?: number,
): void;
removeFormElement(child: FormRegisteringHost): void;
_onRequestToAddFormElement(e: CustomEvent): void;
isRegisteredFormElement(el: FormControlHost): boolean;
registrationComplete: Promise<boolean>;
}
export declare function FormRegistrarImplementation<T extends Constructor<HTMLElement>>(
export declare function FormRegistrarImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<FormRegistrarHost> & FormRegistrarHost;
): T &
Constructor<FormRegistrarHost> &
typeof FormRegistrarHost &
Constructor<FormRegisteringHost> &
typeof FormRegisteringHost;
export type FormRegistrarMixin = typeof FormRegistrarImplementation;

View file

@ -1,10 +1,11 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { LitElement } from '@lion/core';
export declare class FormRegistrarPortalHost {
__redispatchEventForFormRegistrarPortalMixin(ev: CustomEvent): void;
}
export declare function FormRegistrarPortalImplementation<T extends Constructor<HTMLElement>>(
export declare function FormRegistrarPortalImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<FormRegistrarPortalHost> & FormRegistrarPortalHost;

View file

@ -23,6 +23,6 @@ export type SyncUpdatableHostType = typeof SyncUpdatableHost;
export declare function SyncUpdatableImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<SyncUpdatableHost> & SyncUpdatableHost;
): T & Constructor<SyncUpdatableHost> & typeof SyncUpdatableHost;
export type SyncUpdatableMixin = typeof SyncUpdatableImplementation;

View file

@ -0,0 +1,90 @@
import { LitElement } from '@lion/core';
import { DisabledHost } from '@lion/core/types/DisabledMixinTypes';
import { SlotsMap, SlotHost } from '@lion/core/types/SlotMixinTypes';
import { Constructor } from '@open-wc/dedupe-mixin';
import { ScopedElementsHost } from '@open-wc/scoped-elements/src/types';
import { FormControlHost } from '../FormControlMixinTypes';
import { LionValidationFeedback } from '../../src/validate/LionValidationFeedback';
import { SyncUpdatableHost } from '../utils/SyncUpdatableMixinTypes';
import { Validator } from '../../src/validate/Validator';
type ScopedElementsMap = {
[key: string]: typeof HTMLElement;
};
type FeedbackMessage = {
message: string | Node;
type: string;
validator?: Validator;
};
export declare class ValidateHost {
validators: Validator[];
hasFeedbackFor: string[];
shouldShowFeedbackFor: string[];
showsFeedbackFor: string[];
validationStates: { [key: string]: { [key: string]: Object } };
isPending: boolean;
defaultValidators: Validator[];
_visibleMessagesAmount: number;
fieldName: string;
static validationTypes: string[];
slots: SlotsMap;
_feedbackNode: LionValidationFeedback;
_allValidators: Validator[];
__syncValidationResult: Validator[];
__asyncValidationResult: Validator[];
__validationResult: Validator[];
__prevValidationResult: Validator[];
connectedCallback(): void;
disconnectedCallback(): void;
firstUpdated(changedProperties: import('lit-element').PropertyValues): void;
updateSync(name: string, oldValue: unknown): void;
updated(changedProperties: import('lit-element').PropertyValues): void;
validate(opts?: { clearCurrentResult?: boolean }): void;
__storePrevResult(): void;
__executeValidators(): void;
validateComplete: Promise<void>;
feedbackComplete: Promise<void>;
__validateCompleteResolve(): void;
__executeSyncValidators(
syncValidators: Validator[],
value: unknown,
opts: { hasAsync: boolean },
): void;
__executeAsyncValidators(asyncValidators: Validator[], value: unknown): void;
__executeResultValidators(regularValidationResult: Validator[]): Validator[];
__finishValidation(options: { source: 'sync' | 'async'; hasAsync?: boolean }): void;
__clearValidationResults(): void;
__onValidatorUpdated(e: Event | CustomEvent): void;
__setupValidators(): void;
__isEmpty(v: unknown): boolean;
__getFeedbackMessages(validators: Validator[]): Promise<FeedbackMessage[]>;
_updateFeedbackComponent(): void;
_showFeedbackConditionFor(type: string): boolean;
_hasFeedbackVisibleFor(type: string): boolean;
_updateShouldShowFeedbackFor(): void;
_prioritizeAndFilterFeedback(opts: { validationResult: Validator[] }): Validator[];
}
export declare function ValidateImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T &
Constructor<ValidateHost> &
typeof ValidateHost &
Constructor<FormControlHost> &
typeof FormControlHost &
Constructor<SyncUpdatableHost> &
typeof SyncUpdatableHost &
Constructor<DisabledHost> &
typeof DisabledHost &
Constructor<SlotHost> &
typeof SlotHost &
Constructor<ScopedElementsHost> &
typeof ScopedElementsHost;
export type ValidateMixin = typeof ValidateImplementation;

View file

@ -163,9 +163,7 @@ export const feedbackCondition = () => {
</form>
</lion-form>
<h-output .field="${fieldElement}" .show="${[...props, 'hasFeedbackFor']}"> </h-output>
<h3>
Set conditions for validation feedback visibility
</h3>
<h3>Set conditions for validation feedback visibility</h3>
<lion-checkbox-group name="props[]" @model-value-changed="${fetchConditionsAndReevaluate}">
${props.map(p => html` <lion-checkbox .label="${p}" .choiceValue="${p}"> </lion-checkbox> `)}
</lion-checkbox-group>

View file

@ -53,9 +53,7 @@ export const disabled = () => {
<lion-input name="LastName2" label="Last Name" .modelValue=${'Bar'}></lion-input>
</lion-fieldset>
</lion-fieldset>
<button @click=${toggleDisabled}>
Toggle disabled
</button>
<button @click=${toggleDisabled}>Toggle disabled</button>
`;
};
```

View file

@ -113,9 +113,7 @@ describe(`Submitting/Resetting Form`, async () => {
></lion-textarea>
<div class="buttons">
<lion-button id="submit_button" type="submit" raised>Submit</lion-button>
<lion-button id="reset_button" type="reset" raised>
Reset
</lion-button>
<lion-button id="reset_button" type="reset" raised> Reset </lion-button>
</div>
</form>
</lion-form>

View file

@ -21,9 +21,7 @@ export class SbLocaleSwitcher extends LitElement {
return html`
${this.showLocales.map(
showLocale => html`
<button @click=${() => this.callback(showLocale)}>
${showLocale}
</button>
<button @click=${() => this.callback(showLocale)}>${showLocale}</button>
`,
)}
`;

View file

@ -225,9 +225,7 @@ export class LionInputDatepicker extends ScopedElementsMixin(OverlayMixin(LionIn
render() {
return html`
<div class="form-field__group-one">
${this._groupOneTemplate()}
</div>
<div class="form-field__group-one">${this._groupOneTemplate()}</div>
<div class="form-field__group-two">
${this._groupTwoTemplate()} ${this._overlayTemplate()}
</div>

View file

@ -79,11 +79,7 @@ class MyHelloComponent extends LocalizeMixin(LitElement) {
}
render() {
return html`
<div>
${this.msgLit('my-hello-component:greeting')}
</div>
`;
return html` <div>${this.msgLit('my-hello-component:greeting')}</div> `;
}
}
```
@ -112,9 +108,7 @@ import { localize } from '@lion/localize';
export function myTemplate(someData) {
return html`
<div>
${localize.msg('my-hello-component:feeling', { feeling: someData.feeling })}
</div>
<div>${localize.msg('my-hello-component:feeling', { feeling: someData.feeling })}</div>
`;
}
```

View file

@ -1,3 +1,4 @@
// @ts-expect-error no types for this package
import MessageFormat from '@bundled-es-modules/message-format/MessageFormat.js';
import isLocalizeESModule from './isLocalizeESModule.js';

View file

@ -19,9 +19,9 @@ export function getDateFormatBasedOnLocale() {
function getPartByIndex(index) {
/** @type {Object.<string,string>} */
const template = {
'2012': 'year',
'12': 'month',
'20': 'day',
2012: 'year',
12: 'month',
20: 'day',
};
const key = dateParts[index];
return template[key];

View file

@ -1,5 +1,6 @@
import { expect, oneEvent, aTimeout } from '@open-wc/testing';
import sinon from 'sinon';
// @ts-expect-error no types for this package
import { fetchMock } from '@bundled-es-modules/fetch-mock';
import { setupFakeImport, resetFakeImport, fakeImport } from '../test-helpers.js';

View file

@ -71,7 +71,7 @@ describe('LocalizeMixin', () => {
'child-element': loc => fakeImport(`./child-element/${loc}.js`),
};
// @ts-ignore
// @ts-expect-error
class ParentElement extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [parentElementNs, defaultNs, ...super.localizeNamespaces];
@ -79,7 +79,7 @@ describe('LocalizeMixin', () => {
}
const tagString = defineCE(
// @ts-ignore
// @ts-expect-error
class ChildElement extends LocalizeMixin(ParentElement) {
static get localizeNamespaces() {
return [childElementNs, defaultNs, ...super.localizeNamespaces];

View file

@ -91,9 +91,7 @@ export const isTooltip = () => {
<button slot="invoker" @mouseenter="${showTooltip}" @mouseleave="${hideTooltip}">
Hover me to open the tooltip!
</button>
<div slot="content" class="demo-overlay">
Hello!
</div>
<div slot="content" class="demo-overlay">Hello!</div>
</demo-overlay-system>
`;
};

View file

@ -167,11 +167,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
function sendCloseEvent(e) {
e.target.dispatchEvent(new Event('close-overlay', { bubbles: true }));
}
const closeBtn = await fixture(html`
<button @click=${sendCloseEvent}>
close
</button>
`);
const closeBtn = await fixture(html` <button @click=${sendCloseEvent}>close</button> `);
const el = await fixture(html`
<${tag} opened>

View file

@ -67,11 +67,7 @@ describe('OverlayController', () => {
`);
}
if (mode === 'inline') {
contentNode = await fixture(html`
<div>
I should be on top
</div>
`);
contentNode = await fixture(html` <div>I should be on top</div> `);
contentNode.style.zIndex = zIndexVal;
}
return contentNode;

View file

@ -103,9 +103,7 @@ describe('Local Positioning', () => {
},
});
await fixture(html`
<div style="position: absolute; top: 0;">
${ctrl.invokerNode}${ctrl.content}
</div>
<div style="position: absolute; top: 0;">${ctrl.invokerNode}${ctrl.content}</div>
`);
await ctrl.show();

View file

@ -35,9 +35,7 @@ const interactionElementsNode = renderLitAsNode(html`
const lightDomTemplate = html`
<div>
<button id="outside-1">outside 1</button>
<div id="rootElement">
${interactionElementsNode}
</div>
<div id="rootElement">${interactionElementsNode}</div>
<button id="outside-2">outside 2</button>
</div>
`;

View file

@ -94,17 +94,11 @@ export const methods = () => {
<button @click=${() => document.getElementById('pagination-method').previous()}>
Previous
</button>
<button @click=${() => document.getElementById('pagination-method').next()}>
Next
</button>
<button @click=${() => document.getElementById('pagination-method').next()}>Next</button>
<br />
<br />
<button @click=${() => document.getElementById('pagination-method').first()}>
First
</button>
<button @click=${() => document.getElementById('pagination-method').last()}>
Last
</button>
<button @click=${() => document.getElementById('pagination-method').first()}>First</button>
<button @click=${() => document.getElementById('pagination-method').last()}>Last</button>
<br />
<br />
<button @click=${() => document.getElementById('pagination-method').goto(55)}>

View file

@ -204,9 +204,7 @@ class PBoard extends DecorateMixin(LitElement) {
<h1 class="heading">providence <span class="heading__part">dashboard</span> (alpha)</h1>
<div class="u-ml2">
${this._activeAnalyzerSelectTemplate()}
<button @click="${() => downloadFile('data.csv', this._createCsv())}">
get csv
</button>
<button @click="${() => downloadFile('data.csv', this._createCsv())}">get csv</button>
</div>
</div>
${this._selectionMenuTemplate(this.__menuData)}

View file

@ -367,9 +367,7 @@ You can use this `selectedElement` to then render the content to your own invoke
```html
<lion-select-rich>
<my-invoker-button slot="invoker"></my-invoker-button>
<lion-options slot="input">
...
</lion-options>
<lion-options slot="input"> ... </lion-options>
</lion-select-rich>
```

View file

@ -51,6 +51,14 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
];
}
/**
* @override We want to start with a clean slate, so we omit slots inherited from FormControl
*/
// eslint-disable-next-line class-methods-use-this
get slots() {
return {};
}
constructor() {
super();
this.active = false;

View file

@ -95,7 +95,7 @@ export class LionSelectInvoker extends LionButton {
_contentTemplate() {
if (this.selectedElement) {
const labelNodes = Array.from(this.selectedElement.querySelectorAll('*'));
const labelNodes = Array.from(this.selectedElement.childNodes);
if (labelNodes.length > 0) {
return labelNodes.map(node => node.cloneNode(true));
}
@ -113,11 +113,7 @@ export class LionSelectInvoker extends LionButton {
}
_beforeTemplate() {
return html`
<div id="content-wrapper">
${this._contentTemplate()}
</div>
`;
return html` <div id="content-wrapper">${this._contentTemplate()}</div> `;
}
// eslint-disable-next-line class-methods-use-this

View file

@ -11,11 +11,14 @@ describe('lion-select-invoker', () => {
it('renders invoker info based on selectedElement child elements', async () => {
const el = await fixture(html`<lion-select-invoker></lion-select-invoker>`);
el.selectedElement = await fixture(`<div class="option"><h2>I am</h2><p>2 lines</p></div>`);
el.selectedElement = await fixture(
`<div class="option">Textnode<h2>I am</h2><p>2 lines</p></div>`,
);
await el.updateComplete;
expect(el._contentWrapperNode).lightDom.to.equal(
`
Textnode
<h2>I am</h2>
<p>2 lines</p>
`,

View file

@ -38,12 +38,8 @@ export class LionSwitch extends ScopedElementsMixin(ChoiceInputMixin(LionField))
*/
render() {
return html`
<div class="form-field__group-one">
${this._groupOneTemplate()}
</div>
<div class="form-field__group-two">
${this._groupTwoTemplate()}
</div>
<div class="form-field__group-one">${this._groupOneTemplate()}</div>
<div class="form-field__group-two">${this._groupTwoTemplate()}</div>
`;
}

Some files were not shown because too many files have changed in this diff Show more