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 ```html
<lea-tabs> <lea-tabs>
<lea-tab slot="tab">Info</lea-tab> <lea-tab slot="tab">Info</lea-tab>
<lea-tab-panel slot="panel"> <lea-tab-panel slot="panel"> Info page with lots of information about us. </lea-tab-panel>
Info page with lots of information about us.
</lea-tab-panel>
<lea-tab slot="tab">Work</lea-tab> <lea-tab slot="tab">Work</lea-tab>
<lea-tab-panel slot="panel"> <lea-tab-panel slot="panel"> Work page that showcases our work. </lea-tab-panel>
Work page that showcases our work.
</lea-tab-panel>
</lea-tabs> </lea-tabs>
``` ```

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,7 +30,7 @@
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.2.18", "@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-element": "~2.4.0",
"lit-html": "^1.3.0" "lit-html": "^1.3.0"
}, },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,13 @@
import { dedupeMixin } from '@lion/core'; import { dedupeMixin } from '@lion/core';
import { FormControlMixin } from './FormControlMixin.js';
/** /**
* @typedef {import('../types/FocusMixinTypes').FocusMixin} FocusMixin * @typedef {import('../types/FocusMixinTypes').FocusMixin} FocusMixin
* @type {FocusMixin} * @type {FocusMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/ */
const FocusMixinImplementation = superclass => const FocusMixinImplementation = superclass =>
// eslint-disable-next-line no-unused-vars, max-len, no-shadow // eslint-disable-next-line no-unused-vars, max-len, no-shadow
class FocusMixin extends superclass { class FocusMixin extends FormControlMixin(superclass) {
static get properties() { static get properties() {
return { return {
focused: { focused: {
@ -21,16 +23,12 @@ const FocusMixinImplementation = superclass =>
} }
connectedCallback() { connectedCallback() {
if (super.connectedCallback) { super.connectedCallback();
super.connectedCallback();
}
this.__registerEventsForFocusMixin(); this.__registerEventsForFocusMixin();
} }
disconnectedCallback() { disconnectedCallback() {
if (super.disconnectedCallback) { super.disconnectedCallback();
super.disconnectedCallback();
}
this.__teardownEventsForFocusMixin(); this.__teardownEventsForFocusMixin();
} }
@ -101,10 +99,22 @@ const FocusMixinImplementation = superclass =>
} }
__teardownEventsForFocusMixin() { __teardownEventsForFocusMixin() {
this._inputNode.removeEventListener('focus', this.__redispatchFocus); this._inputNode.removeEventListener(
this._inputNode.removeEventListener('blur', this.__redispatchBlur); 'focus',
this._inputNode.removeEventListener('focusin', this.__redispatchFocusin); /** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocus),
this._inputNode.removeEventListener('focusout', this.__redispatchFocusout); );
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 { 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 { FormRegisteringMixin } from './registration/FormRegisteringMixin.js';
import { getAriaElementsInRightDomOrder } from './utils/getAriaElementsInRightDomOrder.js'; import { getAriaElementsInRightDomOrder } from './utils/getAriaElementsInRightDomOrder.js';
import { Unparseable } from './validate/Unparseable.js';
/** /**
* Generates random unique identifier (for dom elements) * 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: * This Mixin is a shared fundament for all form components, it's applied on:
* - LionField (which is extended to LionInput, LionTextarea, LionSelect etc. etc.) * - LionField (which is extended to LionInput, LionTextarea, LionSelect etc. etc.)
* - LionFieldset (which is extended to LionRadioGroup, LionCheckboxGroup, LionForm) * - LionFieldset (which is extended to LionRadioGroup, LionCheckboxGroup, LionForm)
* @typedef {import('lit-html').TemplateResult} TemplateResult * @typedef {import('@lion/core').TemplateResult} TemplateResult
* @typedef {import('lit-element').CSSResult} CSSResult * @typedef {import('@lion/core').CSSResult} CSSResult
* @typedef {import('lit-html').nothing} nothing * @typedef {import('@lion/core').nothing} nothing
* @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap * @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap
* @typedef {import('../types/FormControlMixinTypes.js').FormControlMixin} FormControlMixin * @typedef {import('../types/FormControlMixinTypes.js').FormControlMixin} FormControlMixin
* @type {FormControlMixin} * @type {FormControlMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/ */
const FormControlMixinImplementation = superclass => const FormControlMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars // 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() { static get properties() {
return { return {
/** /**
@ -48,6 +50,21 @@ const FormControlMixinImplementation = superclass =>
type: String, type: String,
attribute: 'help-text', 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` * Contains all elements that should end up in aria-labelledby of `._inputNode`
*/ */
@ -112,7 +129,8 @@ const FormControlMixinImplementation = superclass =>
* @return {string} * @return {string}
*/ */
get fieldName() { 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() { get _feedbackNode() {
return this.__getDirectSlotChild('feedback'); return /** @type {import('./validate/LionValidationFeedback').LionValidationFeedback | undefined} */ (this.__getDirectSlotChild(
'feedback',
));
} }
constructor() { constructor() {
@ -197,7 +217,11 @@ const FormControlMixinImplementation = superclass =>
this._ariaDescribedNodes = []; this._ariaDescribedNodes = [];
/** @type {'child' | 'choice-group' | 'fieldset'} */ /** @type {'child' | 'choice-group' | 'fieldset'} */
this._repropagationRole = 'child'; this._repropagationRole = 'child';
this.addEventListener('model-value-changed', this.__repropagateChildrenValues); this._isRepropagationEndpoint = false;
this.addEventListener(
'model-value-changed',
/** @type {EventListenerOrEventListenerObject} */ (this.__repropagateChildrenValues),
);
} }
connectedCallback() { connectedCallback() {
@ -339,12 +363,8 @@ const FormControlMixinImplementation = superclass =>
*/ */
render() { render() {
return html` return html`
<div class="form-field__group-one"> <div class="form-field__group-one">${this._groupOneTemplate()}</div>
${this._groupOneTemplate()} <div class="form-field__group-two">${this._groupTwoTemplate()}</div>
</div>
<div class="form-field__group-two">
${this._groupTwoTemplate()}
</div>
`; `;
} }
@ -479,10 +499,15 @@ const FormControlMixinImplementation = superclass =>
/** /**
* @param {?} modelValue * @param {?} modelValue
* @return {boolean} * @return {boolean}
*
* FIXME: Move to FormatMixin? Since there we have access to modelValue prop
*/ */
// @ts-expect-error
_isEmpty(modelValue = this.modelValue) { _isEmpty(modelValue = this.modelValue) {
let value = modelValue; let value = modelValue;
// @ts-expect-error
if (this.modelValue instanceof Unparseable) { if (this.modelValue instanceof Unparseable) {
// @ts-expect-error
value = this.modelValue.viewValue; 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) // Returns dom references to all elements that should be referred to by field(s)
_getAriaDescriptionElements() { _getAriaDescriptionElements() {
@ -681,10 +706,12 @@ const FormControlMixinImplementation = superclass =>
/** /**
* @param {string} slotName * @param {string} slotName
* @return {HTMLElement} * @return {HTMLElement | undefined}
*/ */
__getDirectSlotChild(slotName) { __getDirectSlotChild(slotName) {
return [...this.children].find(el => el.slot === slotName); return /** @type {HTMLElement[]} */ (Array.from(this.children)).find(
el => el.slot === slotName,
);
} }
__dispatchInitialModelValueChangedEvent() { __dispatchInitialModelValueChangedEvent() {
@ -756,6 +783,7 @@ const FormControlMixinImplementation = superclass =>
// We only send the checked changed up (not the unchecked). In this way a choice group // 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) // (radio-group, checkbox-group, select/listbox) acts as an 'endpoint' (a single Field)
// just like the native <select> // 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) { if (this._repropagationRole === 'choice-group' && !this.multipleChoice && !target.checked) {
return; return;
} }

View file

@ -1,7 +1,9 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
import { dedupeMixin } from '@lion/core'; import { dedupeMixin } from '@lion/core';
import { FormControlMixin } from './FormControlMixin.js';
import { Unparseable } from './validate/Unparseable.js'; import { Unparseable } from './validate/Unparseable.js';
import { ValidateMixin } from './validate/ValidateMixin.js';
/** /**
* @typedef {import('../types/FormatMixinTypes').FormatMixin} FormatMixin * @typedef {import('../types/FormatMixinTypes').FormatMixin} FormatMixin
@ -52,25 +54,12 @@ import { Unparseable } from './validate/Unparseable.js';
* Flow: serializedValue (deserializer) -> `.modelValue` (formatter) -> `.formattedValue` -> `._inputNode.value` * Flow: serializedValue (deserializer) -> `.modelValue` (formatter) -> `.formattedValue` -> `._inputNode.value`
* *
* @type {FormatMixin} * @type {FormatMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/ */
const FormatMixinImplementation = superclass => const FormatMixinImplementation = superclass =>
class FormatMixin extends superclass { class FormatMixin extends ValidateMixin(FormControlMixin(superclass)) {
static get properties() { static get properties() {
return { 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 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]). * The result will be stored in the native _inputNode (usually an input[type=text]).
@ -296,7 +285,7 @@ const FormatMixinImplementation = superclass =>
*/ */
_onModelValueChanged(...args) { _onModelValueChanged(...args) {
this._calculateValues({ source: 'model' }); 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); this._dispatchModelValueChangedEvent(...args);
} }
@ -405,7 +394,8 @@ const FormatMixinImplementation = superclass =>
this._inputNode.removeEventListener('input', this._proxyInputEvent); this._inputNode.removeEventListener('input', this._proxyInputEvent);
this._inputNode.removeEventListener( this._inputNode.removeEventListener(
this.formatOn, 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 * - leaves a form field(blur) -> 'touched' will be set to true. 'prefilled' when a
* field is left non-empty * field is left non-empty
* - on keyup (actually, on the model-value-changed event) -> 'dirty' will be set to true * - on keyup (actually, on the model-value-changed event) -> 'dirty' will be set to true
* @param {HTMLElement} superclass *
*/
/**
* @type {InteractionStateMixin} * @type {InteractionStateMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/ */
const InteractionStateMixinImplementation = superclass => const InteractionStateMixinImplementation = superclass =>
class InteractionStateMixin extends FormControlMixin(superclass) { class InteractionStateMixin extends FormControlMixin(superclass) {
@ -105,18 +103,14 @@ const InteractionStateMixinImplementation = superclass =>
* Register event handlers and validate prefilled inputs * Register event handlers and validate prefilled inputs
*/ */
connectedCallback() { connectedCallback() {
if (super.connectedCallback) { super.connectedCallback();
super.connectedCallback();
}
this.addEventListener(this._leaveEvent, this._iStateOnLeave); this.addEventListener(this._leaveEvent, this._iStateOnLeave);
this.addEventListener(this._valueChangedEvent, this._iStateOnValueChange); this.addEventListener(this._valueChangedEvent, this._iStateOnValueChange);
this.initInteractionState(); this.initInteractionState();
} }
disconnectedCallback() { disconnectedCallback() {
if (super.disconnectedCallback) { super.disconnectedCallback();
super.disconnectedCallback();
}
this.removeEventListener(this._leaveEvent, this._iStateOnLeave); this.removeEventListener(this._leaveEvent, this._iStateOnLeave);
this.removeEventListener(this._valueChangedEvent, this._iStateOnValueChange); this.removeEventListener(this._valueChangedEvent, this._iStateOnValueChange);
} }
@ -169,6 +163,27 @@ const InteractionStateMixinImplementation = superclass =>
_onDirtyChanged() { _onDirtyChanged() {
this.dispatchEvent(new CustomEvent('dirty-changed', { bubbles: true, composed: true })); 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); export const InteractionStateMixin = dedupeMixin(InteractionStateMixinImplementation);

View file

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

View file

@ -2,272 +2,316 @@ import { dedupeMixin } from '@lion/core';
import { FormRegistrarMixin } from '../registration/FormRegistrarMixin.js'; import { FormRegistrarMixin } from '../registration/FormRegistrarMixin.js';
import { InteractionStateMixin } from '../InteractionStateMixin.js'; import { InteractionStateMixin } from '../InteractionStateMixin.js';
export const ChoiceGroupMixin = dedupeMixin( /**
superclass => * @typedef {import('../../types/choice-group/ChoiceGroupMixinTypes').ChoiceGroupMixin} ChoiceGroupMixin
// eslint-disable-next-line * @typedef {import('../../types/FormControlMixinTypes').FormControlHost} FormControlHost
class ChoiceGroupMixin extends FormRegistrarMixin(InteractionStateMixin(superclass)) { * @typedef {import('../../types/registration/FormRegistrarMixinTypes').ElementWithParentFormGroup} ElementWithParentFormGroup
static get properties() { * @typedef {FormControlHost & HTMLElement & {__parentFormGroup?:HTMLElement, checked?:boolean}} FormControl
return { */
/**
* @desc When false (default), modelValue and serializedValue will reflect the /**
* currently selected choice (usually a string). When true, modelValue will and * @type {ChoiceGroupMixin}
* serializedValue will be an array of strings. * @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
* @type {boolean} */
*/ const ChoiceGroupMixinImplementation = superclass =>
multipleChoice: { class ChoiceGroupMixin extends FormRegistrarMixin(InteractionStateMixin(superclass)) {
type: Boolean, static get properties() {
attribute: 'multiple-choice', return {
}, /**
}; * @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.
*/
multipleChoice: {
type: Boolean,
attribute: 'multiple-choice',
},
};
}
get modelValue() {
const elems = this._getCheckedElements();
if (this.multipleChoice) {
return elems.map(el => el.modelValue.value);
} }
return elems[0] ? elems[0].modelValue.value : '';
}
get modelValue() { set modelValue(value) {
const elems = this._getCheckedElements(); /**
if (this.multipleChoice) { * @param {{ modelValue: { value: any; }; }} el
return elems.map(el => el.modelValue.value); * @param {any} val
} */
return elems[0] ? elems[0].modelValue.value : ''; const checkCondition = (el, val) => el.modelValue.value === val;
}
set modelValue(value) { if (this.__isInitialModelValue) {
if (this.__isInitialModelValue) { this.__isInitialModelValue = false;
this.__isInitialModelValue = false; this.registrationComplete.then(() => {
this.registrationComplete.then(() => { this._setCheckedElements(value, checkCondition);
this._setCheckedElements(value, (el, val) => el.modelValue.value === val);
});
} else {
this._setCheckedElements(value, (el, val) => el.modelValue.value === val);
}
}
get serializedValue() {
// We want to filter out disabled values out by default:
// The goal of serializing values could either be submitting state to a backend
// ot storing state in a backend. For this, only values that are entered by the end
// user are relevant, choice values are always defined by the Application Developer
// and known by the backend.
// Assuming values are always defined as strings, modelValues and serializedValues
// are the same.
const elems = this._getCheckedElements();
if (this.multipleChoice) {
return elems.map(el => el.serializedValue.value);
}
return elems[0] ? elems[0].serializedValue.value : '';
}
set serializedValue(value) {
if (this.__isInitialSerializedValue) {
this.__isInitialSerializedValue = false;
this.registrationComplete.then(() => {
this._setCheckedElements(value, (el, val) => el.serializedValue.value === val);
});
} else {
this._setCheckedElements(value, (el, val) => el.serializedValue.value === val);
}
}
get formattedValue() {
const elems = this._getCheckedElements();
if (this.multipleChoice) {
return elems.map(el => el.formattedValue);
}
return elems[0] ? elems[0].formattedValue : '';
}
set formattedValue(value) {
if (this.__isInitialFormattedValue) {
this.__isInitialFormattedValue = false;
this.registrationComplete.then(() => {
this._setCheckedElements(value, (el, val) => el.formattedValue === val);
});
} else {
this._setCheckedElements(value, (el, val) => el.formattedValue === val);
}
}
constructor() {
super();
this.multipleChoice = false;
this._repropagationRole = 'choice-group'; // configures event propagation logic of FormControlMixin
this.__isInitialModelValue = true;
this.__isInitialSerializedValue = true;
this.__isInitialFormattedValue = true;
this.registrationComplete = new Promise((resolve, reject) => {
this.__resolveRegistrationComplete = resolve;
this.__rejectRegistrationComplete = reject;
}); });
this.registrationComplete.done = false; } else {
this.registrationComplete.then( this._setCheckedElements(value, checkCondition);
() => { }
this.registrationComplete.done = true; }
},
() => { get serializedValue() {
this.registrationComplete.done = true; // We want to filter out disabled values out by default:
throw new Error( // The goal of serializing values could either be submitting state to a backend
'Registration could not finish. Please use await el.registrationComplete;', // ot storing state in a backend. For this, only values that are entered by the end
); // user are relevant, choice values are always defined by the Application Developer
}, // and known by the backend.
// Assuming values are always defined as strings, modelValues and serializedValues
// are the same.
const elems = this._getCheckedElements();
if (this.multipleChoice) {
return elems.map(el => el.serializedValue.value);
}
return elems[0] ? elems[0].serializedValue.value : '';
}
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, checkCondition);
});
} else {
this._setCheckedElements(value, checkCondition);
}
}
get formattedValue() {
const elems = this._getCheckedElements();
if (this.multipleChoice) {
return elems.map(el => el.formattedValue);
}
return elems[0] ? elems[0].formattedValue : '';
}
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, checkCondition);
});
} else {
this._setCheckedElements(value, checkCondition);
}
}
constructor() {
super();
this.multipleChoice = false;
this._repropagationRole = 'choice-group'; // configures event propagation logic of FormControlMixin
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;
});
this.registrationComplete.done = false;
this.registrationComplete.then(
() => {
this.registrationComplete.done = true;
},
() => {
this.registrationComplete.done = true;
throw new Error(
'Registration could not finish. Please use await el.registrationComplete;',
);
},
);
}
connectedCallback() {
super.connectedCallback();
Promise.resolve().then(() => this.__resolveRegistrationComplete());
this.registrationComplete.then(() => {
this.__isInitialModelValue = false;
this.__isInitialSerializedValue = false;
this.__isInitialFormattedValue = false;
});
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.registrationComplete.done === false) {
this.__rejectRegistrationComplete();
}
}
/**
* @override from FormRegistrarMixin
* @param {FormControl} child
* @param {number} indexToInsertAt
*/
addFormElement(child, indexToInsertAt) {
this._throwWhenInvalidChildModelValue(child);
this.__delegateNameAttribute(child);
super.addFormElement(child, indexToInsertAt);
}
/**
* @override from FormControlMixin
*/
_triggerInitialModelValueChangedEvent() {
this.registrationComplete.then(() => {
this.__dispatchInitialModelValueChangedEvent();
});
}
/**
* @override
* @param {string} property
*/
_getFromAllFormElements(property, filterCondition = () => true) {
// For modelValue, serializedValue and formattedValue, an exception should be made,
// The reset can be requested from children
if (
property === 'modelValue' ||
property === 'serializedValue' ||
property === 'formattedValue'
) {
return this[property];
}
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')
) {
throw new Error(
`The ${this.tagName.toLowerCase()} name="${
this.name
}" does not allow to register ${child.tagName.toLowerCase()} with .modelValue="${
child.modelValue
}" - The modelValue should represent an Object { value: "foo", checked: false }`,
); );
} }
}
connectedCallback() { _isEmpty() {
super.connectedCallback(); if (this.multipleChoice) {
Promise.resolve().then(() => this.__resolveRegistrationComplete()); return this.modelValue.length === 0;
}
this.registrationComplete.then(() => { if (typeof this.modelValue === 'string' && this.modelValue === '') {
this.__isInitialModelValue = false; return true;
this.__isInitialSerializedValue = false; }
this.__isInitialFormattedValue = false; if (this.modelValue === undefined || this.modelValue === null) {
return true;
}
return false;
}
/**
* @param {CustomEvent & {target:FormControl}} ev
*/
_checkSingleChoiceElements(ev) {
const { target } = ev;
if (target.checked === false) return;
const groupName = target.name;
this.formElements
.filter(i => i.name === groupName)
.forEach(choice => {
if (choice !== target) {
choice.checked = false; // eslint-disable-line no-param-reassign
}
}); });
} // this.__triggerCheckedValueChanged();
}
disconnectedCallback() { _getCheckedElements() {
if (super.disconnectedCallback) { // We want to filter out disabled values out by default
super.disconnectedCallback(); return this.formElements.filter(el => el.checked && !el.disabled);
} }
if (this.registrationComplete.done === false) { /**
this.__rejectRegistrationComplete(); * @param {string | any[]} value
} * @param {Function} check
} */
_setCheckedElements(value, check) {
/** for (let i = 0; i < this.formElements.length; i += 1) {
* @override from FormRegistrarMixin
*/
addFormElement(child, indexToInsertAt) {
this._throwWhenInvalidChildModelValue(child);
this.__delegateNameAttribute(child);
super.addFormElement(child, indexToInsertAt);
}
/**
* @override from FormControlMixin
*/
_triggerInitialModelValueChangedEvent() {
this.registrationComplete.then(() => {
this.__dispatchInitialModelValueChangedEvent();
});
}
/**
* @override
*/
_getFromAllFormElements(property, filterCondition = () => true) {
// For modelValue, serializedValue and formattedValue, an exception should be made,
// The reset can be requested from children
if (
property === 'modelValue' ||
property === 'serializedValue' ||
property === 'formattedValue'
) {
return this[property];
}
return this.formElements.filter(filterCondition).map(el => el.property);
}
_throwWhenInvalidChildModelValue(child) {
if (
typeof child.modelValue.checked !== 'boolean' ||
!Object.prototype.hasOwnProperty.call(child.modelValue, 'value')
) {
throw new Error(
`The ${this.tagName.toLowerCase()} name="${
this.name
}" does not allow to register ${child.tagName.toLowerCase()} with .modelValue="${
child.modelValue
}" - The modelValue should represent an Object { value: "foo", checked: false }`,
);
}
}
_isEmpty() {
if (this.multipleChoice) { if (this.multipleChoice) {
return this.modelValue.length === 0; this.formElements[i].checked = value.includes(this.formElements[i].value);
} } else if (check(this.formElements[i], value)) {
// Allows checking against custom values e.g. formattedValue or serializedValue
if (typeof this.modelValue === 'string' && this.modelValue === '') { this.formElements[i].checked = true;
return true;
}
if (this.modelValue === undefined || this.modelValue === null) {
return true;
}
return false;
}
_checkSingleChoiceElements(ev) {
const { target } = ev;
if (target.checked === false) return;
const groupName = target.name;
this.formElements
.filter(i => i.name === groupName)
.forEach(choice => {
if (choice !== target) {
choice.checked = false; // eslint-disable-line no-param-reassign
}
});
this.__triggerCheckedValueChanged();
}
_getCheckedElements() {
// We want to filter out disabled values out by default
return this.formElements.filter(el => el.checked && !el.disabled);
}
_setCheckedElements(value, check) {
for (let i = 0; i < this.formElements.length; i += 1) {
if (this.multipleChoice) {
this.formElements[i].checked = value.includes(this.formElements[i].value);
} else if (check(this.formElements[i], value)) {
// Allows checking against custom values e.g. formattedValue or serializedValue
this.formElements[i].checked = true;
}
} }
} }
}
__setChoiceGroupTouched() { __setChoiceGroupTouched() {
const value = this.modelValue; const value = this.modelValue;
if (value != null && value !== this.__previousCheckedValue) { if (value != null && value !== this.__previousCheckedValue) {
// TODO: what happens here exactly? Needs to be based on user interaction (?) // TODO: what happens here exactly? Needs to be based on user interaction (?)
this.touched = true; this.touched = true;
this.__previousCheckedValue = value; this.__previousCheckedValue = value;
}
} }
}
__delegateNameAttribute(child) { /**
if (!child.name || child.name === this.name) { * @param {FormControl} child
// eslint-disable-next-line no-param-reassign */
child.name = this.name; __delegateNameAttribute(child) {
} else { if (!child.name || child.name === this.name) {
throw new Error( // eslint-disable-next-line no-param-reassign
`The ${this.tagName.toLowerCase()} name="${ child.name = this.name;
this.name } else {
}" does not allow to register ${child.tagName.toLowerCase()} with custom names (name="${ throw new Error(
child.name `The ${this.tagName.toLowerCase()} name="${
}" given)`, this.name
); }" does not allow to register ${child.tagName.toLowerCase()} with custom names (name="${
} child.name
}" given)`,
);
} }
}
/** /**
* @override FormControlMixin * @override FormControlMixin
*/ * @param {CustomEvent} ev
_onBeforeRepropagateChildrenValues(ev) { */
// Normalize target, since we might receive 'portal events' (from children in a modal, _onBeforeRepropagateChildrenValues(ev) {
// see select-rich) // Normalize target, since we might receive 'portal events' (from children in a modal,
const target = (ev.detail && ev.detail.element) || ev.target; // see select-rich)
if (this.multipleChoice || !target.checked) { const target = (ev.detail && ev.detail.element) || ev.target;
return; if (this.multipleChoice || !target.checked) {
} return;
this.formElements.forEach(option => {
if (target.choiceValue !== option.choiceValue) {
option.checked = false; // eslint-disable-line no-param-reassign
}
});
this.__setChoiceGroupTouched();
this.requestUpdate('modelValue');
} }
}, this.formElements.forEach(option => {
); if (target.choiceValue !== option.choiceValue) {
option.checked = false; // eslint-disable-line no-param-reassign
}
});
this.__setChoiceGroupTouched();
this.requestUpdate('modelValue');
}
};
export const ChoiceGroupMixin = dedupeMixin(ChoiceGroupMixinImplementation);

View file

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

View file

@ -5,6 +5,11 @@ export class FormElementsHaveNoError extends Validator {
return 'FormElementsHaveNoError'; return 'FormElementsHaveNoError';
} }
/**
* @param {unknown} [value]
* @param {string | undefined} [options]
* @param {{ node: any }} config
*/
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
execute(value, options, config) { execute(value, options, config) {
const hasError = config.node._anyFormElementHasFeedbackFor('error'); 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 { getAriaElementsInRightDomOrder } from '../utils/getAriaElementsInRightDomOrder.js';
import { FormElementsHaveNoError } from './FormElementsHaveNoError.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 * @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). * elements with [role="group|radiogroup"] (think of checkbox-group, radio-group, fieldset).
@ -15,448 +24,485 @@ import { FormElementsHaveNoError } from './FormElementsHaveNoError.js';
* It is designed to be used on top of FormRegistrarMixin and ChoiceGroupMixin. * 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 * 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 '[]'). * 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( const FormGroupMixinImplementation = superclass =>
superclass => class FormGroupMixin extends FormRegistrarMixin(
// eslint-disable-next-line no-shadow FormControlMixin(ValidateMixin(DisabledMixin(SlotMixin(superclass)))),
class FormGroupMixin extends FormRegistrarMixin( ) {
FormControlMixin(ValidateMixin(DisabledMixin(SlotMixin(superclass)))), static get properties() {
) { return {
static get properties() { /**
return { * Interaction state that can be used to compute the visibility of
/** * feedback messages
* Interaction state that can be used to compute the visibility of */
* feedback messages submitted: {
*/ type: Boolean,
submitted: { reflect: true,
type: Boolean, },
reflect: true, /**
}, * Interaction state that will be active when any of the children
/** * is focused.
* Interaction state that will be active when any of the children */
* is focused. focused: {
*/ type: Boolean,
focused: { reflect: true,
type: Boolean, },
reflect: true, /**
}, * Interaction state that will be active when any of the children
/** * is dirty (see InteractionStateMixin for more details.)
* Interaction state that will be active when any of the children */
* is dirty (see InteractionStateMixin for more details.) dirty: {
*/ type: Boolean,
dirty: { reflect: true,
type: Boolean, },
reflect: true, /**
}, * Interaction state that will be active when the group as a whole is
/** * blurred
* Interaction state that will be active when the group as a whole is */
* blurred touched: {
*/ type: Boolean,
touched: { reflect: true,
type: Boolean, },
reflect: true, /**
}, * Interaction state that will be active when all of the children
/** * are prefilled (see InteractionStateMixin for more details.)
* Interaction state that will be active when all of the children */
* are prefilled (see InteractionStateMixin for more details.) prefilled: {
*/ type: Boolean,
prefilled: { reflect: true,
type: Boolean, },
reflect: true, };
}, }
};
}
get _inputNode() { get _inputNode() {
return this; return this;
} }
get modelValue() { get modelValue() {
return this._getFromAllFormElements('modelValue'); return this._getFromAllFormElements('modelValue');
} }
set modelValue(values) { set modelValue(values) {
if (this.__isInitialModelValue) { if (this.__isInitialModelValue) {
this.__isInitialModelValue = false; this.__isInitialModelValue = false;
this.registrationComplete.then(() => { this.registrationComplete.then(() => {
this._setValueMapForAllFormElements('modelValue', values);
});
} else {
this._setValueMapForAllFormElements('modelValue', values); this._setValueMapForAllFormElements('modelValue', values);
} });
} else {
this._setValueMapForAllFormElements('modelValue', values);
} }
}
get serializedValue() { get serializedValue() {
return this._getFromAllFormElements('serializedValue'); return this._getFromAllFormElements('serializedValue');
} }
set serializedValue(values) { set serializedValue(values) {
if (this.__isInitialSerializedValue) { if (this.__isInitialSerializedValue) {
this.__isInitialSerializedValue = false; this.__isInitialSerializedValue = false;
this.registrationComplete.then(() => { this.registrationComplete.then(() => {
this._setValueMapForAllFormElements('serializedValue', values);
});
} else {
this._setValueMapForAllFormElements('serializedValue', values); this._setValueMapForAllFormElements('serializedValue', values);
}
}
get formattedValue() {
return this._getFromAllFormElements('formattedValue');
}
set formattedValue(values) {
this._setValueMapForAllFormElements('formattedValue', values);
}
get prefilled() {
return this._everyFormElementHas('prefilled');
}
constructor() {
super();
this.disabled = false;
this.submitted = false;
this.dirty = false;
this.touched = false;
this.focused = false;
this.__addedSubValidators = false;
this.__isInitialModelValue = true;
this.__isInitialSerializedValue = true;
this._checkForOutsideClick = this._checkForOutsideClick.bind(this);
this.addEventListener('focusin', this._syncFocused);
this.addEventListener('focusout', this._onFocusOut);
this.addEventListener('dirty-changed', this._syncDirty);
this.addEventListener('validate-performed', this.__onChildValidatePerformed);
this.defaultValidators = [new FormElementsHaveNoError()];
this.registrationComplete = new Promise((resolve, reject) => {
this.__resolveRegistrationComplete = resolve;
this.__rejectRegistrationComplete = reject;
}); });
this.registrationComplete.done = false; } else {
this.registrationComplete.then( this._setValueMapForAllFormElements('serializedValue', values);
() => {
this.registrationComplete.done = true;
},
() => {
this.registrationComplete.done = true;
throw new Error(
'Registration could not finish. Please use await el.registrationComplete;',
);
},
);
} }
}
connectedCallback() { get formattedValue() {
super.connectedCallback(); return this._getFromAllFormElements('formattedValue');
this.setAttribute('role', 'group'); }
Promise.resolve().then(() => this.__resolveRegistrationComplete());
this.registrationComplete.then(() => { set formattedValue(values) {
this.__isInitialModelValue = false; this._setValueMapForAllFormElements('formattedValue', values);
this.__isInitialSerializedValue = false; }
this.__initInteractionStates();
});
}
disconnectedCallback() { get prefilled() {
if (super.disconnectedCallback) { return this._everyFormElementHas('prefilled');
super.disconnectedCallback(); }
}
if (this.__hasActiveOutsideClickHandling) { constructor() {
document.removeEventListener('click', this._checkForOutsideClick); super();
this.__hasActiveOutsideClickHandling = false; this.disabled = false;
} this.submitted = false;
if (this.registrationComplete.done === false) { this.dirty = false;
this.__rejectRegistrationComplete(); this.touched = false;
} this.focused = false;
} this.__addedSubValidators = false;
this.__isInitialModelValue = true;
this.__isInitialSerializedValue = true;
__initInteractionStates() { this._checkForOutsideClick = this._checkForOutsideClick.bind(this);
this.formElements.forEach(el => {
if (typeof el.initInteractionState === 'function') {
el.initInteractionState();
}
});
}
/** this.addEventListener('focusin', this._syncFocused);
* @override from FormControlMixin this.addEventListener('focusout', this._onFocusOut);
*/ this.addEventListener('dirty-changed', this._syncDirty);
_triggerInitialModelValueChangedEvent() { this.addEventListener('validate-performed', this.__onChildValidatePerformed);
this.registrationComplete.then(() => {
this.__dispatchInitialModelValueChangedEvent();
});
}
updated(changedProperties) { this.defaultValidators = [new FormElementsHaveNoError()];
super.updated(changedProperties); /** @type {Promise<any> & {done?:boolean}} */
this.registrationComplete = new Promise((resolve, reject) => {
if (changedProperties.has('disabled')) { this.__resolveRegistrationComplete = resolve;
if (this.disabled) { this.__rejectRegistrationComplete = reject;
this.__requestChildrenToBeDisabled(); });
} else { this.registrationComplete.done = false;
this.__retractRequestChildrenToBeDisabled(); this.registrationComplete.then(
} () => {
} this.registrationComplete.done = true;
},
if (changedProperties.has('focused')) { () => {
if (this.focused === true) { this.registrationComplete.done = true;
this.__setupOutsideClickHandling(); throw new Error(
} 'Registration could not finish. Please use await el.registrationComplete;',
}
}
__setupOutsideClickHandling() {
if (!this.__hasActiveOutsideClickHandling) {
document.addEventListener('click', this._checkForOutsideClick);
this.__hasActiveOutsideClickHandling = true;
}
}
_checkForOutsideClick(event) {
const outsideGroupClicked = !this.contains(event.target);
if (outsideGroupClicked) {
this.touched = true;
}
}
__requestChildrenToBeDisabled() {
this.formElements.forEach(child => {
if (child.makeRequestToBeDisabled) {
child.makeRequestToBeDisabled();
}
});
}
__retractRequestChildrenToBeDisabled() {
this.formElements.forEach(child => {
if (child.retractRequestToBeDisabled) {
child.retractRequestToBeDisabled();
}
});
}
// eslint-disable-next-line class-methods-use-this
_inputGroupTemplate() {
return html`
<div class="input-group">
<slot></slot>
</div>
`;
}
/**
* @desc Handles interaction state 'submitted'.
* This allows children to enable visibility of validation feedback
*/
submitGroup() {
this.submitted = true;
this.formElements.forEach(child => {
if (typeof child.submitGroup === 'function') {
child.submitGroup();
} else {
child.submitted = true; // eslint-disable-line no-param-reassign
}
});
}
resetGroup() {
this.formElements.forEach(child => {
if (typeof child.resetGroup === 'function') {
child.resetGroup();
} else if (typeof child.reset === 'function') {
child.reset();
}
});
this.resetInteractionState();
}
clearGroup() {
this.formElements.forEach(child => {
if (typeof child.clearGroup === 'function') {
child.clearGroup();
} else if (typeof child.clear === 'function') {
child.clear();
}
});
this.resetInteractionState();
}
resetInteractionState() {
this.submitted = false;
this.touched = false;
this.dirty = false;
this.formElements.forEach(formElement => {
if (typeof formElement.resetInteractionState === 'function') {
formElement.resetInteractionState();
}
});
}
_getFromAllFormElements(property, filterCondition = 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)) {
if (typeof elem._getFromAllFormElements === 'function') {
result[name] = elem._getFromAllFormElements(property, filterCondition);
} else {
result[name] = elem[property];
}
}
});
return result;
}
_setValueForAllFormElements(property, value) {
this.formElements.forEach(el => {
el[property] = value; // eslint-disable-line no-param-reassign
});
}
_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) => {
el[property] = values[name][index]; // eslint-disable-line no-param-reassign
});
}
if (this.formElements[name]) {
this.formElements[name][property] = values[name];
}
});
}
}
_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][property];
});
}
_anyFormElementHasFeedbackFor(state) {
return Object.keys(this.formElements).some(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].some(el => {
return Boolean(el.hasFeedbackFor && el.hasFeedbackFor.includes(state));
});
}
return Boolean(
this.formElements[name].hasFeedbackFor &&
this.formElements[name].hasFeedbackFor.includes(state),
); );
}); },
} );
}
_everyFormElementHas(property) { connectedCallback() {
return Object.keys(this.formElements).every(name => { super.connectedCallback();
if (Array.isArray(this.formElements[name])) { this.setAttribute('role', 'group');
return this.formElements[name].every(el => !!el[property]); Promise.resolve().then(() => this.__resolveRegistrationComplete());
}
return !!this.formElements[name][property];
});
}
/** this.registrationComplete.then(() => {
* Gets triggered by event 'validate-performed' which enabled us to handle 2 different situations this.__isInitialModelValue = false;
* - react on modelValue change, which says something about the validity as a whole this.__isInitialSerializedValue = false;
* (at least two checkboxes for instance) and nothing about the children's values this.__initInteractionStates();
* - children validity states have changed, so fieldset needs to update itself based on that });
*/ }
__onChildValidatePerformed(ev) {
if (ev && this.isRegisteredFormElement(ev.target)) { disconnectedCallback() {
this.validate(); super.disconnectedCallback();
if (this.__hasActiveOutsideClickHandling) {
document.removeEventListener('click', this._checkForOutsideClick);
this.__hasActiveOutsideClickHandling = false;
}
if (this.registrationComplete.done === false) {
this.__rejectRegistrationComplete();
}
}
__initInteractionStates() {
this.formElements.forEach(el => {
if (typeof el.initInteractionState === 'function') {
el.initInteractionState();
} }
} });
}
_syncFocused() { /**
this.focused = this._anyFormElementHas('focused'); * @override from FormControlMixin
} */
_triggerInitialModelValueChangedEvent() {
this.registrationComplete.then(() => {
this.__dispatchInitialModelValueChangedEvent();
});
}
_onFocusOut(ev) { /**
const lastEl = this.formElements[this.formElements.length - 1]; * @param {import('lit-element').PropertyValues } changedProperties
if (ev.target === lastEl) { */
this.touched = true; updated(changedProperties) {
} super.updated(changedProperties);
this.focused = false;
}
_syncDirty() { if (changedProperties.has('disabled')) {
this.dirty = this._anyFormElementHas('dirty');
}
__linkChildrenMessagesToParent(child) {
// aria-describedby of (nested) children
let parent = this;
while (parent) {
this.constructor._addDescriptionElementIdsToField(
child,
parent._getAriaDescriptionElements(),
);
// Also check if the newly added child needs to refer grandparents
parent = parent.__parentFormGroup;
}
}
/**
* @override of FormRegistrarMixin.
* @desc Connects ValidateMixin and DisabledMixin
* On top of this, error messages of children are linked to their parents
*/
addFormElement(child, indexToInsertAt) {
super.addFormElement(child, indexToInsertAt);
if (this.disabled) { if (this.disabled) {
// eslint-disable-next-line no-param-reassign this.__requestChildrenToBeDisabled();
} else {
this.__retractRequestChildrenToBeDisabled();
}
}
if (changedProperties.has('focused')) {
if (this.focused === true) {
this.__setupOutsideClickHandling();
}
}
}
__setupOutsideClickHandling() {
if (!this.__hasActiveOutsideClickHandling) {
document.addEventListener('click', this._checkForOutsideClick);
this.__hasActiveOutsideClickHandling = true;
}
}
/**
* @param {Event} event
*/
_checkForOutsideClick(event) {
const outsideGroupClicked = !this.contains(/** @type {Node} */ (event.target));
if (outsideGroupClicked) {
this.touched = true;
}
}
__requestChildrenToBeDisabled() {
this.formElements.forEach(child => {
if (child.makeRequestToBeDisabled) {
child.makeRequestToBeDisabled(); child.makeRequestToBeDisabled();
} }
// TODO: Unlink in removeFormElement });
this.__linkChildrenMessagesToParent(child); }
this.validate({ clearCurrentResult: true });
}
/** __retractRequestChildrenToBeDisabled() {
* Gathers initial model values of all children. Used this.formElements.forEach(child => {
* when resetGroup() is called. if (child.retractRequestToBeDisabled) {
*/ child.retractRequestToBeDisabled();
get _initialModelValue() { }
return this._getFromAllFormElements('_initialModelValue'); });
} }
/** // eslint-disable-next-line class-methods-use-this
* Add aria-describedby to child element(field), so that it points to feedback/help-text of _inputGroupTemplate() {
* parent(fieldset) return html`
* @param {LionField} field - the child: lion-field/lion-input/lion-textarea <div class="input-group">
* @param {array} descriptionElements - description elements like feedback and help-text <slot></slot>
*/ </div>
static _addDescriptionElementIdsToField(field, descriptionElements) { `;
const orderedEls = getAriaElementsInRightDomOrder(descriptionElements, { reverse: true }); }
orderedEls.forEach(el => {
if (field.addToAriaDescribedBy) { /**
field.addToAriaDescribedBy(el, { reorder: false }); * @desc Handles interaction state 'submitted'.
* This allows children to enable visibility of validation feedback
*/
submitGroup() {
this.submitted = true;
this.formElements.forEach(child => {
if (typeof child.submitGroup === 'function') {
child.submitGroup();
} else {
child.submitted = true; // eslint-disable-line no-param-reassign
}
});
}
resetGroup() {
this.formElements.forEach(child => {
if (typeof child.resetGroup === 'function') {
child.resetGroup();
} else if (typeof child.reset === 'function') {
child.reset();
}
});
this.resetInteractionState();
}
clearGroup() {
this.formElements.forEach(child => {
if (typeof child.clearGroup === 'function') {
child.clearGroup();
} else if (typeof child.clear === 'function') {
child.clear();
}
});
this.resetInteractionState();
}
resetInteractionState() {
this.submitted = false;
this.touched = false;
this.dirty = false;
this.formElements.forEach(formElement => {
if (typeof formElement.resetInteractionState === 'function') {
formElement.resetInteractionState();
}
});
}
/**
* @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 => filterFn(el)).map(el => el[property]);
} else if (filterFn(elem)) {
if (typeof elem._getFromAllFormElements === 'function') {
result[name] = elem._getFromAllFormElements(property, filterFn);
} else {
result[name] = elem[property];
}
}
});
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((
/** @type {FormControl} */ el,
/** @type {number} */ index,
) => {
el[property] = values[name][index]; // eslint-disable-line no-param-reassign
});
}
if (this.formElements[name]) {
this.formElements[name][property] = values[name];
} }
}); });
} }
}
/** /**
* @override of FormRegistrarMixin. Connects ValidateMixin * @param {string} property
*/ */
removeFormElement(...args) { _anyFormElementHas(property) {
super.removeFormElement(...args); return Object.keys(this.formElements).some(name => {
this.validate({ clearCurrentResult: true }); if (Array.isArray(this.formElements[name])) {
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((/** @type {FormControl} */ el) => {
return Boolean(el.hasFeedbackFor && el.hasFeedbackFor.includes(state));
});
}
return Boolean(
this.formElements[name].hasFeedbackFor &&
this.formElements[name].hasFeedbackFor.includes(state),
);
});
}
/**
* @param {string} property
*/
_everyFormElementHas(property) {
return Object.keys(this.formElements).every(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].every((/** @type {FormControl} */ el) => !!el[property]);
}
return !!this.formElements[name][property];
});
}
/**
* Gets triggered by event 'validate-performed' which enabled us to handle 2 different situations
* - 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(/** @type {FormControl} */ (ev.target))) {
this.validate();
} }
}, }
);
_syncFocused() {
this.focused = this._anyFormElementHas('focused');
}
/**
* @param {Event} ev
*/
_onFocusOut(ev) {
const lastEl = this.formElements[this.formElements.length - 1];
if (ev.target === lastEl) {
this.touched = true;
}
this.focused = false;
}
_syncDirty() {
this.dirty = this._anyFormElementHas('dirty');
}
/**
* @param {FormControl} child
*/
__linkChildrenMessagesToParent(child) {
// aria-describedby of (nested) children
const unTypedThis = /** @type {unknown} */ (this);
let parent = /** @type {FormControlHost & { __parentFormGroup:any }} */ (unTypedThis);
const ctor = /** @type {typeof FormGroupMixin} */ (this.constructor);
while (parent) {
ctor._addDescriptionElementIdsToField(child, parent._getAriaDescriptionElements());
// Also check if the newly added child needs to refer grandparents
parent = parent.__parentFormGroup;
}
}
/**
* @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) {
child.makeRequestToBeDisabled();
}
// TODO: Unlink in removeFormElement
this.__linkChildrenMessagesToParent(child);
this.validate({ clearCurrentResult: true });
}
/**
* Gathers initial model values of all children. Used
* when resetGroup() is called.
*/
get _initialModelValue() {
return this._getFromAllFormElements('_initialModelValue');
}
/**
* Add aria-describedby to child element(field), so that it points to feedback/help-text of
* parent(fieldset)
* @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 });
orderedEls.forEach(el => {
if (field.addToAriaDescribedBy) {
field.addToAriaDescribedBy(el, { reorder: false });
}
});
}
/**
* @override of FormRegistrarMixin. Connects ValidateMixin
* @param {FormRegisteringHost} el
*/
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/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 * This Mixin registers a form element to a Registrar
* *
* @type {FormRegisteringMixin} * @type {FormRegisteringMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<HTMLElement>} superclass
*/ */
const FormRegisteringMixinImplementation = superclass => const FormRegisteringMixinImplementation = superclass =>
class FormRegisteringMixin extends superclass { class extends superclass {
/** @type {FormRegistrarHost | undefined} */
__parentFormGroup;
connectedCallback() { connectedCallback() {
// @ts-expect-error check it anyway, because could be lit-element extension
if (super.connectedCallback) { if (super.connectedCallback) {
// @ts-expect-error check it anyway, because could be lit-element extension
super.connectedCallback(); super.connectedCallback();
} }
this.dispatchEvent( this.dispatchEvent(
@ -26,7 +34,9 @@ const FormRegisteringMixinImplementation = superclass =>
} }
disconnectedCallback() { disconnectedCallback() {
// @ts-expect-error check it anyway, because could be lit-element extension
if (super.disconnectedCallback) { if (super.disconnectedCallback) {
// @ts-expect-error check it anyway, because could be lit-element extension
super.disconnectedCallback(); super.disconnectedCallback();
} }
if (this.__parentFormGroup) { if (this.__parentFormGroup) {

View file

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

View file

@ -17,10 +17,11 @@ import { dedupeMixin } from '@lion/core';
* </my-portal> * </my-portal>
* // my-field will be registered within my-form * // my-field will be registered within my-form
* @type {FormRegistrarPortalMixin} * @type {FormRegistrarPortalMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<HTMLElement>} superclass
*/ */
const FormRegistrarPortalMixinImplementation = superclass => const FormRegistrarPortalMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars // eslint-disable-next-line no-shadow, no-unused-vars
class FormRegistrarPortalMixin extends superclass { class extends superclass {
constructor() { constructor() {
super(); super();
/** @type {(FormRegistrarPortalHost & HTMLElement) | undefined} */ /** @type {(FormRegistrarPortalHost & HTMLElement) | undefined} */
@ -30,7 +31,8 @@ const FormRegistrarPortalMixinImplementation = superclass =>
); );
this.addEventListener( this.addEventListener(
'form-element-register', '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 * 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 * `requestUpdate`) we only have to change our abstraction instead of all our components
* @type {SyncUpdatableMixin} * @type {SyncUpdatableMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/ */
const SyncUpdatableMixinImplementation = superclass => const SyncUpdatableMixinImplementation = superclass =>
class SyncUpdatable extends superclass { class extends superclass {
constructor() { constructor() {
super(); super();
// Namespace for this mixin that guarantees naming clashes will not occur... // Namespace for this mixin that guarantees naming clashes will not occur...
@ -52,6 +53,7 @@ const SyncUpdatableMixinImplementation = superclass =>
* @param {*} oldValue * @param {*} oldValue
*/ */
static __syncUpdatableHasChanged(name, newValue, oldValue) { static __syncUpdatableHasChanged(name, newValue, oldValue) {
// @ts-expect-error FIXME: Typescript bug, superclass static method not availabe from static context
const properties = this._classProperties; const properties = this._classProperties;
if (properties.get(name) && properties.get(name).hasChanged) { if (properties.get(name) && properties.get(name).hasChanged) {
return properties.get(name).hasChanged(newValue, oldValue); return properties.get(name).hasChanged(newValue, oldValue);
@ -61,7 +63,8 @@ const SyncUpdatableMixinImplementation = superclass =>
__syncUpdatableInitialize() { __syncUpdatableInitialize() {
const ns = 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);
ns.initialized = true; ns.initialized = true;
// Empty queue... // Empty queue...
@ -84,7 +87,8 @@ const SyncUpdatableMixinImplementation = superclass =>
this.__SyncUpdatableNamespace = this.__SyncUpdatableNamespace || {}; this.__SyncUpdatableNamespace = this.__SyncUpdatableNamespace || {};
const ns = 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 // Before connectedCallback: queue
if (!ns.connected) { 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'; 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 * @desc Takes care of accessible rendering of error messages
* Should be used in conjunction with FormControl having ValidateMixin applied * 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 { export class LionValidationFeedback extends LitElement {
static get properties() { static get properties() {
return { return {
/** feedbackData: { attribute: false },
* @property {FeedbackData} feedbackData
*/
feedbackData: Array,
}; };
} }
/** /**
* @overridable * @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 // eslint-disable-next-line class-methods-use-this
_messageTemplate({ message }) { _messageTemplate({ message }) {
return message; return message;
} }
updated() { /**
super.updated(); * @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (this.feedbackData && this.feedbackData[0]) { if (this.feedbackData && this.feedbackData[0]) {
this.setAttribute('type', this.feedbackData[0].type); this.setAttribute('type', this.feedbackData[0].type);
this.currentType = this.feedbackData[0].type; this.currentType = this.feedbackData[0].type;
@ -31,7 +44,8 @@ export class LionValidationFeedback extends LitElement {
if (this.currentType === 'success') { if (this.currentType === 'success') {
this.removeMessage = window.setTimeout(() => { this.removeMessage = window.setTimeout(() => {
this.removeAttribute('type'); this.removeAttribute('type');
this.feedbackData = ''; /** @type {messageMap[]} */
this.feedbackData = [];
}, 3000); }, 3000);
} }
} else if (this.currentType !== 'success') { } else if (this.currentType !== 'success') {

View file

@ -8,11 +8,14 @@ import { Validator } from './Validator.js';
*/ */
export class ResultValidator extends Validator { export class ResultValidator extends Validator {
/** /**
* @param {object} context * @param {Object} context
* @param {Validator[]} context.validationResult * @param {Validator[]} context.regularValidationResult
* @param {Validator[]} context.prevValidationResult * @param {Validator[] | undefined} context.prevValidationResult
* @param {Validator[]} context.validators * @param {Validator[]} [context.validators]
* @returns {Feedback[]} * @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;
}
} }

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,9 @@ export class AlwaysValid extends Validator {
return 'AlwaysValid'; return 'AlwaysValid';
} }
/**
* @return {Promise<boolean> | boolean}
*/
execute() { execute() {
const showMessage = false; const showMessage = false;
return showMessage; return showMessage;
@ -28,7 +31,10 @@ export class AsyncAlwaysValid extends AlwaysValid {
return true; return true;
} }
execute() { /**
* @return {Promise<boolean>}
*/
async execute() {
return true; return true;
} }
} }
@ -38,6 +44,9 @@ export class AsyncAlwaysInvalid extends AlwaysValid {
return true; return true;
} }
/**
* @return {Promise<boolean>}
*/
async execute() { async execute() {
return false; 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').FormRegistrarHost} FormRegistrarHost
* @typedef {import('../types/registration/FormRegistrarMixinTypes').FormRegistrarMixin} FormRegistrarMixin
*/ */
/** /**
* * @typedef {Object} customConfig
* @param {Object} customConfig * @property {typeof HTMLElement | typeof import('@lion/core').UpdatingElement | typeof LitElement} [baseElement]
* @param {string} customConfig.suffix * @property {string} [customConfig.suffix]
* @param {string} customConfig.parentTagString * @property {string} [customConfig.parentTagString]
* @param {string} customConfig.childTagString * @property {string} [customConfig.childTagString]
* @param {string} customConfig.portalTagString * @property {string} [customConfig.portalTagString]
*/
/**
* @param {customConfig} customConfig
*/ */
export const runRegistrationSuite = customConfig => { export const runRegistrationSuite = customConfig => {
const cfg = { const cfg = {
// @ts-expect-error https://github.com/microsoft/TypeScript/issues/38535
baseElement: HTMLElement, baseElement: HTMLElement,
...customConfig, ...customConfig,
}; };
describe(`FormRegistrationMixins ${cfg.suffix}`, () => { describe(`FormRegistrationMixins ${cfg.suffix}`, () => {
// @ts-expect-error base constructors same return type & type cannot be assigned like this
class RegistrarClass extends FormRegistrarMixin(cfg.baseElement) {} class RegistrarClass extends FormRegistrarMixin(cfg.baseElement) {}
cfg.parentTagString = defineCE(RegistrarClass); cfg.parentTagString = defineCE(RegistrarClass);
// @ts-expect-error base constructors same return type & type cannot be assigned like this
class RegisteringClass extends FormRegisteringMixin(cfg.baseElement) {} class RegisteringClass extends FormRegisteringMixin(cfg.baseElement) {}
cfg.childTagString = defineCE(RegisteringClass); cfg.childTagString = defineCE(RegisteringClass);
// @ts-expect-error base constructors same return type & type cannot be assigned like this
class PortalClass extends FormRegistrarPortalMixin(cfg.baseElement) {} class PortalClass extends FormRegistrarPortalMixin(cfg.baseElement) {}
cfg.portalTagString = defineCE(PortalClass); cfg.portalTagString = defineCE(PortalClass);
@ -84,6 +91,7 @@ export const runRegistrationSuite = customConfig => {
}); });
it('works for components that have a delayed render', async () => { it('works for components that have a delayed render', async () => {
// @ts-expect-error base constructors same return type
class PerformUpdate extends FormRegistrarMixin(LitElement) { class PerformUpdate extends FormRegistrarMixin(LitElement) {
async performUpdate() { async performUpdate() {
await new Promise(resolve => setTimeout(() => resolve(), 10)); 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 { aTimeout, defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
import sinon from 'sinon'; import sinon from 'sinon';
import { FormatMixin } from '../src/FormatMixin.js'; 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 * @typedef {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
*/ */
// @ts-expect-error base constructor same return type
class FormatClass extends FormatMixin(LitElement) { class FormatClass extends FormatMixin(LitElement) {
get _inputNode() {
return /** @type {HTMLInputElement} */ (super._inputNode); // casts type
}
render() { render() {
return html`<slot name="input"></slot>`; return html`<slot name="input"></slot>`;
} }
@ -29,15 +30,10 @@ class FormatClass extends FormatMixin(LitElement) {
} }
return ''; return '';
} }
get _inputNode() {
return this.querySelector('input');
}
} }
/** /**
* * @param {FormatClass} formControl
* @param {FormatClass & inputNodeHost} formControl
* @param {?} newViewValue * @param {?} newViewValue
*/ */
function mimicUserInput(formControl, 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) { export function runFormatMixinSuite(customConfig) {
const cfg = { const cfg = {
@ -95,7 +91,7 @@ export function runFormatMixinSuite(customConfig) {
let elem; let elem;
/** @type {FormatClass} */ /** @type {FormatClass} */
let nonFormat; let nonFormat;
/** @type {FormatClass & inputNodeHost} */ /** @type {FormatClass} */
let fooFormat; let fooFormat;
before(async () => { before(async () => {
@ -128,7 +124,7 @@ export function runFormatMixinSuite(customConfig) {
}); });
it('fires `model-value-changed` for every change on the input', async () => { 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}>`, html`<${elem}><input slot="input"></${elem}>`,
)); ));
@ -215,7 +211,7 @@ export function runFormatMixinSuite(customConfig) {
it('synchronizes _inputNode.value as a fallback mechanism', async () => { 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> // 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} <${elem}
value="string" value="string"
.formatter=${/** @param {string} value */ value => `foo: ${value}`} .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 () => { 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}`}"> <${elem} .formatter="${/** @param {string} value */ value => `foo: ${value}`}">
<input slot="input" /> <input slot="input" />
</${elem}> </${elem}>
@ -255,14 +251,14 @@ export function runFormatMixinSuite(customConfig) {
}); });
it('reflects back .formattedValue immediately when .modelValue changed imperatively', async () => { 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}`}"> <${elem} .formatter="${/** @param {string} value */ value => `foo: ${value}`}">
<input slot="input" /> <input slot="input" />
</${elem}> </${elem}>
`)); `));
// The FormatMixin can be used in conjunction with the ValidateMixin, in which case // The FormatMixin can be used in conjunction with the ValidateMixin, in which case
// it can hold errorState (affecting the formatting) // it can hold errorState (affecting the formatting)
el.errorState = true; el.hasFeedbackFor = ['error'];
// users types value 'test' // users types value 'test'
mimicUserInput(el, 'test'); mimicUserInput(el, 'test');
@ -274,6 +270,7 @@ export function runFormatMixinSuite(customConfig) {
}); });
it('works if there is no underlying _inputNode', async () => { 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 tagNoInputString = defineCE(class extends FormatMixin(LitElement) {});
const tagNoInput = unsafeStatic(tagNoInputString); const tagNoInput = unsafeStatic(tagNoInputString);
expect(async () => { expect(async () => {
@ -300,7 +297,9 @@ export function runFormatMixinSuite(customConfig) {
it('should have formatOptions available in formatter', async () => { it('should have formatOptions available in formatter', async () => {
const formatterSpy = sinon.spy(value => `foo: ${value}`); const formatterSpy = sinon.spy(value => `foo: ${value}`);
const generatedViewValue = generateValueBasedOnType({ viewValue: true }); const generatedViewValue = /** @type {string} */ (generateValueBasedOnType({
viewValue: true,
}));
await fixture(html` await fixture(html`
<${elem} value="${generatedViewValue}" .formatter="${formatterSpy}" <${elem} value="${generatedViewValue}" .formatter="${formatterSpy}"
.formatOptions="${{ locale: 'en-GB', decimalSeparator: '-' }}"> .formatOptions="${{ locale: 'en-GB', decimalSeparator: '-' }}">
@ -319,7 +318,7 @@ export function runFormatMixinSuite(customConfig) {
/** @type {?} */ /** @type {?} */
const generatedValue = generateValueBasedOnType(); const generatedValue = generateValueBasedOnType();
const parserSpy = sinon.spy(); const parserSpy = sinon.spy();
const el = /** @type {FormatClass & inputNodeHost} */ (await fixture(html` const el = /** @type {FormatClass} */ (await fixture(html`
<${elem} .parser="${parserSpy}"> <${elem} .parser="${parserSpy}">
<input slot="input" value="${generatedValue}"> <input slot="input" value="${generatedValue}">
</${elem}> </${elem}>
@ -335,7 +334,7 @@ export function runFormatMixinSuite(customConfig) {
}); });
it('will not return Unparseable when empty strings are inputted', async () => { 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}> <${elem}>
<input slot="input" value="string"> <input slot="input" value="string">
</${elem}> </${elem}>
@ -359,7 +358,7 @@ export function runFormatMixinSuite(customConfig) {
toggleValue: true, toggleValue: true,
}); });
const el = /** @type {FormatClass & inputNodeHost & validateHost} */ (await fixture(html` const el = /** @type {FormatClass} */ (await fixture(html`
<${elem} .formatter=${formatterSpy}> <${elem} .formatter=${formatterSpy}>
<input slot="input" value="${generatedViewValue}"> <input slot="input" value="${generatedViewValue}">
</${elem}> </${elem}>
@ -371,7 +370,7 @@ export function runFormatMixinSuite(customConfig) {
// Setting hasError = true is not enough if the element has errorValidators (uses ValidateMixin) // 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. // 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() { static get validatorName() {
return 'AlwaysInvalid'; return 'AlwaysInvalid';
} }
@ -379,9 +378,9 @@ export function runFormatMixinSuite(customConfig) {
execute() { execute() {
return true; return true;
} }
}; */ };
// @ts-ignore // FIXME: remove this ignore after Validator class is typed
// el.validators = [new AlwaysInvalid()]; el.validators = [new AlwaysInvalid()];
mimicUserInput(el, generatedViewValueAlt); mimicUserInput(el, generatedViewValueAlt);
expect(formatterSpy.callCount).to.equal(1); expect(formatterSpy.callCount).to.equal(1);
@ -398,19 +397,21 @@ export function runFormatMixinSuite(customConfig) {
}); });
describe('Unparseable values', () => { describe('Unparseable values', () => {
// it('should convert to Unparseable when wrong value inputted by user', async () => { it('should convert to Unparseable when wrong value inputted by user', async () => {
// const el = /** @type {FormatClass & inputNodeHost} */ (await fixture(html` const el = /** @type {FormatClass} */ (await fixture(html`
// <${elem} .parser=${viewValue => Number(viewValue) || undefined} <${elem} .parser=${
// > /** @param {string} viewValue */ viewValue => Number(viewValue) || undefined
// <input slot="input"> }
// </${elem}> >
// `)); <input slot="input">
// mimicUserInput(el, 'test'); </${elem}>
// expect(el.modelValue).to.be.an.instanceof(Unparseable); `));
// }); mimicUserInput(el, 'test');
expect(el.modelValue).to.be.an.instanceof(Unparseable);
});
it('should preserve the viewValue when not parseable', async () => { 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} <${elem}
.parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined} .parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined}
> >
@ -422,17 +423,17 @@ export function runFormatMixinSuite(customConfig) {
expect(el.value).to.equal('test'); expect(el.value).to.equal('test');
}); });
// it('should display the viewValue when modelValue is of type Unparseable', async () => { it('should display the viewValue when modelValue is of type Unparseable', async () => {
// const el = /** @type {FormatClass} */ (await fixture(html` const el = /** @type {FormatClass} */ (await fixture(html`
// <${elem} <${elem}
// .parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined} .parser=${/** @param {string} viewValue */ viewValue => Number(viewValue) || undefined}
// > >
// <input slot="input"> <input slot="input">
// </${elem}> </${elem}>
// `)); `));
// el.modelValue = new Unparseable('foo'); el.modelValue = new Unparseable('foo');
// expect(el.value).to.equal('foo'); expect(el.value).to.equal('foo');
// }); });
}); });
}); });
} }

View file

@ -10,6 +10,8 @@ import {
} from '@open-wc/testing'; } from '@open-wc/testing';
import sinon from 'sinon'; import sinon from 'sinon';
import { InteractionStateMixin } from '../src/InteractionStateMixin.js'; 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] * @param {{tagString?: string, allowedModelValueTypes?: Array.<ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor>}} [customConfig]
@ -22,7 +24,8 @@ export function runInteractionStateMixinSuite(customConfig) {
}; };
describe(`InteractionStateMixin`, async () => { describe(`InteractionStateMixin`, async () => {
class IState extends InteractionStateMixin(LitElement) { // @ts-expect-error base constructors same return type
class IState extends InteractionStateMixin(ValidateMixin(LitElement)) {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.tabIndex = 0; this.tabIndex = 0;
@ -207,8 +210,41 @@ export function runInteractionStateMixinSuite(customConfig) {
expect(el.prefilled).to.be.true; 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', () => { describe('SubClassers', () => {
it('can override the `_leaveEvent`', async () => { it('can override the `_leaveEvent`', async () => {
// @ts-expect-error base constructor same return type
class IStateCustomBlur extends InteractionStateMixin(LitElement) { class IStateCustomBlur extends InteractionStateMixin(LitElement) {
constructor() { constructor() {
super(); super();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@ describe('Number Validation', () => {
it('provides new IsNumber() to allow only numbers', () => { it('provides new IsNumber() to allow only numbers', () => {
let isEnabled; let isEnabled;
const validator = new IsNumber(); const validator = new IsNumber();
expect(validator.constructor.validatorName).to.equal('IsNumber'); expect(IsNumber.validatorName).to.equal('IsNumber');
isEnabled = validator.execute(4); isEnabled = validator.execute(4);
expect(isEnabled).to.be.false; expect(isEnabled).to.be.false;
@ -26,7 +26,7 @@ describe('Number Validation', () => {
it('provides new MinNumber(x) to allow only numbers longer then min', () => { it('provides new MinNumber(x) to allow only numbers longer then min', () => {
let isEnabled; let isEnabled;
const validator = new MinNumber(3); const validator = new MinNumber(3);
expect(validator.constructor.validatorName).to.equal('MinNumber'); expect(MinNumber.validatorName).to.equal('MinNumber');
isEnabled = validator.execute(3); isEnabled = validator.execute(3);
expect(isEnabled).to.be.false; expect(isEnabled).to.be.false;
@ -38,7 +38,7 @@ describe('Number Validation', () => {
it('provides new MaxNumber(x) to allow only number shorter then max', () => { it('provides new MaxNumber(x) to allow only number shorter then max', () => {
let isEnabled; let isEnabled;
const validator = new MaxNumber(3); const validator = new MaxNumber(3);
expect(validator.constructor.validatorName).to.equal('MaxNumber'); expect(MaxNumber.validatorName).to.equal('MaxNumber');
isEnabled = validator.execute(3); isEnabled = validator.execute(3);
expect(isEnabled).to.be.false; 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', () => { it('provides new MinMaxNumber({ min: x, max: y}) to allow only numbers between min and max', () => {
let isEnabled; let isEnabled;
const validator = new MinMaxNumber({ min: 2, max: 4 }); 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); isEnabled = validator.execute(2);
expect(isEnabled).to.be.false; 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 { Required } from '../../src/validate/validators/Required.js';
import { MinLength } from '../../src/validate/validators/StringValidators.js'; import { MinLength } from '../../src/validate/validators/StringValidators.js';
/**
* @typedef {import('../../src/validate/Validator').Validator} Validator
*/
describe('ResultValidator', () => { describe('ResultValidator', () => {
it('has an "executeOnResults" function returning active state', async () => { it('has an "executeOnResults" function returning active state', async () => {
// This test shows the best practice of creating executeOnResults method // This test shows the best practice of creating executeOnResults method
class MyResultValidator extends ResultValidator { 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( expect(
new MyResultValidator().executeOnResults({ new MyResultValidator().executeOnResults({
regularValidateResult: [new Required(), new MinLength(3)], regularValidationResult: [new Required(), new MinLength(3)],
prevValidationResult: [], prevValidationResult: [],
}), }),
).to.be.true; ).to.be.true;

View file

@ -14,7 +14,7 @@ describe('String Validation', () => {
it('provides new IsString() to allow only strings', () => { it('provides new IsString() to allow only strings', () => {
let isEnabled; let isEnabled;
const validator = new IsString(); const validator = new IsString();
expect(validator.constructor.validatorName).to.equal('IsString'); expect(IsString.validatorName).to.equal('IsString');
isEnabled = validator.execute('foo'); isEnabled = validator.execute('foo');
expect(isEnabled).to.be.false; expect(isEnabled).to.be.false;
@ -29,7 +29,7 @@ describe('String Validation', () => {
it('provides new EqualsLength(x) to allow only a specific string length', () => { it('provides new EqualsLength(x) to allow only a specific string length', () => {
let isEnabled; let isEnabled;
const validator = new EqualsLength(3); const validator = new EqualsLength(3);
expect(validator.constructor.validatorName).to.equal('EqualsLength'); expect(EqualsLength.validatorName).to.equal('EqualsLength');
isEnabled = validator.execute('foo'); isEnabled = validator.execute('foo');
expect(isEnabled).to.be.false; expect(isEnabled).to.be.false;
@ -44,7 +44,7 @@ describe('String Validation', () => {
it('provides new MinLength(x) to allow only strings longer then min', () => { it('provides new MinLength(x) to allow only strings longer then min', () => {
let isEnabled; let isEnabled;
const validator = new MinLength(3); const validator = new MinLength(3);
expect(validator.constructor.validatorName).to.equal('MinLength'); expect(MinLength.validatorName).to.equal('MinLength');
isEnabled = validator.execute('foo'); isEnabled = validator.execute('foo');
expect(isEnabled).to.be.false; expect(isEnabled).to.be.false;
@ -56,7 +56,7 @@ describe('String Validation', () => {
it('provides new MaxLength(x) to allow only strings shorter then max', () => { it('provides new MaxLength(x) to allow only strings shorter then max', () => {
let isEnabled; let isEnabled;
const validator = new MaxLength(3); const validator = new MaxLength(3);
expect(validator.constructor.validatorName).to.equal('MaxLength'); expect(MaxLength.validatorName).to.equal('MaxLength');
isEnabled = validator.execute('foo'); isEnabled = validator.execute('foo');
expect(isEnabled).to.be.false; 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', () => { it('provides new MinMaxValidator({ min: x, max: y}) to allow only strings between min and max', () => {
let isEnabled; let isEnabled;
const validator = new MinMaxLength({ min: 2, max: 4 }); 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'); isEnabled = validator.execute('foo');
expect(isEnabled).to.be.false; expect(isEnabled).to.be.false;
@ -83,7 +83,7 @@ describe('String Validation', () => {
it('provides new IsEmail() to allow only valid email formats', () => { it('provides new IsEmail() to allow only valid email formats', () => {
let isEnabled; let isEnabled;
const validator = new IsEmail(); const validator = new IsEmail();
expect(validator.constructor.validatorName).to.equal('IsEmail'); expect(IsEmail.validatorName).to.equal('IsEmail');
isEnabled = validator.execute('foo@bar.com'); isEnabled = validator.execute('foo@bar.com');
expect(isEnabled).to.be.false; expect(isEnabled).to.be.false;
@ -116,7 +116,7 @@ describe('String Validation', () => {
it('provides new Pattern() to allow only valid patterns', () => { it('provides new Pattern() to allow only valid patterns', () => {
let isEnabled; let isEnabled;
let validator = new Pattern(/#LionRocks/); let validator = new Pattern(/#LionRocks/);
expect(validator.constructor.validatorName).to.equal('Pattern'); expect(Pattern.validatorName).to.equal('Pattern');
isEnabled = validator.execute('#LionRocks'); isEnabled = validator.execute('#LionRocks');
expect(isEnabled).to.be.false; 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 { 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 { ValidateMixin } from '../../src/validate/ValidateMixin.js';
import { Validator } from '../../src/validate/Validator.js'; import { Validator } from '../../src/validate/Validator.js';
/**
* @param {function} method
* @param {string} errorMessage
*/
async function expectThrowsAsync(method, errorMessage) { async function expectThrowsAsync(method, errorMessage) {
let error = null; let error = null;
try { try {
@ -20,6 +24,10 @@ async function expectThrowsAsync(method, errorMessage) {
describe('Validator', () => { describe('Validator', () => {
it('has an "execute" function returning "shown" state', async () => { it('has an "execute" function returning "shown" state', async () => {
class MyValidator extends Validator { class MyValidator extends Validator {
/**
* @param {string} [modelValue]
* @param {string} [param]
*/
execute(modelValue, param) { execute(modelValue, param) {
const hasError = modelValue === 'test' && param === 'me'; const hasError = modelValue === 'test' && param === 'me';
return hasError; return hasError;
@ -79,20 +87,24 @@ describe('Validator', () => {
}); });
it('has access to name, type, params, config in static get getMessage', () => { it('has access to name, type, params, config in static get getMessage', () => {
let staticArgs; let data;
class MyValidator extends Validator { class MyValidator extends Validator {
static get validatorName() { static get validatorName() {
return 'MyValidator'; 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' }); const vali = new MyValidator('myParam', { my: 'config' });
vali._getMessage(); vali._getMessage();
expect(staticArgs[0]).to.deep.equal({ expect(data).to.deep.equal({
name: 'MyValidator', name: 'MyValidator',
type: 'error', type: 'error',
params: 'myParam', params: 'myParam',
@ -103,7 +115,9 @@ describe('Validator', () => {
it('fires "param-changed" event on param change', async () => { it('fires "param-changed" event on param change', async () => {
const vali = new Validator('foo'); const vali = new Validator('foo');
const cb = sinon.spy(() => {}); const cb = sinon.spy(() => {});
vali.addEventListener('param-changed', cb); if (vali.addEventListener) {
vali.addEventListener('param-changed', cb);
}
vali.param = 'bar'; vali.param = 'bar';
expect(cb.callCount).to.equal(1); expect(cb.callCount).to.equal(1);
}); });
@ -111,47 +125,41 @@ describe('Validator', () => {
it('fires "config-changed" event on config change', async () => { it('fires "config-changed" event on config change', async () => {
const vali = new Validator('foo', { foo: 'bar' }); const vali = new Validator('foo', { foo: 'bar' });
const cb = sinon.spy(() => {}); const cb = sinon.spy(() => {});
vali.addEventListener('config-changed', cb); if (vali.addEventListener) {
vali.addEventListener('config-changed', cb);
}
vali.config = { bar: 'foo' }; vali.config = { bar: 'foo' };
expect(cb.callCount).to.equal(1); expect(cb.callCount).to.equal(1);
}); });
it('has access to FormControl', async () => { it('has access to FormControl', async () => {
const lightDom = ''; const lightDom = '';
const tagString = defineCE( // @ts-expect-error base constructors same return type
class extends ValidateMixin(LitElement) { class ValidateElement extends ValidateMixin(LitElement) {
static get properties() { static get properties() {
return { modelValue: String }; return { modelValue: String };
} }
}, }
); const tagString = defineCE(ValidateElement);
const tag = unsafeStatic(tagString); const tag = unsafeStatic(tagString);
class MyValidator extends Validator { class MyValidator extends Validator {
/**
* @param {string} modelValue
* @param {string} param
*/
execute(modelValue, param) { execute(modelValue, param) {
const hasError = modelValue === 'forbidden' && param === 'values'; const hasError = modelValue === 'forbidden' && param === 'values';
return hasError; 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 myVal = new MyValidator();
const connectSpy = sinon.spy(myVal, 'onFormControlConnect'); const connectSpy = sinon.spy(myVal, 'onFormControlConnect');
const disconnectSpy = sinon.spy(myVal, 'onFormControlDisconnect'); const disconnectSpy = sinon.spy(myVal, 'onFormControlDisconnect');
const el = await fixture(html` const el = /** @type {ValidateElement} */ (await fixture(html`
<${tag} .validators=${[myVal]}>${lightDom}</${tag}> <${tag} .validators=${[myVal]}>${lightDom}</${tag}>
`); `));
expect(connectSpy.callCount).to.equal(1); expect(connectSpy.callCount).to.equal(1);
expect(connectSpy.calledWith(el)).to.equal(true); expect(connectSpy.calledWith(el)).to.equal(true);
@ -171,6 +179,9 @@ describe('Validator', () => {
it('supports customized types', async () => { it('supports customized types', async () => {
// This test shows the best practice of adding custom types // This test shows the best practice of adding custom types
class MyValidator extends Validator { class MyValidator extends Validator {
/**
* @param {...any} args
*/
constructor(...args) { constructor(...args) {
super(...args); super(...args);
this.type = 'my-type'; this.type = 'my-type';

View file

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

View file

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

View file

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

View file

@ -1,27 +1,8 @@
import { Constructor } from '@open-wc/dedupe-mixin'; import { Constructor } from '@open-wc/dedupe-mixin';
import { LitElement } from '@lion/core'; import { LitElement } from '@lion/core';
import { FormControlHost } from './FormControlMixinTypes';
export declare class InteractionStateHost { 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; prefilled: boolean;
filled: boolean; filled: boolean;
touched: boolean; touched: boolean;
@ -43,6 +24,10 @@ export declare class InteractionStateHost {
export declare function InteractionStateImplementation<T extends Constructor<LitElement>>( export declare function InteractionStateImplementation<T extends Constructor<LitElement>>(
superclass: T, superclass: T,
): T & Constructor<InteractionStateHost> & InteractionStateHost; ): T &
Constructor<InteractionStateHost> &
InteractionStateHost &
Constructor<FormControlHost> &
typeof FormControlHost;
export type InteractionStateMixin = typeof InteractionStateImplementation; 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 { Constructor } from '@open-wc/dedupe-mixin';
import { FormRegistrarHost } from './FormRegistrarMixinTypes';
import { LitElement } from '@lion/core';
export declare class FormRegisteringHost { export declare class FormRegisteringHost {
connectedCallback(): void; connectedCallback(): void;
disconnectedCallback(): void; disconnectedCallback(): void;
__parentFormGroup?: FormRegistrarHost;
} }
export declare function FormRegisteringImplementation<T extends Constructor<HTMLElement>>( export declare function FormRegisteringImplementation<T extends Constructor<LitElement>>(
superclass: T, superclass: T,
): T & Constructor<FormRegisteringHost> & FormRegisteringHost; ): T & Constructor<FormRegisteringHost> & typeof FormRegisteringHost;
export type FormRegisteringMixin = typeof FormRegisteringImplementation; export type FormRegisteringMixin = typeof FormRegisteringImplementation;

View file

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

View file

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

View file

@ -23,6 +23,6 @@ export type SyncUpdatableHostType = typeof SyncUpdatableHost;
export declare function SyncUpdatableImplementation<T extends Constructor<LitElement>>( export declare function SyncUpdatableImplementation<T extends Constructor<LitElement>>(
superclass: T, superclass: T,
): T & Constructor<SyncUpdatableHost> & SyncUpdatableHost; ): T & Constructor<SyncUpdatableHost> & typeof SyncUpdatableHost;
export type SyncUpdatableMixin = typeof SyncUpdatableImplementation; 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> </form>
</lion-form> </lion-form>
<h-output .field="${fieldElement}" .show="${[...props, 'hasFeedbackFor']}"> </h-output> <h-output .field="${fieldElement}" .show="${[...props, 'hasFeedbackFor']}"> </h-output>
<h3> <h3>Set conditions for validation feedback visibility</h3>
Set conditions for validation feedback visibility
</h3>
<lion-checkbox-group name="props[]" @model-value-changed="${fetchConditionsAndReevaluate}"> <lion-checkbox-group name="props[]" @model-value-changed="${fetchConditionsAndReevaluate}">
${props.map(p => html` <lion-checkbox .label="${p}" .choiceValue="${p}"> </lion-checkbox> `)} ${props.map(p => html` <lion-checkbox .label="${p}" .choiceValue="${p}"> </lion-checkbox> `)}
</lion-checkbox-group> </lion-checkbox-group>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -94,17 +94,11 @@ export const methods = () => {
<button @click=${() => document.getElementById('pagination-method').previous()}> <button @click=${() => document.getElementById('pagination-method').previous()}>
Previous Previous
</button> </button>
<button @click=${() => document.getElementById('pagination-method').next()}> <button @click=${() => document.getElementById('pagination-method').next()}>Next</button>
Next
</button>
<br /> <br />
<br /> <br />
<button @click=${() => document.getElementById('pagination-method').first()}> <button @click=${() => document.getElementById('pagination-method').first()}>First</button>
First <button @click=${() => document.getElementById('pagination-method').last()}>Last</button>
</button>
<button @click=${() => document.getElementById('pagination-method').last()}>
Last
</button>
<br /> <br />
<br /> <br />
<button @click=${() => document.getElementById('pagination-method').goto(55)}> <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> <h1 class="heading">providence <span class="heading__part">dashboard</span> (alpha)</h1>
<div class="u-ml2"> <div class="u-ml2">
${this._activeAnalyzerSelectTemplate()} ${this._activeAnalyzerSelectTemplate()}
<button @click="${() => downloadFile('data.csv', this._createCsv())}"> <button @click="${() => downloadFile('data.csv', this._createCsv())}">get csv</button>
get csv
</button>
</div> </div>
</div> </div>
${this._selectionMenuTemplate(this.__menuData)} ${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 ```html
<lion-select-rich> <lion-select-rich>
<my-invoker-button slot="invoker"></my-invoker-button> <my-invoker-button slot="invoker"></my-invoker-button>
<lion-options slot="input"> <lion-options slot="input"> ... </lion-options>
...
</lion-options>
</lion-select-rich> </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() { constructor() {
super(); super();
this.active = false; this.active = false;

View file

@ -95,7 +95,7 @@ export class LionSelectInvoker extends LionButton {
_contentTemplate() { _contentTemplate() {
if (this.selectedElement) { if (this.selectedElement) {
const labelNodes = Array.from(this.selectedElement.querySelectorAll('*')); const labelNodes = Array.from(this.selectedElement.childNodes);
if (labelNodes.length > 0) { if (labelNodes.length > 0) {
return labelNodes.map(node => node.cloneNode(true)); return labelNodes.map(node => node.cloneNode(true));
} }
@ -113,11 +113,7 @@ export class LionSelectInvoker extends LionButton {
} }
_beforeTemplate() { _beforeTemplate() {
return html` return html` <div id="content-wrapper">${this._contentTemplate()}</div> `;
<div id="content-wrapper">
${this._contentTemplate()}
</div>
`;
} }
// eslint-disable-next-line class-methods-use-this // 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 () => { it('renders invoker info based on selectedElement child elements', async () => {
const el = await fixture(html`<lion-select-invoker></lion-select-invoker>`); 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; await el.updateComplete;
expect(el._contentWrapperNode).lightDom.to.equal( expect(el._contentWrapperNode).lightDom.to.equal(
` `
Textnode
<h2>I am</h2> <h2>I am</h2>
<p>2 lines</p> <p>2 lines</p>
`, `,

View file

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