Socket
Socket
Sign inDemoInstall

@lion/form-core

Package Overview
Dependencies
2
Maintainers
1
Versions
73
Alerts
File Explorer

Advanced tools

Install Socket

Detect and block malicious and high-risk dependencies

Install

Comparing version 0.3.1 to 0.4.1

index.d.ts

105

CHANGELOG.md
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## 0.4.1
## [0.3.1](https://github.com/ing-bank/lion/compare/@lion/form-core@0.3.0...@lion/form-core@0.3.1) (2020-07-28)
### Patch Changes
- fb236975: Add index.d.ts file so that its types can be used across lion. Add package root index.js to TSC build config exclude filter to prevent TSC from erroring on already existing type definition files.
### Bug Fixes
## 0.4.0
* resolve registrationComplete via microtask to be sync ([6b8daef](https://github.com/ing-bank/lion/commit/6b8daef5099ab7bab40f9bdd8aaa1a0795b56214))
### Minor Changes
- 3c61fd29: Add types to form-core, for everything except form-group, choice-group and validate. Also added index.d.ts (re-)export files to git so that interdependent packages can use their types locally.
### Patch Changes
- Updated dependencies [3c61fd29]
- Updated dependencies [09d96759]
- Updated dependencies [9ecab4d5]
- @lion/core@0.9.0
- @lion/localize@0.14.0
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.3.0](https://github.com/ing-bank/lion/compare/@lion/form-core@0.2.6...@lion/form-core@0.3.0) (2020-07-27)
## [0.3.1](https://github.com/ing-bank/lion/compare/@lion/form-core@0.3.0...@lion/form-core@0.3.1) (2020-07-28)
### Bug Fixes
### Features
- resolve registrationComplete via microtask to be sync ([6b8daef](https://github.com/ing-bank/lion/commit/6b8daef5099ab7bab40f9bdd8aaa1a0795b56214))
* synchronous form registration system ([8698f73](https://github.com/ing-bank/lion/commit/8698f734186eb88c4669bbadf8d5ae461f1c27f5))
# [0.3.0](https://github.com/ing-bank/lion/compare/@lion/form-core@0.2.6...@lion/form-core@0.3.0) (2020-07-27)
### Features
- synchronous form registration system ([8698f73](https://github.com/ing-bank/lion/commit/8698f734186eb88c4669bbadf8d5ae461f1c27f5))
## [0.2.6](https://github.com/ing-bank/lion/compare/@lion/form-core@0.2.5...@lion/form-core@0.2.6) (2020-07-16)
### Bug Fixes
* **form-core:** remove possible untrusted input value callback ([e06196d](https://github.com/ing-bank/lion/commit/e06196dec86f6d99fda0ed1c5dac3f62e3e34f6d))
- **form-core:** remove possible untrusted input value callback ([e06196d](https://github.com/ing-bank/lion/commit/e06196dec86f6d99fda0ed1c5dac3f62e3e34f6d))
## [0.2.5](https://github.com/ing-bank/lion/compare/@lion/form-core@0.2.4...@lion/form-core@0.2.5) (2020-07-13)

@@ -43,6 +48,2 @@

## [0.2.4](https://github.com/ing-bank/lion/compare/@lion/form-core@0.2.3...@lion/form-core@0.2.4) (2020-07-09)

@@ -52,28 +53,14 @@

## [0.2.3](https://github.com/ing-bank/lion/compare/@lion/form-core@0.2.2...@lion/form-core@0.2.3) (2020-07-09)
### Bug Fixes
* **formgroup:** wait for formElements to register ([780383d](https://github.com/ing-bank/lion/commit/780383d081607c0099004c2824a1493ced3d78a9))
- **formgroup:** wait for formElements to register ([780383d](https://github.com/ing-bank/lion/commit/780383d081607c0099004c2824a1493ced3d78a9))
## [0.2.2](https://github.com/ing-bank/lion/compare/@lion/form-core@0.2.1...@lion/form-core@0.2.2) (2020-07-09)
### Bug Fixes
* **select-rich:** instantiation after first update ([184898c](https://github.com/ing-bank/lion/commit/184898c111dcb81f269fffc6b82688cc6f25aac2))
- **select-rich:** instantiation after first update ([184898c](https://github.com/ing-bank/lion/commit/184898c111dcb81f269fffc6b82688cc6f25aac2))
## [0.2.1](https://github.com/ing-bank/lion/compare/@lion/form-core@0.2.0...@lion/form-core@0.2.1) (2020-07-07)

@@ -83,17 +70,8 @@

# [0.2.0](https://github.com/ing-bank/lion/compare/@lion/form-core@0.1.6...@lion/form-core@0.2.0) (2020-07-06)
### Features
* **choice-input:** add rendering of help-text ([5cd36ca](https://github.com/ing-bank/lion/commit/5cd36cac20c763d47ee495daede421bb66c4d0ba))
- **choice-input:** add rendering of help-text ([5cd36ca](https://github.com/ing-bank/lion/commit/5cd36cac20c763d47ee495daede421bb66c4d0ba))
## [0.1.6](https://github.com/ing-bank/lion/compare/@lion/form-core@0.1.5...@lion/form-core@0.1.6) (2020-06-18)

@@ -103,6 +81,2 @@

## [0.1.5](https://github.com/ing-bank/lion/compare/@lion/form-core@0.1.4...@lion/form-core@0.1.5) (2020-06-10)

@@ -112,17 +86,8 @@

## [0.1.4](https://github.com/ing-bank/lion/compare/@lion/form-core@0.1.3...@lion/form-core@0.1.4) (2020-06-09)
### Bug Fixes
* **form-core:** on reset the submitted values becomes false ([#753](https://github.com/ing-bank/lion/issues/753)) ([d86cfc5](https://github.com/ing-bank/lion/commit/d86cfc59018a2e5dcff0b2f5728683fc4e4861e6))
- **form-core:** on reset the submitted values becomes false ([#753](https://github.com/ing-bank/lion/issues/753)) ([d86cfc5](https://github.com/ing-bank/lion/commit/d86cfc59018a2e5dcff0b2f5728683fc4e4861e6))
## [0.1.3](https://github.com/ing-bank/lion/compare/@lion/form-core@0.1.2...@lion/form-core@0.1.3) (2020-06-08)

@@ -132,6 +97,2 @@

## [0.1.2](https://github.com/ing-bank/lion/compare/@lion/form-core@0.1.1...@lion/form-core@0.1.2) (2020-06-08)

@@ -141,23 +102,13 @@

## [0.1.1](https://github.com/ing-bank/lion/compare/@lion/form-core@0.1.0...@lion/form-core@0.1.1) (2020-06-03)
### Bug Fixes
* **field:** remove validation toggled disable ([e24f2ef](https://github.com/ing-bank/lion/commit/e24f2efcff7ffba6076faa4f3ce17ca4c8062b72))
* remove all stories folders from npm ([1e04d06](https://github.com/ing-bank/lion/commit/1e04d06921f9d5e1a446b6d14045154ff83771c3))
- **field:** remove validation toggled disable ([e24f2ef](https://github.com/ing-bank/lion/commit/e24f2efcff7ffba6076faa4f3ce17ca4c8062b72))
- remove all stories folders from npm ([1e04d06](https://github.com/ing-bank/lion/commit/1e04d06921f9d5e1a446b6d14045154ff83771c3))
# 0.1.0 (2020-05-29)
### Features
* merge field/validate/choice-input/form-group into @lion/form-core ([6170374](https://github.com/ing-bank/lion/commit/6170374ee8c058cb95fff79b4953b0535219e9b4))
- merge field/validate/choice-input/form-group into @lion/form-core ([6170374](https://github.com/ing-bank/lion/commit/6170374ee8c058cb95fff79b4953b0535219e9b4))
{
"name": "@lion/form-core",
"version": "0.3.1",
"version": "0.4.1",
"description": "Form-core contains all essential building blocks for creating form fields and fieldsets",

@@ -35,4 +35,4 @@ "license": "MIT",

"dependencies": {
"@lion/core": "0.8.0",
"@lion/localize": "0.13.1"
"@lion/core": "0.9.0",
"@lion/localize": "0.14.0"
},

@@ -46,4 +46,3 @@ "keywords": [

"access": "public"
},
"gitHead": "0afd2edb79c5c611353b81aaa41d5e134957138b"
}
}

@@ -84,2 +84,3 @@ /* eslint-disable class-methods-use-this */

this.modelValue = { value: '', checked: false };
this.__toggleChecked = this.__toggleChecked.bind(this);
}

@@ -86,0 +87,0 @@

import { dedupeMixin } from '@lion/core';
/**
* @typedef {import('../types/FocusMixinTypes').FocusMixin} FocusMixin
* @type {FocusMixin}
*/
const FocusMixinImplementation = superclass =>
// eslint-disable-next-line no-unused-vars, max-len, no-shadow
class FocusMixin extends superclass {
static get properties() {
return {
focused: {
type: Boolean,
reflect: true,
},
};
}
export const FocusMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-unused-vars, max-len, no-shadow
class FocusMixin extends superclass {
static get properties() {
return {
focused: {
type: Boolean,
reflect: true,
},
};
}
constructor() {
super();
this.focused = false;
}
constructor() {
super();
this.focused = false;
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
this.__registerEventsForFocusMixin();
}
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
this.__registerEventsForFocusMixin();
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
this.__teardownEventsForFocusMixin();
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
this.__teardownEventsForFocusMixin();
focus() {
const native = this._inputNode;
if (native) {
native.focus();
}
}
focus() {
const native = this._inputNode;
if (native) {
native.focus();
}
blur() {
const native = this._inputNode;
if (native) {
native.blur();
}
}
blur() {
const native = this._inputNode;
if (native) {
native.blur();
}
}
__onFocus() {
this.focused = true;
}
__onFocus() {
if (super.__onFocus) {
super.__onFocus();
}
this.focused = true;
}
__onBlur() {
this.focused = false;
}
__onBlur() {
if (super.__onBlur) {
super.__onBlur();
}
this.focused = false;
}
__registerEventsForFocusMixin() {
/**
* focus
* @param {Event} ev
*/
this.__redispatchFocus = ev => {
ev.stopPropagation();
this.dispatchEvent(new Event('focus'));
};
this._inputNode.addEventListener('focus', this.__redispatchFocus);
__registerEventsForFocusMixin() {
// focus
this.__redispatchFocus = ev => {
ev.stopPropagation();
this.dispatchEvent(new Event('focus'));
};
this._inputNode.addEventListener('focus', this.__redispatchFocus);
/**
* blur
* @param {Event} ev
*/
this.__redispatchBlur = ev => {
ev.stopPropagation();
this.dispatchEvent(new Event('blur'));
};
this._inputNode.addEventListener('blur', this.__redispatchBlur);
// blur
this.__redispatchBlur = ev => {
ev.stopPropagation();
this.dispatchEvent(new Event('blur'));
};
this._inputNode.addEventListener('blur', this.__redispatchBlur);
/**
* focusin
* @param {Event} ev
*/
this.__redispatchFocusin = ev => {
ev.stopPropagation();
this.__onFocus();
this.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
};
this._inputNode.addEventListener('focusin', this.__redispatchFocusin);
// focusin
this.__redispatchFocusin = ev => {
ev.stopPropagation();
this.__onFocus(ev);
this.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
};
this._inputNode.addEventListener('focusin', this.__redispatchFocusin);
/**
* focusout
* @param {Event} ev
*/
this.__redispatchFocusout = ev => {
ev.stopPropagation();
this.__onBlur();
this.dispatchEvent(new Event('focusout', { bubbles: true, composed: true }));
};
this._inputNode.addEventListener('focusout', this.__redispatchFocusout);
}
// focusout
this.__redispatchFocusout = ev => {
ev.stopPropagation();
this.__onBlur();
this.dispatchEvent(new Event('focusout', { bubbles: true, composed: true }));
};
this._inputNode.addEventListener('focusout', this.__redispatchFocusout);
}
__teardownEventsForFocusMixin() {
this._inputNode.removeEventListener('focus', this.__redispatchFocus);
this._inputNode.removeEventListener('blur', this.__redispatchBlur);
this._inputNode.removeEventListener('focusin', this.__redispatchFocusin);
this._inputNode.removeEventListener('focusout', this.__redispatchFocusout);
}
};
__teardownEventsForFocusMixin() {
this._inputNode.removeEventListener('focus', this.__redispatchFocus);
this._inputNode.removeEventListener('blur', this.__redispatchBlur);
this._inputNode.removeEventListener('focusin', this.__redispatchFocusin);
this._inputNode.removeEventListener('focusout', this.__redispatchFocusout);
}
},
);
export const FocusMixin = dedupeMixin(FocusMixinImplementation);

@@ -306,3 +306,3 @@ import { dedupeMixin, html, SlotMixin } from '@lion/core';

const result = {};
this.formElements.keys().forEach(name => {
this.formElements._keys().forEach(name => {
const elem = this.formElements[name];

@@ -309,0 +309,0 @@ if (elem instanceof FormControlsCollection) {

@@ -6,2 +6,7 @@ /* eslint-disable class-methods-use-this */

/**
* @typedef {import('../types/FormatMixinTypes').FormatMixin} FormatMixin
* @typedef {import('../types/FormatMixinTypes').FormatOptions} FormatOptions
*/
// For a future breaking release:

@@ -49,345 +54,363 @@ // - do not allow the private `.formattedValue` as property that can be set to

* Flow: serializedValue (deserializer) -> `.modelValue` (formatter) -> `.formattedValue` -> `._inputNode.value`
*
* @type {FormatMixin}
*/
export const FormatMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-unused-vars, no-shadow
class FormatMixin extends superclass {
static get properties() {
return {
/**
* The model value is the result of the parser function(when available).
* It should be considered as the internal value used for validation and reasoning/logic.
* The model value is 'ready for consumption' by the outside world (think of a Date
* object or a float). The modelValue can(and is recommended to) be used as both input
* value and output value of the `LionField`.
*
* Examples:
* - For a date input: a String '20/01/1999' will be converted to new Date('1999/01/20')
* - For a number input: a formatted String '1.234,56' will be converted to a Number:
* 1234.56
*/
modelValue: {
type: Object,
},
const FormatMixinImplementation = superclass =>
class FormatMixin extends superclass {
static get properties() {
return {
/**
* The model value is the result of the parser function(when available).
* It should be considered as the internal value used for validation and reasoning/logic.
* The model value is 'ready for consumption' by the outside world (think of a Date
* object or a float). The modelValue can(and is recommended to) be used as both input
* value and output value of the `LionField`.
*
* Examples:
* - For a date input: a String '20/01/1999' will be converted to new Date('1999/01/20')
* - For a number input: a formatted String '1.234,56' will be converted to a Number:
* 1234.56
*/
modelValue: { attribute: false },
/**
* The view value is the result of the formatter function (when available).
* The result will be stored in the native _inputNode (usually an input[type=text]).
*
* Examples:
* - For a date input, this would be '20/01/1999' (dependent on locale).
* - For a number input, this could be '1,234.56' (a String representation of modelValue
* 1234.56)
*
* @private
*/
formattedValue: {
type: String,
},
/**
* 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]).
*
* Examples:
* - For a date input, this would be '20/01/1999' (dependent on locale).
* - For a number input, this could be '1,234.56' (a String representation of modelValue
* 1234.56)
*
* @private
*/
formattedValue: { attribute: false },
/**
* The serialized version of the model value.
* This value exists for maximal compatibility with the platform API.
* The serialized value can be an interface in context where data binding is not
* supported and a serialized string needs to be set.
*
* Examples:
* - For a date input, this would be the iso format of a date, e.g. '1999-01-20'.
* - For a number input this would be the String representation of a float ('1234.56'
* instead of 1234.56)
*
* When no parser is available, the value is usually the same as the formattedValue
* (being _inputNode.value)
*
*/
serializedValue: {
type: String,
},
/**
* The serialized version of the model value.
* This value exists for maximal compatibility with the platform API.
* The serialized value can be an interface in context where data binding is not
* supported and a serialized string needs to be set.
*
* Examples:
* - For a date input, this would be the iso format of a date, e.g. '1999-01-20'.
* - For a number input this would be the String representation of a float ('1234.56'
* instead of 1234.56)
*
* When no parser is available, the value is usually the same as the formattedValue
* (being _inputNode.value)
*
*/
serializedValue: { attribute: false },
/**
* Event that will trigger formatting (more precise, visual update of the view, so the
* user sees the formatted value)
* Default: 'change'
*/
formatOn: {
type: String,
},
/**
* Event that will trigger formatting (more precise, visual update of the view, so the
* user sees the formatted value)
* Default: 'change'
*/
formatOn: { attribute: false },
/**
* Configuration object that will be available inside the formatter function
*/
formatOptions: {
type: Object,
},
};
}
/**
* Configuration object that will be available inside the formatter function
*/
formatOptions: { attribute: false },
};
}
_requestUpdate(name, oldVal) {
super._requestUpdate(name, oldVal);
/**
* @param {string} name
* @param {any} oldVal
*/
_requestUpdate(name, oldVal) {
super._requestUpdate(name, oldVal);
if (name === 'modelValue' && this.modelValue !== oldVal) {
this._onModelValueChanged({ modelValue: this.modelValue }, { modelValue: oldVal });
}
if (name === 'serializedValue' && this.serializedValue !== oldVal) {
this._calculateValues({ source: 'serialized' });
}
if (name === 'formattedValue' && this.formattedValue !== oldVal) {
this._calculateValues({ source: 'formatted' });
}
if (name === 'modelValue' && this.modelValue !== oldVal) {
this._onModelValueChanged({ modelValue: this.modelValue }, { modelValue: oldVal });
}
/**
* Converts formattedValue to modelValue
* For instance, a localized date to a Date Object
* @param {String} value - formattedValue: the formatted value inside <input>
* @returns {Object} modelValue
*/
parser(v) {
return v;
if (name === 'serializedValue' && this.serializedValue !== oldVal) {
this._calculateValues({ source: 'serialized' });
}
/**
* Converts modelValue to formattedValue (formattedValue will be synced with
* `._inputNode.value`)
* For instance, a Date object to a localized date.
* @param {Object} value - modelValue: can be an Object, Number, String depending on the
* input type(date, number, email etc)
* @returns {String} formattedValue
*/
formatter(v) {
return v;
if (name === 'formattedValue' && this.formattedValue !== oldVal) {
this._calculateValues({ source: 'formatted' });
}
}
/**
* Converts `.modelValue` to `.serializedValue`
* For instance, a Date object to an iso formatted date string
* @param {Object} value - modelValue: can be an Object, Number, String depending on the
* input type(date, number, email etc)
* @returns {String} serializedValue
*/
serializer(v) {
return v !== undefined ? v : '';
}
/**
* Converts formattedValue to modelValue
* For instance, a localized date to a Date Object
* @param {string} v - formattedValue: the formatted value inside <input>
* @param {FormatOptions} opts
* @returns {*} modelValue
*/
// eslint-disable-next-line no-unused-vars
parser(v, opts) {
return v;
}
/**
* Converts `LionField.value` to `.modelValue`
* For instance, an iso formatted date string to a Date object
* @param {Object} value - modelValue: can be an Object, Number, String depending on the
* input type(date, number, email etc)
* @returns {Object} modelValue
*/
deserializer(v) {
return v === undefined ? '' : v;
}
/**
* Converts modelValue to formattedValue (formattedValue will be synced with
* `._inputNode.value`)
* For instance, a Date object to a localized date.
* @param {*} v - modelValue: can be an Object, Number, String depending on the
* input type(date, number, email etc)
* @param {FormatOptions} opts
* @returns {string} formattedValue
*/
// eslint-disable-next-line no-unused-vars
formatter(v, opts) {
return v;
}
/**
* Responsible for storing all representations(modelValue, serializedValue, formattedValue
* and value) of the input value. Prevents infinite loops, so all value observers can be
* treated like they will only be called once, without indirectly calling other observers.
* (in fact, some are called twice, but the __preventRecursiveTrigger lock prevents the
* second call from having effect).
*
* @param {string} source - the type of value that triggered this method. It should not be
* set again, so that its observer won't be triggered. Can be:
* 'model'|'formatted'|'serialized'.
*/
_calculateValues({ source } = {}) {
if (this.__preventRecursiveTrigger) return; // prevent infinite loops
/**
* Converts `.modelValue` to `.serializedValue`
* For instance, a Date object to an iso formatted date string
* @param {?} v - modelValue: can be an Object, Number, String depending on the
* input type(date, number, email etc)
* @returns {string} serializedValue
*/
serializer(v) {
return v !== undefined ? v : '';
}
this.__preventRecursiveTrigger = true;
if (source !== 'model') {
if (source === 'serialized') {
this.modelValue = this.deserializer(this.serializedValue);
} else if (source === 'formatted') {
this.modelValue = this.__callParser();
}
}
if (source !== 'formatted') {
this.formattedValue = this.__callFormatter();
}
if (source !== 'serialized') {
this.serializedValue = this.serializer(this.modelValue);
}
this._reflectBackFormattedValueToUser();
this.__preventRecursiveTrigger = false;
}
/**
* Converts `LionField.value` to `.modelValue`
* For instance, an iso formatted date string to a Date object
* @param {?} v - modelValue: can be an Object, Number, String depending on the
* input type(date, number, email etc)
* @returns {?} modelValue
*/
deserializer(v) {
return v === undefined ? '' : v;
}
__callParser(value = this.formattedValue) {
// A) check if we need to parse at all
/**
* Responsible for storing all representations(modelValue, serializedValue, formattedValue
* and value) of the input value. Prevents infinite loops, so all value observers can be
* treated like they will only be called once, without indirectly calling other observers.
* (in fact, some are called twice, but the __preventRecursiveTrigger lock prevents the
* second call from having effect).
*
* @param {{source:'model'|'serialized'|'formatted'|null}} config - the type of value that triggered this method. It should not be
* set again, so that its observer won't be triggered. Can be:
* 'model'|'formatted'|'serialized'.
*/
_calculateValues({ source } = { source: null }) {
if (this.__preventRecursiveTrigger) return; // prevent infinite loops
// A.1) The end user had no intention to parse
if (value === '') {
// Ideally, modelValue should be undefined for empty strings.
// For backwards compatibility we return an empty string:
// - it triggers validation for required validators (see ValidateMixin.validate())
// - it can be expected by 3rd parties (for instance unit tests)
// TODO(@tlouisse): In a breaking refactor of the Validation System, this behavior can be corrected.
return '';
/** @type {boolean} */
this.__preventRecursiveTrigger = true;
if (source !== 'model') {
if (source === 'serialized') {
/** @type {?} */
this.modelValue = this.deserializer(this.serializedValue);
} else if (source === 'formatted') {
this.modelValue = this.__callParser();
}
}
if (source !== 'formatted') {
/** @type {string} */
this.formattedValue = this.__callFormatter();
}
if (source !== 'serialized') {
/** @type {string} */
this.serializedValue = this.serializer(this.modelValue);
}
this._reflectBackFormattedValueToUser();
this.__preventRecursiveTrigger = false;
}
// A.2) Handle edge cases We might have no view value yet, for instance because
// _inputNode.value was not available yet
if (typeof value !== 'string') {
// This means there is nothing to find inside the view that can be of
// interest to the Application Developer or needed to store for future
// form state retrieval.
return undefined;
}
/**
* @param {string|undefined} value
* @return {?}
*/
__callParser(value = this.formattedValue) {
// A) check if we need to parse at all
// B) parse the view value
// A.1) The end user had no intention to parse
if (value === '') {
// Ideally, modelValue should be undefined for empty strings.
// For backwards compatibility we return an empty string:
// - it triggers validation for required validators (see ValidateMixin.validate())
// - it can be expected by 3rd parties (for instance unit tests)
// TODO(@tlouisse): In a breaking refactor of the Validation System, this behavior can be corrected.
return '';
}
// - if result:
// return the successfully parsed viewValue
// - if no result:
// Apparently, the parser was not able to produce a satisfactory output for the desired
// modelValue type, based on the current viewValue. Unparseable allows to restore all
// states (for instance from a lost user session), since it saves the current viewValue.
const result = this.parser(value, this.formatOptions);
return result !== undefined ? result : new Unparseable(value);
// A.2) Handle edge cases We might have no view value yet, for instance because
// _inputNode.value was not available yet
if (typeof value !== 'string') {
// This means there is nothing to find inside the view that can be of
// interest to the Application Developer or needed to store for future
// form state retrieval.
return undefined;
}
__callFormatter() {
// - Why check for this.hasError?
// We only want to format values that are considered valid. For best UX,
// we only 'reward' valid inputs.
// - Why check for __isHandlingUserInput?
// Downwards sync is prevented whenever we are in an `@user-input-changed` flow, [2].
// If we are in a 'imperatively set `.modelValue`' flow, [1], we want to reflect back
// the value, no matter what.
// This means, whenever we are in hasError and modelValue is set
// imperatively, we DO want to format a value (it is the only way to get meaningful
// input into `._inputNode` with modelValue as input)
// B) parse the view value
if (
this.__isHandlingUserInput &&
this.hasFeedbackFor &&
this.hasFeedbackFor.length &&
this.hasFeedbackFor.includes('error') &&
this._inputNode
) {
return this._inputNode ? this.value : undefined;
}
// - if result:
// return the successfully parsed viewValue
// - if no result:
// Apparently, the parser was not able to produce a satisfactory output for the desired
// modelValue type, based on the current viewValue. Unparseable allows to restore all
// states (for instance from a lost user session), since it saves the current viewValue.
const result = this.parser(value, this.formatOptions);
return result !== undefined ? result : new Unparseable(value);
}
if (this.modelValue instanceof Unparseable) {
// When the modelValue currently is unparseable, we need to sync back the supplied
// viewValue. In flow [2], this should not be needed.
// In flow [1] (we restore a previously stored modelValue) we should sync down, however.
return this.modelValue.viewValue;
}
/**
* @returns {string|undefined}
*/
__callFormatter() {
// - Why check for this.hasError?
// We only want to format values that are considered valid. For best UX,
// we only 'reward' valid inputs.
// - Why check for __isHandlingUserInput?
// Downwards sync is prevented whenever we are in an `@user-input-changed` flow, [2].
// If we are in a 'imperatively set `.modelValue`' flow, [1], we want to reflect back
// the value, no matter what.
// This means, whenever we are in hasError and modelValue is set
// imperatively, we DO want to format a value (it is the only way to get meaningful
// input into `._inputNode` with modelValue as input)
return this.formatter(this.modelValue, this.formatOptions);
if (
this.__isHandlingUserInput &&
this.hasFeedbackFor &&
this.hasFeedbackFor.length &&
this.hasFeedbackFor.includes('error') &&
this._inputNode
) {
return this._inputNode ? this.value : undefined;
}
/** Observer Handlers */
_onModelValueChanged(...args) {
this._calculateValues({ source: 'model' });
this._dispatchModelValueChangedEvent(...args);
if (this.modelValue instanceof Unparseable) {
// When the modelValue currently is unparseable, we need to sync back the supplied
// viewValue. In flow [2], this should not be needed.
// In flow [1] (we restore a previously stored modelValue) we should sync down, however.
return this.modelValue.viewValue;
}
/**
* This is wrapped in a distinct method, so that parents can control when the changed event
* is fired. For objects, a deep comparison might be needed.
*/
_dispatchModelValueChangedEvent() {
/** @event model-value-changed */
this.dispatchEvent(
new CustomEvent('model-value-changed', {
bubbles: true,
detail: { formPath: [this] },
}),
);
}
return this.formatter(this.modelValue, this.formatOptions);
}
/**
* Synchronization from `._inputNode.value` to `LionField` (flow [2])
*/
_syncValueUpwards() {
// Downwards syncing should only happen for `LionField`.value changes from 'above'
// This triggers _onModelValueChanged and connects user input to the
// parsing/formatting/serializing loop
this.modelValue = this.__callParser(this.value);
}
/**
* Observer Handlers
* @param {{ modelValue: unknown; }[]} args
*/
_onModelValueChanged(...args) {
this._calculateValues({ source: 'model' });
// @ts-ignore only passing this so a subclasser can use it, but we do not use it ourselves
this._dispatchModelValueChangedEvent(...args);
}
/**
* Synchronization from `LionField.value` to `._inputNode.value`
* - flow [1] will always be reflected back
* - flow [2] will not be reflected back when this flow was triggered via
* `@user-input-changed` (this will happen later, when `formatOn` condition is met)
*/
_reflectBackFormattedValueToUser() {
if (this._reflectBackOn()) {
// Text 'undefined' should not end up in <input>
this.value = typeof this.formattedValue !== 'undefined' ? this.formattedValue : '';
}
}
/**
* This is wrapped in a distinct method, so that parents can control when the changed event
* is fired. For objects, a deep comparison might be needed.
*/
_dispatchModelValueChangedEvent() {
/** @event model-value-changed */
this.dispatchEvent(
new CustomEvent('model-value-changed', {
bubbles: true,
detail: { formPath: [this] },
}),
);
}
_reflectBackOn() {
return !this.__isHandlingUserInput;
}
/**
* Synchronization from `._inputNode.value` to `LionField` (flow [2])
*/
_syncValueUpwards() {
// Downwards syncing should only happen for `LionField`.value changes from 'above'
// This triggers _onModelValueChanged and connects user input to the
// parsing/formatting/serializing loop
this.modelValue = this.__callParser(this.value);
}
// This can be called whenever the view value should be updated. Dependent on component type
// ("input" for <input> or "change" for <select>(mainly for IE)) a different event should be
// used as source for the "user-input-changed" event (which can be seen as an abstraction
// layer on top of other events (input, change, whatever))
_proxyInputEvent() {
this.dispatchEvent(
new CustomEvent('user-input-changed', {
bubbles: true,
composed: true,
}),
);
/**
* Synchronization from `LionField.value` to `._inputNode.value`
* - flow [1] will always be reflected back
* - flow [2] will not be reflected back when this flow was triggered via
* `@user-input-changed` (this will happen later, when `formatOn` condition is met)
*/
_reflectBackFormattedValueToUser() {
if (this._reflectBackOn()) {
// Text 'undefined' should not end up in <input>
this.value = typeof this.formattedValue !== 'undefined' ? this.formattedValue : '';
}
}
_onUserInputChanged() {
// Upwards syncing. Most properties are delegated right away, value is synced to
// `LionField`, to be able to act on (imperatively set) value changes
this.__isHandlingUserInput = true;
/**
* @return {boolean}
*/
_reflectBackOn() {
return !this.__isHandlingUserInput;
}
// This can be called whenever the view value should be updated. Dependent on component type
// ("input" for <input> or "change" for <select>(mainly for IE)) a different event should be
// used as source for the "user-input-changed" event (which can be seen as an abstraction
// layer on top of other events (input, change, whatever))
_proxyInputEvent() {
this.dispatchEvent(
new CustomEvent('user-input-changed', {
bubbles: true,
composed: true,
}),
);
}
_onUserInputChanged() {
// Upwards syncing. Most properties are delegated right away, value is synced to
// `LionField`, to be able to act on (imperatively set) value changes
this.__isHandlingUserInput = true;
this._syncValueUpwards();
this.__isHandlingUserInput = false;
}
constructor() {
super();
this.formatOn = 'change';
/** @type {FormatOptions} */
this.formatOptions = {};
}
connectedCallback() {
super.connectedCallback();
this._reflectBackFormattedValueToUser = this._reflectBackFormattedValueToUser.bind(this);
this._reflectBackFormattedValueDebounced = () => {
// Make sure this is fired after the change event of _inputNode, so that formattedValue
// is guaranteed to be calculated
setTimeout(this._reflectBackFormattedValueToUser);
};
this.addEventListener('user-input-changed', this._onUserInputChanged);
// Connect the value found in <input> to the formatting/parsing/serializing loop as a
// fallback mechanism. Assume the user uses the value property of the
// `LionField`(recommended api) as the api (this is a downwards sync).
// However, when no value is specified on `LionField`, have support for sync of the real
// input to the `LionField` (upwards sync).
if (typeof this.modelValue === 'undefined') {
this._syncValueUpwards();
this.__isHandlingUserInput = false;
}
this._reflectBackFormattedValueToUser();
constructor() {
super();
this.formatOn = 'change';
this.formatOptions = {};
if (this._inputNode) {
this._inputNode.addEventListener(this.formatOn, this._reflectBackFormattedValueDebounced);
this._inputNode.addEventListener('input', this._proxyInputEvent);
}
}
connectedCallback() {
super.connectedCallback();
this._reflectBackFormattedValueToUser = this._reflectBackFormattedValueToUser.bind(this);
this._reflectBackFormattedValueDebounced = () => {
// Make sure this is fired after the change event of _inputNode, so that formattedValue
// is guaranteed to be calculated
setTimeout(this._reflectBackFormattedValueToUser);
};
this.addEventListener('user-input-changed', this._onUserInputChanged);
// Connect the value found in <input> to the formatting/parsing/serializing loop as a
// fallback mechanism. Assume the user uses the value property of the
// `LionField`(recommended api) as the api (this is a downwards sync).
// However, when no value is specified on `LionField`, have support for sync of the real
// input to the `LionField` (upwards sync).
if (typeof this.modelValue === 'undefined') {
this._syncValueUpwards();
}
this._reflectBackFormattedValueToUser();
if (this._inputNode) {
this._inputNode.addEventListener(this.formatOn, this._reflectBackFormattedValueDebounced);
this._inputNode.addEventListener('input', this._proxyInputEvent);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('user-input-changed', this._onUserInputChanged);
if (this._inputNode) {
this._inputNode.removeEventListener('input', this._proxyInputEvent);
this._inputNode.removeEventListener(
this.formatOn,
this._reflectBackFormattedValueDebounced,
);
}
}
};
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('user-input-changed', this._onUserInputChanged);
if (this._inputNode) {
this._inputNode.removeEventListener('input', this._proxyInputEvent);
this._inputNode.removeEventListener(
this.formatOn,
this._reflectBackFormattedValueDebounced,
);
}
}
},
);
export const FormatMixin = dedupeMixin(FormatMixinImplementation);

@@ -20,664 +20,764 @@ import { css, dedupeMixin, html, nothing, SlotMixin } from '@lion/core';

* - LionFieldset (which is extended to LionRadioGroup, LionCheckboxGroup, LionForm)
*
* @polymerMixin
* @mixinFunction
* @typedef {import('lit-html').TemplateResult} TemplateResult
* @typedef {import('lit-element').CSSResult} CSSResult
* @typedef {import('lit-html').nothing} nothing
* @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap
* @typedef {import('../types/FormControlMixinTypes.js').FormControlMixin} FormControlMixin
* @type {FormControlMixin}
*/
export const FormControlMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormControlMixin extends FormRegisteringMixin(SlotMixin(superclass)) {
static get properties() {
return {
/**
* The name the element will be registered on to the .formElements collection
* of the parent.
*/
name: {
type: String,
reflect: true,
},
/**
* When no light dom defined and prop set
*/
label: String,
/**
* When no light dom defined and prop set
*/
helpText: {
type: String,
attribute: 'help-text',
},
/**
* Contains all elements that should end up in aria-labelledby of `._inputNode`
*/
_ariaLabelledNodes: Array,
/**
* Contains all elements that should end up in aria-describedby of `._inputNode`
*/
_ariaDescribedNodes: Array,
/**
* Based on the role, details of handling model-value-changed repropagation differ.
* @type {'child'|'fieldset'|'choice-group'}
*/
_repropagationRole: String,
/**
* By default, a field with _repropagationRole 'choice-group' will act as an
* 'endpoint'. This means it will be considered as an individual field: for
* a select, individual options will not be part of the formPath. They
* will.
* Similarly, components that (a11y wise) need to be fieldsets, but 'interaction wise'
* (from Application Developer perspective) need to be more like fields
* (think of an amount-input with a currency select box next to it), can set this
* to true to hide private internals in the formPath.
*/
_isRepropagationEndpoint: Boolean,
};
}
const FormControlMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormControlMixin extends FormRegisteringMixin(SlotMixin(superclass)) {
static get properties() {
return {
/**
* The name the element will be registered on to the .formElements collection
* of the parent.
*/
name: {
type: String,
reflect: true,
},
/**
* When no light dom defined and prop set
*/
label: String, // FIXME: { attribute: false } breaks a bunch of tests, but shouldn't...
/**
* When no light dom defined and prop set
*/
helpText: {
type: String,
attribute: 'help-text',
},
/**
* Contains all elements that should end up in aria-labelledby of `._inputNode`
*/
_ariaLabelledNodes: { attribute: false },
/**
* Contains all elements that should end up in aria-describedby of `._inputNode`
*/
_ariaDescribedNodes: { attribute: false },
/**
* Based on the role, details of handling model-value-changed repropagation differ.
*/
_repropagationRole: { attribute: false },
/**
* By default, a field with _repropagationRole 'choice-group' will act as an
* 'endpoint'. This means it will be considered as an individual field: for
* a select, individual options will not be part of the formPath. They
* will.
* Similarly, components that (a11y wise) need to be fieldsets, but 'interaction wise'
* (from Application Developer perspective) need to be more like fields
* (think of an amount-input with a currency select box next to it), can set this
* to true to hide private internals in the formPath.
*/
_isRepropagationEndpoint: { attribute: false },
};
}
get label() {
return this.__label || (this._labelNode && this._labelNode.textContent);
}
/**
* @return {string}
*/
get label() {
return this.__label || (this._labelNode && this._labelNode.textContent) || '';
}
set label(newValue) {
const oldValue = this.label;
this.__label = newValue;
this.requestUpdate('label', oldValue);
}
/**
* @param {string} newValue
*/
set label(newValue) {
const oldValue = this.label;
/** @type {string} */
this.__label = newValue;
this.requestUpdate('label', oldValue);
}
get helpText() {
return this.__helpText || (this._helpTextNode && this._helpTextNode.textContent);
}
/**
* @return {string}
*/
get helpText() {
return this.__helpText || (this._helpTextNode && this._helpTextNode.textContent) || '';
}
set helpText(newValue) {
const oldValue = this.helpText;
this.__helpText = newValue;
this.requestUpdate('helpText', oldValue);
/**
* @param {string} newValue
*/
set helpText(newValue) {
const oldValue = this.helpText;
/** @type {string} */
this.__helpText = newValue;
this.requestUpdate('helpText', oldValue);
}
/**
* @return {string}
*/
get fieldName() {
return this.__fieldName || this.label || this.name;
}
/**
* @param {string} value
*/
set fieldName(value) {
/** @type {string} */
this.__fieldName = value;
}
/**
* @return {SlotsMap}
*/
get slots() {
return {
...super.slots,
label: () => {
const label = document.createElement('label');
label.textContent = this.label;
return label;
},
'help-text': () => {
const helpText = document.createElement('div');
helpText.textContent = this.helpText;
return helpText;
},
};
}
/** @param {import('lit-element').PropertyValues } changedProperties */
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('_ariaLabelledNodes')) {
this.__reflectAriaAttr(
'aria-labelledby',
this._ariaLabelledNodes,
this.__reorderAriaLabelledNodes,
);
}
set fieldName(value) {
this.__fieldName = value;
if (changedProperties.has('_ariaDescribedNodes')) {
this.__reflectAriaAttr(
'aria-describedby',
this._ariaDescribedNodes,
this.__reorderAriaDescribedNodes,
);
}
get fieldName() {
return this.__fieldName || this.label || this.name;
if (changedProperties.has('label')) {
this._onLabelChanged({ label: this.label });
}
get slots() {
return {
...super.slots,
label: () => {
const label = document.createElement('label');
label.textContent = this.label;
return label;
},
'help-text': () => {
const helpText = document.createElement('div');
helpText.textContent = this.helpText;
return helpText;
},
};
if (changedProperties.has('helpText')) {
this._onHelpTextChanged({ helpText: this.helpText });
}
}
updated(changedProperties) {
super.updated(changedProperties);
get _inputNode() {
return this.__getDirectSlotChild('input');
}
if (changedProperties.has('_ariaLabelledNodes')) {
this.__reflectAriaAttr(
'aria-labelledby',
this._ariaLabelledNodes,
this.__reorderAriaLabelledNodes,
);
}
get _labelNode() {
return this.__getDirectSlotChild('label');
}
if (changedProperties.has('_ariaDescribedNodes')) {
this.__reflectAriaAttr(
'aria-describedby',
this._ariaDescribedNodes,
this.__reorderAriaDescribedNodes,
);
}
get _helpTextNode() {
return this.__getDirectSlotChild('help-text');
}
if (changedProperties.has('label')) {
this._onLabelChanged({ label: this.label });
}
get _feedbackNode() {
return this.__getDirectSlotChild('feedback');
}
if (changedProperties.has('helpText')) {
this._onHelpTextChanged({ helpText: this.helpText });
}
}
constructor() {
super();
/** @type {string} */
this._inputId = uuid(this.localName);
/** @type {HTMLElement[]} */
this._ariaLabelledNodes = [];
/** @type {HTMLElement[]} */
this._ariaDescribedNodes = [];
/** @type {'child' | 'choice-group' | 'fieldset'} */
this._repropagationRole = 'child';
this.addEventListener('model-value-changed', this.__repropagateChildrenValues);
}
get _inputNode() {
return this.__getDirectSlotChild('input');
}
connectedCallback() {
super.connectedCallback();
this._enhanceLightDomClasses();
this._enhanceLightDomA11y();
this._triggerInitialModelValueChangedEvent();
}
get _labelNode() {
return this.__getDirectSlotChild('label');
}
_triggerInitialModelValueChangedEvent() {
this.__dispatchInitialModelValueChangedEvent();
}
get _helpTextNode() {
return this.__getDirectSlotChild('help-text');
_enhanceLightDomClasses() {
if (this._inputNode) {
this._inputNode.classList.add('form-control');
}
}
get _feedbackNode() {
return this.__getDirectSlotChild('feedback');
}
_enhanceLightDomA11y() {
const { _inputNode, _labelNode, _helpTextNode, _feedbackNode } = this;
constructor() {
super();
this._inputId = uuid(this.localName);
this._ariaLabelledNodes = [];
this._ariaDescribedNodes = [];
this._repropagationRole = 'child';
this.addEventListener('model-value-changed', this.__repropagateChildrenValues);
if (_inputNode) {
_inputNode.id = _inputNode.id || this._inputId;
}
connectedCallback() {
super.connectedCallback();
this._enhanceLightDomClasses();
this._enhanceLightDomA11y();
this._triggerInitialModelValueChangedEvent();
if (_labelNode) {
_labelNode.setAttribute('for', this._inputId);
this.addToAriaLabelledBy(_labelNode, { idPrefix: 'label' });
}
_triggerInitialModelValueChangedEvent() {
this.__dispatchInitialModelValueChangedEvent();
if (_helpTextNode) {
this.addToAriaDescribedBy(_helpTextNode, { idPrefix: 'help-text' });
}
_enhanceLightDomClasses() {
if (this._inputNode) {
this._inputNode.classList.add('form-control');
}
if (_feedbackNode) {
_feedbackNode.setAttribute('aria-live', 'polite');
this.addToAriaDescribedBy(_feedbackNode, { idPrefix: 'feedback' });
}
this._enhanceLightDomA11yForAdditionalSlots();
}
_enhanceLightDomA11y() {
const { _inputNode, _labelNode, _helpTextNode, _feedbackNode } = this;
if (_inputNode) {
_inputNode.id = _inputNode.id || this._inputId;
}
if (_labelNode) {
_labelNode.setAttribute('for', this._inputId);
this.addToAriaLabelledBy(_labelNode, { idPrefix: 'label' });
}
if (_helpTextNode) {
this.addToAriaDescribedBy(_helpTextNode, { idPrefix: 'help-text' });
}
if (_feedbackNode) {
_feedbackNode.setAttribute('aria-live', 'polite');
this.addToAriaDescribedBy(_feedbackNode, { idPrefix: 'feedback' });
}
this._enhanceLightDomA11yForAdditionalSlots();
}
/**
* Enhances additional slots(prefix, suffix, before, after) defined by developer.
*
* When boolean attribute data-label or data-description is found,
* the slot element will be connected to the input via aria-labelledby or aria-describedby
*/
_enhanceLightDomA11yForAdditionalSlots(
additionalSlots = ['prefix', 'suffix', 'before', 'after'],
) {
additionalSlots.forEach(additionalSlot => {
const element = this.__getDirectSlotChild(additionalSlot);
if (element) {
if (element.hasAttribute('data-label') === true) {
this.addToAriaLabelledBy(element, { idPrefix: additionalSlot });
}
if (element.hasAttribute('data-description') === true) {
this.addToAriaDescribedBy(element, { idPrefix: additionalSlot });
}
/**
* Enhances additional slots(prefix, suffix, before, after) defined by developer.
*
* When boolean attribute data-label or data-description is found,
* the slot element will be connected to the input via aria-labelledby or aria-describedby
* @param {string[]} additionalSlots
*/
_enhanceLightDomA11yForAdditionalSlots(
additionalSlots = ['prefix', 'suffix', 'before', 'after'],
) {
additionalSlots.forEach(additionalSlot => {
const element = this.__getDirectSlotChild(additionalSlot);
if (element) {
if (element.hasAttribute('data-label') === true) {
this.addToAriaLabelledBy(element, { idPrefix: additionalSlot });
}
});
}
/**
* Will handle help text, validation feedback and character counter,
* prefix/suffix/before/after (if they contain data-description flag attr).
* Also, contents of id references that will be put in the <lion-field>._ariaDescribedby property
* from an external context, will be read by a screen reader.
*/
__reflectAriaAttr(attrName, nodes, reorder) {
if (this._inputNode) {
if (reorder) {
const insideNodes = nodes.filter(n => this.contains(n));
const outsideNodes = nodes.filter(n => !this.contains(n));
// eslint-disable-next-line no-param-reassign
nodes = [...getAriaElementsInRightDomOrder(insideNodes), ...outsideNodes];
if (element.hasAttribute('data-description') === true) {
this.addToAriaDescribedBy(element, { idPrefix: additionalSlot });
}
const string = nodes.map(n => n.id).join(' ');
this._inputNode.setAttribute(attrName, string);
}
}
});
}
_onLabelChanged({ label }) {
if (this._labelNode) {
this._labelNode.textContent = label;
/**
* Will handle help text, validation feedback and character counter,
* prefix/suffix/before/after (if they contain data-description flag attr).
* Also, contents of id references that will be put in the <lion-field>._ariaDescribedby property
* from an external context, will be read by a screen reader.
* @param {string} attrName
* @param {HTMLElement[]} nodes
* @param {boolean|undefined} reorder
*/
__reflectAriaAttr(attrName, nodes, reorder) {
if (this._inputNode) {
if (reorder) {
const insideNodes = nodes.filter(n => this.contains(n));
const outsideNodes = nodes.filter(n => !this.contains(n));
// eslint-disable-next-line no-param-reassign
nodes = [...getAriaElementsInRightDomOrder(insideNodes), ...outsideNodes];
}
const string = nodes.map(n => n.id).join(' ');
this._inputNode.setAttribute(attrName, string);
}
}
_onHelpTextChanged({ helpText }) {
if (this._helpTextNode) {
this._helpTextNode.textContent = helpText;
}
/**
*
* @param {{label:string}} opts
*/
_onLabelChanged({ label }) {
if (this._labelNode) {
this._labelNode.textContent = label;
}
}
/**
* Default Render Result:
* <div class="form-field__group-one">
* <div class="form-field__label">
* <slot name="label"></slot>
* </div>
* <small class="form-field__help-text">
* <slot name="help-text"></slot>
* </small>
* </div>
* <div class="form-field__group-two">
* <div class="input-group">
* <div class="input-group__before">
* <slot name="before"></slot>
* </div>
* <div class="input-group__container">
* <div class="input-group__prefix">
* <slot name="prefix"></slot>
* </div>
* <div class="input-group__input">
* <slot name="input"></slot>
* </div>
* <div class="input-group__suffix">
* <slot name="suffix"></slot>
* </div>
* </div>
* <div class="input-group__after">
* <slot name="after"></slot>
* </div>
* </div>
* <div class="form-field__feedback">
* <slot name="feedback"></slot>
* </div>
* </div>
*/
render() {
return html`
<div class="form-field__group-one">
${this._groupOneTemplate()}
</div>
<div class="form-field__group-two">
${this._groupTwoTemplate()}
</div>
`;
/**
*
* @param {{helpText:string}} opts
*/
_onHelpTextChanged({ helpText }) {
if (this._helpTextNode) {
this._helpTextNode.textContent = helpText;
}
}
_groupOneTemplate() {
return html` ${this._labelTemplate()} ${this._helpTextTemplate()} `;
}
/**
* Default Render Result:
* <div class="form-field__group-one">
* <div class="form-field__label">
* <slot name="label"></slot>
* </div>
* <small class="form-field__help-text">
* <slot name="help-text"></slot>
* </small>
* </div>
* <div class="form-field__group-two">
* <div class="input-group">
* <div class="input-group__before">
* <slot name="before"></slot>
* </div>
* <div class="input-group__container">
* <div class="input-group__prefix">
* <slot name="prefix"></slot>
* </div>
* <div class="input-group__input">
* <slot name="input"></slot>
* </div>
* <div class="input-group__suffix">
* <slot name="suffix"></slot>
* </div>
* </div>
* <div class="input-group__after">
* <slot name="after"></slot>
* </div>
* </div>
* <div class="form-field__feedback">
* <slot name="feedback"></slot>
* </div>
* </div>
*/
render() {
return html`
<div class="form-field__group-one">
${this._groupOneTemplate()}
</div>
<div class="form-field__group-two">
${this._groupTwoTemplate()}
</div>
`;
}
_groupTwoTemplate() {
return html` ${this._inputGroupTemplate()} ${this._feedbackTemplate()} `;
}
/**
* @return {TemplateResult}
*/
_groupOneTemplate() {
return html` ${this._labelTemplate()} ${this._helpTextTemplate()} `;
}
// eslint-disable-next-line class-methods-use-this
_labelTemplate() {
return html`
<div class="form-field__label">
<slot name="label"></slot>
</div>
`;
}
/**
* @return {TemplateResult}
*/
_groupTwoTemplate() {
return html` ${this._inputGroupTemplate()} ${this._feedbackTemplate()} `;
}
// eslint-disable-next-line class-methods-use-this
_helpTextTemplate() {
return html`
<small class="form-field__help-text">
<slot name="help-text"></slot>
</small>
`;
}
/**
* @return {TemplateResult}
*/
// eslint-disable-next-line class-methods-use-this
_labelTemplate() {
return html`
<div class="form-field__label">
<slot name="label"></slot>
</div>
`;
}
_inputGroupTemplate() {
return html`
<div class="input-group">
${this._inputGroupBeforeTemplate()}
<div class="input-group__container">
${this._inputGroupPrefixTemplate()} ${this._inputGroupInputTemplate()}
${this._inputGroupSuffixTemplate()}
</div>
${this._inputGroupAfterTemplate()}
</div>
`;
}
/**
* @return {TemplateResult}
*/
// eslint-disable-next-line class-methods-use-this
_helpTextTemplate() {
return html`
<small class="form-field__help-text">
<slot name="help-text"></slot>
</small>
`;
}
// eslint-disable-next-line class-methods-use-this
_inputGroupBeforeTemplate() {
return html`
<div class="input-group__before">
<slot name="before"></slot>
/**
* @return {TemplateResult}
*/
_inputGroupTemplate() {
return html`
<div class="input-group">
${this._inputGroupBeforeTemplate()}
<div class="input-group__container">
${this._inputGroupPrefixTemplate()} ${this._inputGroupInputTemplate()}
${this._inputGroupSuffixTemplate()}
</div>
`;
}
${this._inputGroupAfterTemplate()}
</div>
`;
}
_inputGroupPrefixTemplate() {
return !Array.from(this.children).find(child => child.slot === 'prefix')
? nothing
: html`
<div class="input-group__prefix">
<slot name="prefix"></slot>
</div>
`;
}
/**
* @return {TemplateResult}
*/
// eslint-disable-next-line class-methods-use-this
_inputGroupBeforeTemplate() {
return html`
<div class="input-group__before">
<slot name="before"></slot>
</div>
`;
}
// eslint-disable-next-line class-methods-use-this
_inputGroupInputTemplate() {
return html`
<div class="input-group__input">
<slot name="input"></slot>
</div>
`;
}
/**
* @return {TemplateResult | nothing}
*/
_inputGroupPrefixTemplate() {
return !Array.from(this.children).find(child => child.slot === 'prefix')
? nothing
: html`
<div class="input-group__prefix">
<slot name="prefix"></slot>
</div>
`;
}
_inputGroupSuffixTemplate() {
return !Array.from(this.children).find(child => child.slot === 'suffix')
? nothing
: html`
<div class="input-group__suffix">
<slot name="suffix"></slot>
</div>
`;
}
/**
* @return {TemplateResult}
*/
// eslint-disable-next-line class-methods-use-this
_inputGroupInputTemplate() {
return html`
<div class="input-group__input">
<slot name="input"></slot>
</div>
`;
}
// eslint-disable-next-line class-methods-use-this
_inputGroupAfterTemplate() {
return html`
<div class="input-group__after">
<slot name="after"></slot>
</div>
`;
}
/**
* @return {TemplateResult | nothing}
*/
_inputGroupSuffixTemplate() {
return !Array.from(this.children).find(child => child.slot === 'suffix')
? nothing
: html`
<div class="input-group__suffix">
<slot name="suffix"></slot>
</div>
`;
}
_isEmpty(modelValue = this.modelValue) {
let value = modelValue;
if (this.modelValue instanceof Unparseable) {
value = this.modelValue.viewValue;
}
/**
* @return {TemplateResult}
*/
// eslint-disable-next-line class-methods-use-this
_inputGroupAfterTemplate() {
return html`
<div class="input-group__after">
<slot name="after"></slot>
</div>
`;
}
// Checks for empty platform types: Objects, Arrays, Dates
if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
return !Object.keys(value).length;
}
/**
* @return {TemplateResult}
*/
// eslint-disable-next-line class-methods-use-this
_feedbackTemplate() {
return html`
<div class="form-field__feedback">
<slot name="feedback"></slot>
</div>
`;
}
// eslint-disable-next-line no-mixed-operators
// Checks for empty platform types: Numbers, Booleans
const isNumberValue = typeof value === 'number' && (value === 0 || Number.isNaN(value));
const isBooleanValue = typeof value === 'boolean' && value === false;
return !value && !isNumberValue && !isBooleanValue;
/**
* @param {?} modelValue
* @return {boolean}
*/
_isEmpty(modelValue = this.modelValue) {
let value = modelValue;
if (this.modelValue instanceof Unparseable) {
value = this.modelValue.viewValue;
}
// eslint-disable-next-line class-methods-use-this
_feedbackTemplate() {
return html`
<div class="form-field__feedback">
<slot name="feedback"></slot>
</div>
`;
// Checks for empty platform types: Objects, Arrays, Dates
if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
return !Object.keys(value).length;
}
/**
* All CSS below is written from a generic mindset, following BEM conventions:
* https://en.bem.info/methodology/
* Although the CSS and HTML are implemented by the component, they should be regarded as
* totally decoupled.
*
* Not only does this force us to write better structured css, it also allows for future
* reusability in many different ways like:
* - disabling shadow DOM for a component (for water proof encapsulation can be combined with
* a build step)
* - easier translation to more flexible, WebComponents agnostic solutions like JSS
* (allowing extends, mixins, reasoning, IDE integration, tree shaking etc.)
* - export to a CSS module for reuse in an outer context
*
*
* Please note that the HTML structure is purposely 'loose', allowing multiple design systems
* to be compatible
* with the CSS component.
* Note that every occurence of '::slotted(*)' can be rewritten to '> *' for use in an other
* context
*/
// eslint-disable-next-line no-mixed-operators
// Checks for empty platform types: Numbers, Booleans
const isNumberValue = typeof value === 'number' && (value === 0 || Number.isNaN(value));
const isBooleanValue = typeof value === 'boolean' && value === false;
/**
* {block} .form-field
*
* Structure:
* - {element} .form-field__label : a wrapper element around the projected label
* - {element} .form-field__help-text (optional) : a wrapper element around the projected
* help-text
* - {block} .input-group : a container around the input element, including prefixes and
* suffixes
* - {element} .form-field__feedback (optional) : a wrapper element around the projected
* (validation) feedback message
*
* Modifiers:
* - {state} [disabled] when .form-control (<input>, <textarea> etc.) has disabled set
* to true
* - {state} [filled] whether <input> has a value
* - {state} [touched] whether the user had blurred the field once
* - {state} [dirty] whether the value has changed since initial value
*
* TODO: update states below
* These classes are now attributes. Check them agains the new attribute names inside ValidateMixin
* and InteractionStateMixin. Some states got renamed. Make sure to use the correct ones!
* - {state} .state-focused: when .form-control (<input>, <textarea> etc.) <input> has focus
* - {state} .state-invalid: when input has error(s) (regardless of whether they should be
* shown to the user)
* - {state} .state-error: when input has error(s) and this/these should be shown to the user
* - {state} .state-warning: when input has warning(s) and this/these should be shown to the
* user
* - {state} .state-info: when input has info feedback message(s) and this/these should be shown
* to the user
* - {state} .state-success: when input has success feedback message(s) and this/these should be
* shown to the user
*/
return !value && !isNumberValue && !isBooleanValue;
}
/**
* {block} .input-group
*
* Structure:
* - {element} .input-group__before (optional) : a prefix that resides outside the container
* - {element} .input-group__container : an inner container: this element contains all styling
* - {element} .input-group__prefix (optional) : a prefix that resides in the container,
* allowing it to be detectable as a :first-child
* - {element} .input-group__input : a wrapper around the form-control component
* - {block} .form-control : the actual input element (input/select/textarea)
* - {element} .input-group__suffix (optional) : a suffix that resides inside the container,
* allowing it to be detectable as a :last-child
* - {element} .input-group__bottom (optional) : placeholder element for additional styling
* (like an animated line for material design input)
* - {element} .input-group__after (optional) : a suffix that resides outside the container
*/
static get styles() {
return [
css`
/**********************
{block} .form-field
********************/
/**
* All CSS below is written from a generic mindset, following BEM conventions:
* https://en.bem.info/methodology/
* Although the CSS and HTML are implemented by the component, they should be regarded as
* totally decoupled.
*
* Not only does this force us to write better structured css, it also allows for future
* reusability in many different ways like:
* - disabling shadow DOM for a component (for water proof encapsulation can be combined with
* a build step)
* - easier translation to more flexible, WebComponents agnostic solutions like JSS
* (allowing extends, mixins, reasoning, IDE integration, tree shaking etc.)
* - export to a CSS module for reuse in an outer context
*
*
* Please note that the HTML structure is purposely 'loose', allowing multiple design systems
* to be compatible
* with the CSS component.
* Note that every occurence of '::slotted(*)' can be rewritten to '> *' for use in an other
* context
*/
:host {
display: block;
}
/**
* {block} .form-field
*
* Structure:
* - {element} .form-field__label : a wrapper element around the projected label
* - {element} .form-field__help-text (optional) : a wrapper element around the projected
* help-text
* - {block} .input-group : a container around the input element, including prefixes and
* suffixes
* - {element} .form-field__feedback (optional) : a wrapper element around the projected
* (validation) feedback message
*
* Modifiers:
* - {state} [disabled] when .form-control (<input>, <textarea> etc.) has disabled set
* to true
* - {state} [filled] whether <input> has a value
* - {state} [touched] whether the user had blurred the field once
* - {state} [dirty] whether the value has changed since initial value
*
* TODO: update states below
* These classes are now attributes. Check them agains the new attribute names inside ValidateMixin
* and InteractionStateMixin. Some states got renamed. Make sure to use the correct ones!
* - {state} .state-focused: when .form-control (<input>, <textarea> etc.) <input> has focus
* - {state} .state-invalid: when input has error(s) (regardless of whether they should be
* shown to the user)
* - {state} .state-error: when input has error(s) and this/these should be shown to the user
* - {state} .state-warning: when input has warning(s) and this/these should be shown to the
* user
* - {state} .state-info: when input has info feedback message(s) and this/these should be shown
* to the user
* - {state} .state-success: when input has success feedback message(s) and this/these should be
* shown to the user
*/
:host([hidden]) {
display: none;
}
/**
* {block} .input-group
*
* Structure:
* - {element} .input-group__before (optional) : a prefix that resides outside the container
* - {element} .input-group__container : an inner container: this element contains all styling
* - {element} .input-group__prefix (optional) : a prefix that resides in the container,
* allowing it to be detectable as a :first-child
* - {element} .input-group__input : a wrapper around the form-control component
* - {block} .form-control : the actual input element (input/select/textarea)
* - {element} .input-group__suffix (optional) : a suffix that resides inside the container,
* allowing it to be detectable as a :last-child
* - {element} .input-group__bottom (optional) : placeholder element for additional styling
* (like an animated line for material design input)
* - {element} .input-group__after (optional) : a suffix that resides outside the container
*
* @return {CSSResult | CSSResult[]}
*/
static get styles() {
return [
css`
/**********************
{block} .form-field
********************/
:host([disabled]) {
pointer-events: none;
}
:host {
display: block;
}
:host([disabled]) .form-field__label ::slotted(*),
:host([disabled]) .form-field__help-text ::slotted(*) {
color: var(--disabled-text-color, #adadad);
}
:host([hidden]) {
display: none;
}
/***********************
{block} .input-group
*********************/
:host([disabled]) {
pointer-events: none;
}
.input-group__container {
display: flex;
}
:host([disabled]) .form-field__label ::slotted(*),
:host([disabled]) .form-field__help-text ::slotted(*) {
color: var(--disabled-text-color, #adadad);
}
.input-group__input {
flex: 1;
display: flex;
}
/***********************
{block} .input-group
*********************/
/***** {state} :disabled *****/
:host([disabled]) .input-group ::slotted(slot='input') {
color: var(--disabled-text-color, #adadad);
}
.input-group__container {
display: flex;
}
/***********************
{block} .form-control
**********************/
.input-group__input {
flex: 1;
display: flex;
}
.input-group__container > .input-group__input ::slotted(.form-control) {
flex: 1 1 auto;
margin: 0; /* remove input margin in Safari */
font-size: 100%; /* normalize default input font-size */
}
`,
];
}
/***** {state} :disabled *****/
:host([disabled]) .input-group ::slotted(slot='input') {
color: var(--disabled-text-color, #adadad);
}
// Returns dom references to all elements that should be referred to by field(s)
_getAriaDescriptionElements() {
return [this._helpTextNode, this._feedbackNode];
}
/***********************
{block} .form-control
**********************/
/**
* Meant for Application Developers wanting to add to aria-labelledby attribute.
* @param {Element} element
*/
addToAriaLabelledBy(element, customConfig = {}) {
const { idPrefix, reorder } = {
reorder: true,
...customConfig,
};
.input-group__container > .input-group__input ::slotted(.form-control) {
flex: 1 1 auto;
margin: 0; /* remove input margin in Safari */
font-size: 100%; /* normalize default input font-size */
}
`,
];
}
// eslint-disable-next-line no-param-reassign
element.id = element.id || `${idPrefix}-${this._inputId}`;
if (!this._ariaLabelledNodes.includes(element)) {
this._ariaLabelledNodes = [...this._ariaLabelledNodes, element];
// This value will be read when we need to reflect to attr
this.__reorderAriaLabelledNodes = Boolean(reorder);
}
}
/**
* @return {HTMLElement[]}
*/
// Returns dom references to all elements that should be referred to by field(s)
_getAriaDescriptionElements() {
return [this._helpTextNode, this._feedbackNode];
}
/**
* Meant for Application Developers wanting to add to aria-describedby attribute.
* @param {Element} element
*/
addToAriaDescribedBy(element, customConfig = {}) {
const { idPrefix, reorder } = {
// chronologically sorts children of host element('this')
reorder: true,
...customConfig,
};
/**
* Meant for Application Developers wanting to add to aria-labelledby attribute.
* @param {HTMLElement} element
* @param {{idPrefix?:string; reorder?: boolean}} customConfig
*/
addToAriaLabelledBy(element, customConfig = {}) {
const { idPrefix, reorder } = {
reorder: true,
...customConfig,
};
// eslint-disable-next-line no-param-reassign
element.id = element.id || `${idPrefix}-${this._inputId}`;
if (!this._ariaDescribedNodes.includes(element)) {
this._ariaDescribedNodes = [...this._ariaDescribedNodes, element];
// This value will be read when we need to reflect to attr
this.__reorderAriaDescribedNodes = Boolean(reorder);
}
// eslint-disable-next-line no-param-reassign
element.id = element.id || `${idPrefix}-${this._inputId}`;
if (!this._ariaLabelledNodes.includes(element)) {
this._ariaLabelledNodes = [...this._ariaLabelledNodes, element];
// This value will be read when we need to reflect to attr
/** @type {boolean} */
this.__reorderAriaLabelledNodes = Boolean(reorder);
}
}
__getDirectSlotChild(slotName) {
return [...this.children].find(el => el.slot === slotName);
/**
* Meant for Application Developers wanting to add to aria-describedby attribute.
* @param {HTMLElement} element
* @param {{idPrefix?:string; reorder?: boolean}} customConfig
*/
addToAriaDescribedBy(element, customConfig = {}) {
const { idPrefix, reorder } = {
// chronologically sorts children of host element('this')
reorder: true,
...customConfig,
};
// eslint-disable-next-line no-param-reassign
element.id = element.id || `${idPrefix}-${this._inputId}`;
if (!this._ariaDescribedNodes.includes(element)) {
this._ariaDescribedNodes = [...this._ariaDescribedNodes, element];
// This value will be read when we need to reflect to attr
/** @type {boolean} */
this.__reorderAriaDescribedNodes = Boolean(reorder);
}
}
__dispatchInitialModelValueChangedEvent() {
// When we are not a fieldset / choice-group, we don't need to wait for our children
// to send a unified event
if (this._repropagationRole === 'child') {
return;
}
/**
* @param {string} slotName
* @return {HTMLElement}
*/
__getDirectSlotChild(slotName) {
return [...this.children].find(el => el.slot === slotName);
}
// Initially we don't repropagate model-value-changed events coming
// from children. On firstUpdated we re-dispatch this event to maintain
// 'count consistency' (to not confuse the application developer with a
// large number of initial events). Initially the source field will not
// be part of the formPath but afterwards it will.
this.__repropagateChildrenInitialized = true;
this.dispatchEvent(
new CustomEvent('model-value-changed', {
bubbles: true,
detail: { formPath: [this], initialize: true },
}),
);
__dispatchInitialModelValueChangedEvent() {
// When we are not a fieldset / choice-group, we don't need to wait for our children
// to send a unified event
if (this._repropagationRole === 'child') {
return;
}
// eslint-disable-next-line class-methods-use-this, no-unused-vars
_onBeforeRepropagateChildrenValues(ev) {}
// Initially we don't repropagate model-value-changed events coming
// from children. On firstUpdated we re-dispatch this event to maintain
// 'count consistency' (to not confuse the application developer with a
// large number of initial events). Initially the source field will not
// be part of the formPath but afterwards it will.
/** @type {boolean} */
this.__repropagateChildrenInitialized = true;
this.dispatchEvent(
new CustomEvent('model-value-changed', {
bubbles: true,
detail: { formPath: [this], initialize: true },
}),
);
}
__repropagateChildrenValues(ev) {
// Allows sub classes to internally listen to the children change events
// (before stopImmediatePropagation is called below).
this._onBeforeRepropagateChildrenValues(ev);
// Normalize target, we also might get it from 'portals' (rich select)
const target = (ev.detail && ev.detail.element) || ev.target;
const isEndpoint =
this._isRepropagationEndpoint || this._repropagationRole === 'choice-group';
/**
* @param {CustomEvent} ev
*/
// eslint-disable-next-line class-methods-use-this, no-unused-vars
_onBeforeRepropagateChildrenValues(ev) {}
// Prevent eternal loops after we sent the event below.
if (target === this) {
return;
}
/**
* @param {CustomEvent} ev
*/
__repropagateChildrenValues(ev) {
// Allows sub classes to internally listen to the children change events
// (before stopImmediatePropagation is called below).
this._onBeforeRepropagateChildrenValues(ev);
// Normalize target, we also might get it from 'portals' (rich select)
const target = (ev.detail && ev.detail.element) || ev.target;
const isEndpoint =
this._isRepropagationEndpoint || this._repropagationRole === 'choice-group';
// A. Stop sibling handlers
//
// Make sure our sibling event listeners (added by Application developers) will not get
// the child model-value-changed event, but the repropagated one at the bottom of this
// method
ev.stopImmediatePropagation();
// Prevent eternal loops after we sent the event below.
if (target === this) {
return;
}
// B1. Are we still initializing? If so, halt...
//
// Stop repropagating children events before firstUpdated and make sure we de not
// repropagate init events of our children (we already sent our own
// initial model-value-change event in firstUpdated)
const isGroup = this._repropagationRole !== 'child'; // => fieldset or choice-group
const isSelfInitializing = isGroup && !this.__repropagateChildrenInitialized;
const isChildGroupInitializing = ev.detail && ev.detail.initialize;
if (isSelfInitializing || isChildGroupInitializing) {
return;
}
// A. Stop sibling handlers
//
// Make sure our sibling event listeners (added by Application developers) will not get
// the child model-value-changed event, but the repropagated one at the bottom of this
// method
ev.stopImmediatePropagation();
// B2. Are we a single choice choice-group? If so, halt when unchecked
//
// We only send the checked changed up (not the unchecked). In this way a choice group
// (radio-group, checkbox-group, select/listbox) acts as an 'endpoint' (a single Field)
// just like the native <select>
if (this._repropagationRole === 'choice-group' && !this.multipleChoice && !target.checked) {
return;
}
// B1. Are we still initializing? If so, halt...
//
// Stop repropagating children events before firstUpdated and make sure we de not
// repropagate init events of our children (we already sent our own
// initial model-value-change event in firstUpdated)
const isGroup = this._repropagationRole !== 'child'; // => fieldset or choice-group
const isSelfInitializing = isGroup && !this.__repropagateChildrenInitialized;
const isChildGroupInitializing = ev.detail && ev.detail.initialize;
if (isSelfInitializing || isChildGroupInitializing) {
return;
}
// C1. We are ready to dispatch. Create a formPath
//
// Compute the formPath. Choice groups are regarded 'end points'
let parentFormPath = [];
if (!isEndpoint) {
parentFormPath = (ev.detail && ev.detail.formPath) || [target];
}
const formPath = [...parentFormPath, this];
// B2. Are we a single choice choice-group? If so, halt when unchecked
//
// We only send the checked changed up (not the unchecked). In this way a choice group
// (radio-group, checkbox-group, select/listbox) acts as an 'endpoint' (a single Field)
// just like the native <select>
if (this._repropagationRole === 'choice-group' && !this.multipleChoice && !target.checked) {
return;
}
// C2. Finally, redispatch a fresh model-value-changed event from our host, consumable
// for an Application Developer
//
// Since for a11y everything needs to be in lightdom, we don't add 'composed:true'
this.dispatchEvent(
new CustomEvent('model-value-changed', { bubbles: true, detail: { formPath } }),
);
// C1. We are ready to dispatch. Create a formPath
//
// Compute the formPath. Choice groups are regarded 'end points'
let parentFormPath = [];
if (!isEndpoint) {
parentFormPath = (ev.detail && ev.detail.formPath) || [target];
}
},
);
const formPath = [...parentFormPath, this];
// C2. Finally, redispatch a fresh model-value-changed event from our host, consumable
// for an Application Developer
//
// Since for a11y everything needs to be in lightdom, we don't add 'composed:true'
this.dispatchEvent(
new CustomEvent('model-value-changed', { bubbles: true, detail: { formPath } }),
);
}
};
export const FormControlMixin = dedupeMixin(FormControlMixinImplementation);

@@ -5,2 +5,6 @@ import { dedupeMixin } from '@lion/core';

/**
* @typedef {import('../types/InteractionStateMixinTypes').InteractionStateMixin} InteractionStateMixin
*/
/**
* @desc `InteractionStateMixin` adds meta information about touched and dirty states, that can

@@ -15,145 +19,158 @@ * be read by other form components (ing-uic-input-error for instance, uses the touched state

*/
export const InteractionStateMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-unused-vars, no-shadow
class InteractionStateMixin extends FormControlMixin(superclass) {
static get properties() {
return {
/**
* True when user has focused and left(blurred) the field.
*/
touched: {
type: Boolean,
reflect: true,
},
/**
* True when user has changed the value of the field.
*/
dirty: {
type: Boolean,
reflect: true,
},
/**
* True when the modelValue is non-empty (see _isEmpty in FormControlMixin)
*/
filled: {
type: Boolean,
reflect: true,
},
/**
* True when user has left non-empty field or input is prefilled.
* The name must be seen from the point of view of the input field:
* once the user enters the input field, the value is non-empty.
*/
prefilled: {
type: Boolean,
},
/**
* True when user has attempted to submit the form, e.g. through a button
* of type="submit"
*/
submitted: {
type: Boolean,
},
};
}
_requestUpdate(name, oldVal) {
super._requestUpdate(name, oldVal);
if (name === 'touched' && this.touched !== oldVal) {
this._onTouchedChanged();
}
/**
* @type {InteractionStateMixin}
*/
const InteractionStateMixinImplementation = superclass =>
class InteractionStateMixin extends FormControlMixin(superclass) {
static get properties() {
return {
/**
* True when user has focused and left(blurred) the field.
*/
touched: {
type: Boolean,
reflect: true,
},
/**
* True when user has changed the value of the field.
*/
dirty: {
type: Boolean,
reflect: true,
},
/**
* True when the modelValue is non-empty (see _isEmpty in FormControlMixin)
*/
filled: {
type: Boolean,
reflect: true,
},
/**
* True when user has left non-empty field or input is prefilled.
* The name must be seen from the point of view of the input field:
* once the user enters the input field, the value is non-empty.
*/
prefilled: {
attribute: false,
},
/**
* True when user has attempted to submit the form, e.g. through a button
* of type="submit"
*/
submitted: {
attribute: false,
},
};
}
if (name === 'modelValue') {
// We do this in _requestUpdate because we don't want to fire another re-render (e.g. when doing this in updated)
// Furthermore, we cannot do it on model-value-changed event because it isn't fired initially.
this.filled = !this._isEmpty();
}
if (name === 'dirty' && this.dirty !== oldVal) {
this._onDirtyChanged();
}
/**
*
* @param {PropertyKey} name
* @param {*} oldVal
*/
_requestUpdate(name, oldVal) {
super._requestUpdate(name, oldVal);
if (name === 'touched' && this.touched !== oldVal) {
this._onTouchedChanged();
}
constructor() {
super();
this.touched = false;
this.dirty = false;
this.prefilled = false;
this.filled = false;
this._leaveEvent = 'blur';
this._valueChangedEvent = 'model-value-changed';
this._iStateOnLeave = this._iStateOnLeave.bind(this);
this._iStateOnValueChange = this._iStateOnValueChange.bind(this);
if (name === 'modelValue') {
// We do this in _requestUpdate because we don't want to fire another re-render (e.g. when doing this in updated)
// Furthermore, we cannot do it on model-value-changed event because it isn't fired initially.
this.filled = !this._isEmpty();
}
/**
* Register event handlers and validate prefilled inputs
*/
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
this.addEventListener(this._leaveEvent, this._iStateOnLeave);
this.addEventListener(this._valueChangedEvent, this._iStateOnValueChange);
this.initInteractionState();
if (name === 'dirty' && this.dirty !== oldVal) {
this._onDirtyChanged();
}
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
this.removeEventListener(this._leaveEvent, this._iStateOnLeave);
this.removeEventListener(this._valueChangedEvent, this._iStateOnValueChange);
}
constructor() {
super();
this.touched = false;
this.dirty = false;
this.prefilled = false;
this.filled = false;
/**
* Evaluations performed on connectedCallback. Since some components can be out of sync
* (due to interdependence on light children that can only be processed
* after connectedCallback and affect the initial value).
* This method is exposed, so it can be called after they are initialized themselves.
* Since this method will be called twice in last mentioned scenario, it must stay idempotent.
*/
initInteractionState() {
this.dirty = false;
this.prefilled = !this._isEmpty();
}
/** @type {string} */
this._leaveEvent = 'blur';
/** @type {string} */
this._valueChangedEvent = 'model-value-changed';
/** @type {EventHandlerNonNull} */
this._iStateOnLeave = this._iStateOnLeave.bind(this);
/** @type {EventHandlerNonNull} */
this._iStateOnValueChange = this._iStateOnValueChange.bind(this);
}
/**
* Sets touched value to true
* Reevaluates prefilled state.
* When false, on next interaction, user will start with a clean state.
* @protected
*/
_iStateOnLeave() {
this.touched = true;
this.prefilled = !this._isEmpty();
/**
* Register event handlers and validate prefilled inputs
*/
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
this.addEventListener(this._leaveEvent, this._iStateOnLeave);
this.addEventListener(this._valueChangedEvent, this._iStateOnValueChange);
this.initInteractionState();
}
/**
* Sets dirty value and validates when already touched or invalid
* @protected
*/
_iStateOnValueChange() {
this.dirty = true;
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
this.removeEventListener(this._leaveEvent, this._iStateOnLeave);
this.removeEventListener(this._valueChangedEvent, this._iStateOnValueChange);
}
/**
* Resets touched and dirty, and recomputes prefilled
*/
resetInteractionState() {
this.touched = false;
this.submitted = false;
this.dirty = false;
this.prefilled = !this._isEmpty();
}
/**
* Evaluations performed on connectedCallback. Since some components can be out of sync
* (due to interdependence on light children that can only be processed
* after connectedCallback and affect the initial value).
* This method is exposed, so it can be called after they are initialized themselves.
* Since this method will be called twice in last mentioned scenario, it must stay idempotent.
*/
initInteractionState() {
this.dirty = false;
this.prefilled = !this._isEmpty();
}
_onTouchedChanged() {
this.dispatchEvent(new CustomEvent('touched-changed', { bubbles: true, composed: true }));
}
/**
* Sets touched value to true
* Reevaluates prefilled state.
* When false, on next interaction, user will start with a clean state.
* @protected
*/
_iStateOnLeave() {
this.touched = true;
this.prefilled = !this._isEmpty();
}
_onDirtyChanged() {
this.dispatchEvent(new CustomEvent('dirty-changed', { bubbles: true, composed: true }));
}
},
);
/**
* Sets dirty value and validates when already touched or invalid
* @protected
*/
_iStateOnValueChange() {
this.dirty = true;
}
/**
* Resets touched and dirty, and recomputes prefilled
*/
resetInteractionState() {
this.touched = false;
this.submitted = false;
this.dirty = false;
this.prefilled = !this._isEmpty();
}
_onTouchedChanged() {
this.dispatchEvent(new CustomEvent('touched-changed', { bubbles: true, composed: true }));
}
_onDirtyChanged() {
this.dispatchEvent(new CustomEvent('dirty-changed', { bubbles: true, composed: true }));
}
};
export const InteractionStateMixin = dedupeMixin(InteractionStateMixinImplementation);

@@ -50,2 +50,3 @@ import { LitElement, SlotMixin } from '@lion/core';

/** @type {number} */
get selectionStart() {

@@ -66,2 +67,3 @@ const native = this._inputNode;

/** @type {number} */
get selectionEnd() {

@@ -83,2 +85,3 @@ const native = this._inputNode;

// We don't delegate, because we want to preserve caret position via _setValueAndPreserveCaret
/** @type {string} */
set value(value) {

@@ -85,0 +88,0 @@ // if not yet connected to dom can't change the value

@@ -0,1 +1,3 @@

/* eslint-disable */
/**

@@ -94,6 +96,7 @@ * @desc This class closely mimics the natively

* @desc Gives back the named keys and filters out array indexes
* @return {string[]}
*/
keys() {
_keys() {
return Object.keys(this).filter(k => Number.isNaN(Number(k)));
}
}
import { dedupeMixin } from '@lion/core';
/**
* @typedef {import('../../types/registration/FormRegisteringMixinTypes').FormRegisteringMixin} FormRegisteringMixin
*/
/**
* #FormRegisteringMixin:

@@ -8,30 +12,28 @@ *

*
* @polymerMixin
* @mixinFunction
* @type {FormRegisteringMixin}
*/
export const FormRegisteringMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormRegisteringMixin extends superclass {
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
this.dispatchEvent(
new CustomEvent('form-element-register', {
detail: { element: this },
bubbles: true,
}),
);
const FormRegisteringMixinImplementation = superclass =>
class FormRegisteringMixin extends superclass {
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
this.dispatchEvent(
new CustomEvent('form-element-register', {
detail: { element: this },
bubbles: true,
}),
);
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
if (this.__parentFormGroup) {
this.__parentFormGroup.removeFormElement(this);
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
},
);
if (this.__parentFormGroup) {
this.__parentFormGroup.removeFormElement(this);
}
}
};
export const FormRegisteringMixin = dedupeMixin(FormRegisteringMixinImplementation);

@@ -9,2 +9,7 @@ // eslint-disable-next-line max-classes-per-file

/**
* @typedef {import('../../types/registration/FormRegistrarMixinTypes').FormRegistrarMixin} FormRegistrarMixin
* @typedef {import('../../types/registration/FormRegistrarMixinTypes').ElementWithParentFormGroup} ElementWithParentFormGroup
*/
/**
* @desc This allows an element to become the manager of a register.

@@ -17,138 +22,157 @@ * It basically keeps track of a FormControlsCollection that it stores in .formElements

* See FormControlsCollection for more information
* @type {FormRegistrarMixin}
*/
export const FormRegistrarMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormRegistrarMixin extends FormRegisteringMixin(superclass) {
static get properties() {
return {
/**
* @desc Flag that determines how ".formElements" should behave.
* For a regular fieldset (see LionFieldset) we expect ".formElements"
* to be accessible as an object.
* In case of a radio-group, a checkbox-group or a select/listbox,
* it should act like an array (see ChoiceGroupMixin).
* Usually, when false, we deal with a choice-group (radio-group, checkbox-group,
* (multi)select)
* @type {boolean}
*/
_isFormOrFieldset: { type: Boolean },
};
}
const FormRegistrarMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormRegistrarMixin extends FormRegisteringMixin(superclass) {
static get properties() {
return {
/**
* @desc Flag that determines how ".formElements" should behave.
* For a regular fieldset (see LionFieldset) we expect ".formElements"
* to be accessible as an object.
* In case of a radio-group, a checkbox-group or a select/listbox,
* it should act like an array (see ChoiceGroupMixin).
* Usually, when false, we deal with a choice-group (radio-group, checkbox-group,
* (multi)select)
*/
_isFormOrFieldset: { type: Boolean },
};
}
constructor() {
super();
this.formElements = new FormControlsCollection();
constructor() {
super();
this.formElements = new FormControlsCollection();
this._isFormOrFieldset = false;
this._isFormOrFieldset = false;
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
this.addEventListener('form-element-register', this._onRequestToAddFormElement);
}
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
this.addEventListener('form-element-register', this._onRequestToAddFormElement);
}
isRegisteredFormElement(el) {
return this.formElements.some(exitingEl => exitingEl === el);
/**
*
* @param {ElementWithParentFormGroup} el
*/
isRegisteredFormElement(el) {
return this.formElements.some(exitingEl => exitingEl === el);
}
/**
* @param {ElementWithParentFormGroup} child the child element (field)
* @param {number} indexToInsertAt index to insert the form element at
*/
addFormElement(child, indexToInsertAt) {
// This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent
// eslint-disable-next-line no-param-reassign
child.__parentFormGroup = this;
// 1. Add children as array element
if (indexToInsertAt > 0) {
this.formElements.splice(indexToInsertAt, 0, child);
} else {
this.formElements.push(child);
}
addFormElement(child, indexToInsertAt) {
// This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent
// eslint-disable-next-line no-param-reassign
child.__parentFormGroup = this;
// 1. Add children as array element
if (indexToInsertAt > 0) {
this.formElements.splice(indexToInsertAt, 0, child);
} else {
this.formElements.push(child);
// 2. Add children as object key
if (this._isFormOrFieldset) {
// @ts-ignore
const { name } = child; // FIXME: <-- ElementWithParentFormGroup should become LionFieldWithParentFormGroup so that "name" exists
if (!name) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError('You need to define a name');
}
if (name === this.name) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(`You can not have the same name "${name}" as your parent`);
}
// 2. Add children as object key
if (this._isFormOrFieldset) {
const { name } = child;
if (!name) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError('You need to define a name');
if (name.substr(-2) === '[]') {
if (!Array.isArray(this.formElements[name])) {
this.formElements[name] = new FormControlsCollection();
}
if (name === this.name) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(`You can not have the same name "${name}" as your parent`);
}
if (name.substr(-2) === '[]') {
if (!Array.isArray(this.formElements[name])) {
this.formElements[name] = new FormControlsCollection();
}
if (indexToInsertAt > 0) {
this.formElements[name].splice(indexToInsertAt, 0, child);
} else {
this.formElements[name].push(child);
}
} else if (!this.formElements[name]) {
this.formElements[name] = child;
if (indexToInsertAt > 0) {
this.formElements[name].splice(indexToInsertAt, 0, child);
} else {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(
`Name "${name}" is already registered - if you want an array add [] to the end`,
);
this.formElements[name].push(child);
}
} else if (!this.formElements[name]) {
this.formElements[name] = child;
} else {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(
`Name "${name}" is already registered - if you want an array add [] to the end`,
);
}
}
}
removeFormElement(child) {
// 1. Handle array based children
const index = this.formElements.indexOf(child);
if (index > -1) {
this.formElements.splice(index, 1);
}
/**
* @param {ElementWithParentFormGroup} child the child element (field)
*/
removeFormElement(child) {
// 1. Handle array based children
const index = this.formElements.indexOf(child);
if (index > -1) {
this.formElements.splice(index, 1);
}
// 2. Handle name based object keys
if (this._isFormOrFieldset) {
const { name } = child;
if (name.substr(-2) === '[]' && this.formElements[name]) {
const idx = this.formElements[name].indexOf(child);
if (idx > -1) {
this.formElements[name].splice(idx, 1);
}
} else if (this.formElements[name]) {
delete this.formElements[name];
// 2. Handle name based object keys
if (this._isFormOrFieldset) {
// @ts-ignore
const { name } = child; // FIXME: <-- ElementWithParentFormGroup should become LionFieldWithParentFormGroup so that "name" exists
if (name.substr(-2) === '[]' && this.formElements[name]) {
const idx = this.formElements[name].indexOf(child);
if (idx > -1) {
this.formElements[name].splice(idx, 1);
}
} else if (this.formElements[name]) {
delete this.formElements[name];
}
}
}
_onRequestToAddFormElement(ev) {
const child = ev.detail.element;
if (child === this) {
// as we fire and listen - don't add ourselves
return;
}
if (this.isRegisteredFormElement(child)) {
// do not readd already existing elements
return;
}
ev.stopPropagation();
/**
* @param {CustomEvent} ev
*/
_onRequestToAddFormElement(ev) {
const child = ev.detail.element;
if (child === this) {
// as we fire and listen - don't add ourselves
return;
}
if (this.isRegisteredFormElement(child)) {
// do not readd already existing elements
return;
}
ev.stopPropagation();
// Check for siblings to determine the right order to insert into formElements
// If there is no next sibling, index is -1
let indexToInsertAt = -1;
if (this.formElements && Array.isArray(this.formElements)) {
indexToInsertAt = this.formElements.indexOf(child.nextElementSibling);
}
this.addFormElement(child, indexToInsertAt);
// Check for siblings to determine the right order to insert into formElements
// If there is no next sibling, index is -1
let indexToInsertAt = -1;
if (this.formElements && Array.isArray(this.formElements)) {
indexToInsertAt = this.formElements.indexOf(child.nextElementSibling);
}
this.addFormElement(child, indexToInsertAt);
}
_onRequestToRemoveFormElement(ev) {
const child = ev.detail.element;
if (child === this) {
// as we fire and listen - don't remove ourselves
return;
}
if (!this.isRegisteredFormElement(child)) {
// do not remove non existing elements
return;
}
ev.stopPropagation();
/**
* @param {CustomEvent} ev
*/
_onRequestToRemoveFormElement(ev) {
const child = ev.detail.element;
if (child === this) {
// as we fire and listen - don't remove ourselves
return;
}
if (!this.isRegisteredFormElement(child)) {
// do not remove non existing elements
return;
}
ev.stopPropagation();
this.removeFormElement(child);
}
},
);
this.removeFormElement(child);
}
};
export const FormRegistrarMixin = dedupeMixin(FormRegistrarMixinImplementation);
import { dedupeMixin } from '@lion/core';
/**
* @typedef {import('../../types/registration/FormRegistrarPortalMixinTypes').FormRegistrarPortalMixin} FormRegistrarPortalMixin
* @typedef {import('../../types/registration/FormRegistrarPortalMixinTypes').FormRegistrarPortalHost} FormRegistrarPortalHost
*/
/**
* This allows to register fields within a form even though they are not within the same dom tree.

@@ -14,32 +19,37 @@ * It does that by redispatching the event on the registration target.

* // my-field will be registered within my-form
* @type {FormRegistrarPortalMixin}
*/
export const FormRegistrarPortalMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormRegistrarPortalMixin extends superclass {
constructor() {
super();
this.registrationTarget = undefined;
this.__redispatchEventForFormRegistrarPortalMixin = this.__redispatchEventForFormRegistrarPortalMixin.bind(
this,
);
this.addEventListener(
'form-element-register',
this.__redispatchEventForFormRegistrarPortalMixin,
);
const FormRegistrarPortalMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormRegistrarPortalMixin extends superclass {
constructor() {
super();
/** @type {(FormRegistrarPortalHost & HTMLElement) | undefined} */
this.registrationTarget = undefined;
this.__redispatchEventForFormRegistrarPortalMixin = this.__redispatchEventForFormRegistrarPortalMixin.bind(
this,
);
this.addEventListener(
'form-element-register',
this.__redispatchEventForFormRegistrarPortalMixin,
);
}
/**
* @param {CustomEvent} ev
*/
__redispatchEventForFormRegistrarPortalMixin(ev) {
ev.stopPropagation();
if (!this.registrationTarget) {
throw new Error('A FormRegistrarPortal element requires a .registrationTarget');
}
this.registrationTarget.dispatchEvent(
new CustomEvent('form-element-register', {
detail: { element: ev.detail.element },
bubbles: true,
}),
);
}
};
__redispatchEventForFormRegistrarPortalMixin(ev) {
ev.stopPropagation();
if (!this.registrationTarget) {
throw new Error('A FormRegistrarPortal element requires a .registrationTarget');
}
this.registrationTarget.dispatchEvent(
new CustomEvent('form-element-register', {
detail: { element: ev.detail.element },
bubbles: true,
}),
);
}
},
);
export const FormRegistrarPortalMixin = dedupeMixin(FormRegistrarPortalMixinImplementation);

@@ -12,5 +12,10 @@ // TODO: still needed? It can be solved with while loop as well

this.__running = false;
/** @type {function[]} */
this.__queue = [];
}
/**
*
* @param {function} task
*/
add(task) {

@@ -21,2 +26,3 @@ this.__queue.push(task);

this.complete = new Promise(resolve => {
/** @type {function} */
this.__callComplete = resolve;

@@ -36,5 +42,7 @@ });

this.__running = false;
this.__callComplete();
if (this.__callComplete) {
this.__callComplete();
}
}
}
}
// 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();
['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => {
// eslint-disable-next-line no-param-reassign
instance[funcName] = (...args) => delegate[funcName](...args);
});
/**
* @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 */
}

@@ -7,7 +7,14 @@ import { browserDetection } from '@lion/core';

* https://developers.google.com/web/fundamentals/accessibility/focus/dom-order-matters
* @param {array} descriptionElements - holds references to description or label elements whose
* @param {HTMLElement[]} descriptionElements - holds references to description or label elements whose
* id should be returned
* @returns {array} sorted set of elements based on dom order
* @param {Object} opts
* @param {boolean} [opts.reverse]
* @returns {HTMLElement[]} sorted set of elements based on dom order
*/
export function getAriaElementsInRightDomOrder(descriptionElements, { reverse } = {}) {
/**
* @param {HTMLElement} a
* @param {HTMLElement} b
* @return {-1|1}
*/
const putPrecedingSiblingsAndLocalParentsFirst = (a, b) => {

@@ -14,0 +21,0 @@ // https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition

@@ -1,4 +0,9 @@

// TODO: pascalCase this filename?
/**
* Return PascalCased version of the camelCased string
*
* @param {string} str
* @return {string}
*/
export function pascalCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}

@@ -6,2 +6,7 @@ import { dedupeMixin } from '@lion/core';

/**
* @typedef {import('../../types/utils/SyncUpdatableMixinTypes').SyncUpdatableMixin} SyncUpdatableMixin
* @typedef {import('../../types/utils/SyncUpdatableMixinTypes').SyncUpdatableNamespace} SyncUpdatableNamespace
*/
/**
* @desc Why this mixin?

@@ -19,77 +24,88 @@ * - it adheres to the "Member Order Independence" web components standard:

* `requestUpdate`) we only have to change our abstraction instead of all our components
* @type {SyncUpdatableMixin}
*/
export const SyncUpdatableMixin = dedupeMixin(
superclass =>
class SyncUpdatable extends superclass {
constructor() {
super();
// Namespace for this mixin that guarantees naming clashes will not occur...
this.__SyncUpdatableNamespace = {};
}
const SyncUpdatableMixinImplementation = superclass =>
class SyncUpdatable extends superclass {
constructor() {
super();
// Namespace for this mixin that guarantees naming clashes will not occur...
/**
* @type {SyncUpdatableNamespace}
*/
this.__SyncUpdatableNamespace = {};
}
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__SyncUpdatableNamespace.connected = true;
this.__syncUpdatableInitialize();
}
/** @param {import('lit-element').PropertyValues } changedProperties */
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__SyncUpdatableNamespace.connected = true;
this.__syncUpdatableInitialize();
}
disconnectedCallback() {
super.disconnectedCallback();
this.__SyncUpdatableNamespace.connected = false;
}
disconnectedCallback() {
super.disconnectedCallback();
this.__SyncUpdatableNamespace.connected = false;
}
/**
* Makes the propertyAccessor.`hasChanged` compatible in synchronous updates
* @param {string} name
* @param {*} oldValue
*/
static __syncUpdatableHasChanged(name, newValue, oldValue) {
const properties = this._classProperties;
if (properties.get(name) && properties.get(name).hasChanged) {
return properties.get(name).hasChanged(newValue, oldValue);
}
return newValue !== oldValue;
/**
* Makes the propertyAccessor.`hasChanged` compatible in synchronous updates
* @param {string} name
* @param {*} newValue
* @param {*} oldValue
*/
static __syncUpdatableHasChanged(name, newValue, oldValue) {
const properties = this._classProperties;
if (properties.get(name) && properties.get(name).hasChanged) {
return properties.get(name).hasChanged(newValue, oldValue);
}
return newValue !== oldValue;
}
__syncUpdatableInitialize() {
const ns = this.__SyncUpdatableNamespace;
const ctor = this.constructor;
__syncUpdatableInitialize() {
const ns = this.__SyncUpdatableNamespace;
const ctor = /** @type {SyncUpdatableMixin & SyncUpdatable} */ (this.constructor);
ns.initialized = true;
// Empty queue...
if (ns.queue) {
Array.from(ns.queue).forEach(name => {
if (ctor.__syncUpdatableHasChanged(name, this[name], undefined)) {
this.updateSync(name, undefined);
}
});
}
ns.initialized = true;
// Empty queue...
if (ns.queue) {
Array.from(ns.queue).forEach(name => {
if (ctor.__syncUpdatableHasChanged(name, this[name], undefined)) {
this.updateSync(name, undefined);
}
});
}
}
_requestUpdate(name, oldValue) {
super._requestUpdate(name, oldValue);
/**
* @param {string} name
* @param {*} oldValue
*/
_requestUpdate(name, oldValue) {
super._requestUpdate(name, oldValue);
this.__SyncUpdatableNamespace = this.__SyncUpdatableNamespace || {};
const ns = this.__SyncUpdatableNamespace;
const ctor = this.constructor;
this.__SyncUpdatableNamespace = this.__SyncUpdatableNamespace || {};
const ns = this.__SyncUpdatableNamespace;
// Before connectedCallback: queue
if (!ns.connected) {
ns.queue = ns.queue || new Set();
// Makes sure that we only initialize one time, with most up to date value
ns.queue.add(name);
} // After connectedCallback: guarded proxy to updateSync
else if (ctor.__syncUpdatableHasChanged(name, this[name], oldValue)) {
this.updateSync(name, oldValue);
}
const ctor = /** @type {SyncUpdatableMixin & SyncUpdatable} */ (this.constructor);
// Before connectedCallback: queue
if (!ns.connected) {
ns.queue = ns.queue || new Set();
// Makes sure that we only initialize one time, with most up to date value
ns.queue.add(name);
} // After connectedCallback: guarded proxy to updateSync
else if (ctor.__syncUpdatableHasChanged(name, this[name], oldValue)) {
this.updateSync(name, oldValue);
}
}
/**
* @desc A public abstraction that has the exact same api as `_requestUpdate`.
* All code previously present in _requestUpdate can be placed in this method.
* @param {string} name
* @param {*} oldValue
*/
updateSync(name, oldValue) {} // eslint-disable-line class-methods-use-this, no-unused-vars
},
);
/**
* @desc A public abstraction that has the exact same api as `_requestUpdate`.
* All code previously present in _requestUpdate can be placed in this method.
* @param {string} name
* @param {*} oldValue
*/
updateSync(name, oldValue) {} // eslint-disable-line class-methods-use-this, no-unused-vars
};
export const SyncUpdatableMixin = dedupeMixin(SyncUpdatableMixinImplementation);

@@ -17,2 +17,3 @@ /**

export class Unparseable {
/** @param {string} value */
constructor(value) {

@@ -19,0 +20,0 @@ this.type = 'unparseable';

@@ -6,24 +6,19 @@ import { LitElement } from '@lion/core';

describe('FocusMixin', () => {
let tag;
class Focusable extends FocusMixin(LitElement) {
render() {
return html`<slot name="input"></slot>`;
}
before(async () => {
const tagString = defineCE(
class extends FocusMixin(LitElement) {
render() {
return html`<slot name="input"></slot>`;
}
get _inputNode() {
return this.querySelector('input');
}
}
get _inputNode() {
return this.querySelector('input');
}
},
);
const tagString = defineCE(Focusable);
const tag = unsafeStatic(tagString);
tag = unsafeStatic(tagString);
});
it('focuses/blurs the underlaying native element on .focus()/.blur()', async () => {
const el = await fixture(html`
const el = /** @type {Focusable} */ (await fixture(html`
<${tag}><input slot="input"></${tag}>
`);
`));
el.focus();

@@ -36,5 +31,5 @@ expect(document.activeElement === el._inputNode).to.be.true;

it('has an attribute focused when focused', async () => {
const el = await fixture(html`
const el = /** @type {Focusable} */ (await fixture(html`
<${tag}><input slot="input"></${tag}>
`);
`));
el.focus();

@@ -50,9 +45,9 @@ await el.updateComplete;

it('becomes focused/blurred if the native element gets focused/blurred', async () => {
const el = await fixture(html`
const el = /** @type {Focusable} */ (await fixture(html`
<${tag}><input slot="input"></${tag}>
`);
`));
expect(el.focused).to.be.false;
el._inputNode.focus();
el._inputNode?.focus();
expect(el.focused).to.be.true;
el._inputNode.blur();
el._inputNode?.blur();
expect(el.focused).to.be.false;

@@ -62,5 +57,5 @@ });

it('dispatches [focus, blur] events', async () => {
const el = await fixture(html`
const el = /** @type {Focusable} */ (await fixture(html`
<${tag}><input slot="input"></${tag}>
`);
`));
setTimeout(() => el.focus());

@@ -85,5 +80,5 @@ const focusEv = await oneEvent(el, 'focus');

it('dispatches [focusin, focusout] events with { bubbles: true, composed: true }', async () => {
const el = await fixture(html`
const el = /** @type {Focusable} */ (await fixture(html`
<${tag}><input slot="input"></${tag}>
`);
`));
setTimeout(() => el.focus());

@@ -90,0 +85,0 @@ const focusinEv = await oneEvent(el, 'focusin');

@@ -9,38 +9,34 @@ import { expect, html, defineCE, unsafeStatic, fixture } from '@open-wc/testing';

const inputSlot = '<input slot="input" />';
let elem;
let tag;
class FormControlMixinClass extends FormControlMixin(SlotMixin(LitElement)) {
static get properties() {
return {
modelValue: {
type: String,
},
};
}
}
before(async () => {
const FormControlMixinClass = class extends FormControlMixin(SlotMixin(LitElement)) {
static get properties() {
return {
modelValue: {
type: String,
},
};
}
};
const tagString = defineCE(FormControlMixinClass);
const tag = unsafeStatic(tagString);
elem = defineCE(FormControlMixinClass);
tag = unsafeStatic(elem);
});
it('has a label', async () => {
const elAttr = await fixture(html`
const elAttr = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag} label="Email address">${inputSlot}</${tag}>
`);
`));
expect(elAttr.label).to.equal('Email address', 'as an attribute');
const elProp = await fixture(html`
const elProp = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag}
.label=${'Email address'}
>${inputSlot}
</${tag}>`);
</${tag}>`));
expect(elProp.label).to.equal('Email address', 'as a property');
const elElem = await fixture(html`
const elElem = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag}>
<label slot="label">Email address</label>
${inputSlot}
</${tag}>`);
</${tag}>`));
expect(elElem.label).to.equal('Email address', 'as an element');

@@ -59,7 +55,7 @@ });

it('has a label that supports inner html', async () => {
const el = await fixture(html`
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag}>
<label slot="label">Email <span>address</span></label>
${inputSlot}
</${tag}>`);
</${tag}>`));
expect(el.label).to.equal('Email address');

@@ -69,3 +65,3 @@ });

it('only takes label of direct child', async () => {
const el = await fixture(html`
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag}>

@@ -75,3 +71,3 @@ <${tag} label="Email address">

</${tag}>
</${tag}>`);
</${tag}>`));
expect(el.label).to.equal('');

@@ -81,19 +77,19 @@ });

it('can have a help-text', async () => {
const elAttr = await fixture(html`
const elAttr = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag} help-text="We will not send you any spam">${inputSlot}</${tag}>
`);
`));
expect(elAttr.helpText).to.equal('We will not send you any spam', 'as an attribute');
const elProp = await fixture(html`
const elProp = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag}
.helpText=${'We will not send you any spam'}
>${inputSlot}
</${tag}>`);
</${tag}>`));
expect(elProp.helpText).to.equal('We will not send you any spam', 'as a property');
const elElem = await fixture(html`
const elElem = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag}>
<div slot="help-text">We will not send you any spam</div>
${inputSlot}
</${tag}>`);
</${tag}>`));
expect(elElem.helpText).to.equal('We will not send you any spam', 'as an element');

@@ -103,7 +99,7 @@ });

it('can have a help-text that supports inner html', async () => {
const el = await fixture(html`
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag}>
<div slot="help-text">We will not send you any <span>spam</span></div>
${inputSlot}
</${tag}>`);
</${tag}>`));
expect(el.helpText).to.equal('We will not send you any spam');

@@ -113,3 +109,3 @@ });

it('only takes help-text of direct child', async () => {
const el = await fixture(html`
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
<${tag}>

@@ -119,3 +115,3 @@ <${tag} help-text="We will not send you any spam">

</${tag}>
</${tag}>`);
</${tag}>`));
expect(el.helpText).to.equal('');

@@ -125,8 +121,8 @@ });

it('does not duplicate aria-describedby and aria-labelledby ids', async () => {
const lionField = await fixture(`
<${elem} help-text="This element will be disconnected/reconnected">${inputSlot}</${elem}>
`);
const lionField = /** @type {FormControlMixinClass} */ (await fixture(`
<${tagString} help-text="This element will be disconnected/reconnected">${inputSlot}</${tagString}>
`));
const wrapper = await fixture(`<div></div>`);
lionField.parentElement.appendChild(wrapper);
const wrapper = /** @type {LitElement} */ (await fixture(`<div></div>`));
lionField.parentElement?.appendChild(wrapper);
wrapper.appendChild(lionField);

@@ -138,6 +134,6 @@ await wrapper.updateComplete;

.find(child => child.slot === 'input')
.getAttribute(ariaAttributeName)
.trim()
?.getAttribute(ariaAttributeName)
?.trim()
.split(' ');
const hasDuplicate = !!ariaAttribute.find((el, i) => ariaAttribute.indexOf(el) !== i);
const hasDuplicate = !!ariaAttribute?.find((el, i) => ariaAttribute.indexOf(el) !== i);
expect(hasDuplicate).to.be.false;

@@ -147,3 +143,4 @@ });

it('internally sorts aria-describedby and aria-labelledby ids', async () => {
// FIXME: Broken test
it.skip('internally sorts aria-describedby and aria-labelledby ids', async () => {
const wrapper = await fixture(html`

@@ -161,4 +158,3 @@ <div id="wrapper">

</div>`);
const el = wrapper.querySelector(elem);
const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString));
const { _inputNode } = el;

@@ -168,22 +164,42 @@

// external inputs should go in order defined by user
el.addToAriaLabelledBy(wrapper.querySelector('#additionalLabelB'));
el.addToAriaLabelledBy(wrapper.querySelector('#additionalLabelA'));
const labelA = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalLabelA'));
const labelB = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalLabelB'));
el.addToAriaLabelledBy(labelA);
el.addToAriaLabelledBy(labelB);
expect(
_inputNode.getAttribute('aria-labelledby').indexOf(`label-${el._inputId}`) <
_inputNode.getAttribute('aria-labelledby').indexOf('additionalLabelB') <
_inputNode.getAttribute('aria-labelledby').indexOf('additionalLabelA'),
);
const ariaLabelId = /** @type {number} */ (_inputNode
.getAttribute('aria-labelledby')
?.indexOf(`label-${el._inputId}`));
const ariaLabelA = /** @type {number} */ (_inputNode
.getAttribute('aria-labelledby')
?.indexOf('additionalLabelA'));
const ariaLabelB = /** @type {number} */ (_inputNode
.getAttribute('aria-labelledby')
?.indexOf('additionalLabelB'));
expect(ariaLabelId < ariaLabelB && ariaLabelB < ariaLabelA).to.be.true;
// 2. addToAriaDescribedBy()
// Check if the aria attr is filled initially
el.addToAriaDescribedBy(wrapper.querySelector('#additionalDescriptionB'));
el.addToAriaDescribedBy(wrapper.querySelector('#additionalDescriptionA'));
const descA = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalDescriptionA'));
const descB = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalDescriptionB'));
el.addToAriaDescribedBy(descB);
el.addToAriaDescribedBy(descA);
const ariaDescId = /** @type {number} */ (_inputNode
.getAttribute('aria-describedby')
?.indexOf(`feedback-${el._inputId}`));
const ariaDescA = /** @type {number} */ (_inputNode
.getAttribute('aria-describedby')
?.indexOf('additionalDescriptionA'));
const ariaDescB = /** @type {number} */ (_inputNode
.getAttribute('aria-describedby')
?.indexOf('additionalDescriptionB'));
// Should be placed in the end
expect(
_inputNode.getAttribute('aria-describedby').indexOf(`feedback-${el._inputId}`) <
_inputNode.getAttribute('aria-describedby').indexOf('additionalDescriptionB') <
_inputNode.getAttribute('aria-describedby').indexOf('additionalDescriptionA'),
);
expect(ariaDescId < ariaDescB && ariaDescB < ariaDescA).to.be.true;
});

@@ -202,3 +218,3 @@

.find(child => child.slot === 'feedback')
.getAttribute('aria-live'),
?.getAttribute('aria-live'),
).to.equal('polite');

@@ -227,3 +243,3 @@ });

const fieldsetSpy = sinon.spy();
const formEl = await fixture(html`
const formEl = /** @type {FormControlMixinClass} */ (await fixture(html`
<${groupTag} name="form" ._repropagationRole=${'form-group'} @model-value-changed=${formSpy}>

@@ -234,6 +250,4 @@ <${groupTag} name="fieldset" ._repropagationRole=${'form-group'} @model-value-changed=${fieldsetSpy}>

</${groupTag}>
`);
`));
const fieldsetEl = formEl.querySelector('[name=fieldset]');
await formEl.registrationComplete;
await fieldsetEl.registrationComplete;

@@ -268,6 +282,6 @@ expect(fieldsetSpy.callCount).to.equal(1);

formEl.addEventListener('model-value-changed', formSpy);
fieldsetEl.addEventListener('model-value-changed', fieldsetSpy);
fieldEl.addEventListener('model-value-changed', fieldSpy);
fieldsetEl?.addEventListener('model-value-changed', fieldsetSpy);
fieldEl?.addEventListener('model-value-changed', fieldSpy);
fieldEl.dispatchEvent(new Event('model-value-changed', { bubbles: true }));
fieldEl?.dispatchEvent(new Event('model-value-changed', { bubbles: true }));

@@ -297,6 +311,11 @@ expect(fieldsetSpy.callCount).to.equal(1);

const choiceGroupEl = formEl.querySelector('[name=choice-group]');
const option1El = formEl.querySelector('#option1');
const option2El = formEl.querySelector('#option2');
/** @typedef {{ checked: boolean }} checkedInterface */
const option1El = /** @type {HTMLElement & checkedInterface} */ (formEl.querySelector(
'#option1',
));
const option2El = /** @type {HTMLElement & checkedInterface} */ (formEl.querySelector(
'#option2',
));
formEl.addEventListener('model-value-changed', formSpy);
choiceGroupEl.addEventListener('model-value-changed', choiceGroupSpy);
choiceGroupEl?.addEventListener('model-value-changed', choiceGroupSpy);

@@ -303,0 +322,0 @@ // Simulate check

@@ -17,2 +17,6 @@ import { unsafeHTML } from '@lion/core';

/**
* @typedef {import('../src/LionField.js').LionField} LionField
*/
const tagString = 'lion-field';

@@ -34,3 +38,3 @@ const tag = unsafeStatic(tagString);

it(`puts a unique id "${tagString}-[hash]" on the native input`, async () => {
const el = 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);

@@ -40,6 +44,10 @@ });

it(`has a fieldName based on the label`, async () => {
const el1 = await fixture(html`<${tag} label="foo">${inputSlot}</${tag}>`);
const el1 = /** @type {LionField} */ (await fixture(
html`<${tag} label="foo">${inputSlot}</${tag}>`,
));
expect(el1.fieldName).to.equal(el1._labelNode.textContent);
const el2 = await fixture(html`<${tag}><label slot="label">bar</label>${inputSlot}</${tag}>`);
const el2 = /** @type {LionField} */ (await fixture(
html`<${tag}><label slot="label">bar</label>${inputSlot}</${tag}>`,
));
expect(el2.fieldName).to.equal(el2._labelNode.textContent);

@@ -49,3 +57,5 @@ });

it(`has a fieldName based on the name if no label exists`, async () => {
const el = await fixture(html`<${tag} name="foo">${inputSlot}</${tag}>`);
const el = /** @type {LionField} */ (await fixture(
html`<${tag} name="foo">${inputSlot}</${tag}>`,
));
expect(el.fieldName).to.equal(el.name);

@@ -55,3 +65,5 @@ });

it(`can override fieldName`, async () => {
const el = await fixture(html`<${tag} label="foo" .fieldName="${'bar'}">${inputSlot}</${tag}>`);
const el = /** @type {LionField} */ (await fixture(
html`<${tag} label="foo" .fieldName="${'bar'}">${inputSlot}</${tag}>`,
));
expect(el.__fieldName).to.equal(el.fieldName);

@@ -61,3 +73,3 @@ });

it('fires focus/blur event on host and native input if focused/blurred', async () => {
const el = await fixture(html`<${tag}>${inputSlot}</${tag}>`);
const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}</${tag}>`));
const cbFocusHost = sinon.spy();

@@ -95,3 +107,3 @@ el.addEventListener('focus', cbFocusHost);

it('offers simple getter "this.focused" returning true/false for the current focus state', async () => {
const el = await fixture(html`<${tag}>${inputSlot}</${tag}>`);
const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}</${tag}>`));
expect(el.focused).to.equal(false);

@@ -105,3 +117,5 @@ await triggerFocusFor(el);

it('can be disabled via attribute', async () => {
const elDisabled = await fixture(html`<${tag} disabled>${inputSlot}</${tag}>`);
const elDisabled = /** @type {LionField} */ (await fixture(
html`<${tag} disabled>${inputSlot}</${tag}>`,
));
expect(elDisabled.disabled).to.equal(true);

@@ -112,3 +126,3 @@ expect(elDisabled._inputNode.disabled).to.equal(true);

it('can be disabled via property', async () => {
const el = await fixture(html`<${tag}>${inputSlot}</${tag}>`);
const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}</${tag}>`));
el.disabled = true;

@@ -120,3 +134,5 @@ await el.updateComplete;

it('can be cleared which erases value, validation and interaction states', async () => {
const el = await fixture(html`<${tag} value="Some value from attribute">${inputSlot}</${tag}>`);
const el = /** @type {LionField} */ (await fixture(
html`<${tag} value="Some value from attribute">${inputSlot}</${tag}>`,
));
el.clear();

@@ -131,6 +147,6 @@ expect(el.modelValue).to.equal('');

it('can be reset which restores original modelValue', async () => {
const el = await fixture(html`
const el = /** @type {LionField} */ (await fixture(html`
<${tag} .modelValue="${'foo'}">
${inputSlot}
</${tag}>`);
</${tag}>`));
expect(el._initialModelValue).to.equal('foo');

@@ -143,3 +159,5 @@ el.modelValue = 'bar';

it('reads initial value from attribute value', async () => {
const el = await fixture(html`<${tag} value="one">${inputSlot}</${tag}>`);
const el = /** @type {LionField} */ (await fixture(
html`<${tag} value="one">${inputSlot}</${tag}>`,
));
expect(Array.from(el.children).find(child => child.slot === 'input').value).to.equal('one');

@@ -149,3 +167,3 @@ });

it('delegates value property', async () => {
const el = 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('');

@@ -159,3 +177,3 @@ el.value = 'one';

it('delegates autocomplete property', async () => {
const el = await fixture(html`<${tag}>${inputSlot}</${tag}>`);
const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}</${tag}>`));
expect(el._inputNode.autocomplete).to.equal('');

@@ -170,3 +188,3 @@ expect(el._inputNode.hasAttribute('autocomplete')).to.be.false;

it('preserves the caret position on value change for native text fields (input|textarea)', async () => {
const el = await fixture(html`<${tag}>${inputSlot}</${tag}>`);
const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}</${tag}>`));
await triggerFocusFor(el);

@@ -184,3 +202,3 @@ await el.updateComplete;

it('is disabled when disabled property is passed', async () => {
const el = await fixture(html`<${tag}>${inputSlot}</${tag}>`);
const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}</${tag}>`));
expect(el._inputNode.hasAttribute('disabled')).to.equal(false);

@@ -193,3 +211,5 @@

expect(el._inputNode.hasAttribute('disabled')).to.equal(true);
const disabledel = await fixture(html`<${tag} disabled>${inputSlot}</${tag}>`);
const disabledel = /** @type {LionField} */ (await fixture(
html`<${tag} disabled>${inputSlot}</${tag}>`,
));
expect(disabledel._inputNode.hasAttribute('disabled')).to.equal(true);

@@ -213,3 +233,3 @@ });

~~~`, async () => {
const el = await fixture(html`<${tag}>
const el = /** @type {LionField} */ (await fixture(html`<${tag}>
<label slot="label">My Name</label>

@@ -220,3 +240,3 @@ ${inputSlot}

</${tag}>
`);
`));
const nativeInput = Array.from(el.children).find(child => child.slot === 'input');

@@ -231,3 +251,3 @@

(via attribute data-label) and in describedby (via attribute data-description)`, async () => {
const el = await fixture(html`<${tag}>
const el = /** @type {LionField} */ (await fixture(html`<${tag}>
${inputSlot}

@@ -239,3 +259,3 @@ <span slot="before" data-label>[before]</span>

</${tag}>
`);
`));

@@ -254,3 +274,3 @@ const nativeInput = Array.from(el.children).find(child => child.slot === 'input');

addToAriaDescribedBy()`, async () => {
const wrapper = await fixture(html`
const wrapper = /** @type {LionField} */ (await fixture(html`
<div id="wrapper">

@@ -264,3 +284,3 @@ <${tag}>

<div id="additionalDescription"> Same for this </div>
</div>`);
</div>`));
const el = wrapper.querySelector(tagString);

@@ -321,3 +341,3 @@ // wait until the field element is done rendering

};
const el = await fixture(html`
const el = /** @type {LionField} */ (await fixture(html`
<${tag}

@@ -329,3 +349,3 @@ .validators=${[new HasX()]}

</${tag}>
`);
`));

@@ -385,3 +405,3 @@ const executeScenario = async (_sceneEl, scenario) => {

};
const disabledEl = await fixture(html`
const disabledEl = /** @type {LionField} */ (await fixture(html`
<${tag}

@@ -394,4 +414,4 @@ disabled

</${tag}>
`);
const el = await fixture(html`
`));
const el = /** @type {LionField} */ (await fixture(html`
<${tag}

@@ -403,3 +423,3 @@ .validators=${[new HasX()]}

</${tag}>
`);
`));

@@ -424,3 +444,3 @@ expect(el.hasFeedbackFor).to.deep.equal(['error']);

};
const el = await fixture(html`
const el = /** @type {LionField} */ (await fixture(html`
<${tag}

@@ -432,3 +452,3 @@ .validators=${[new HasX()]}

</${tag}>
`);
`));
expect(el.hasFeedbackFor).to.deep.equal(['error']);

@@ -444,7 +464,7 @@ expect(el.validationStates.error).to.have.a.property('HasX');

it('can be required', async () => {
const el = await fixture(html`
const el = /** @type {LionField} */ (await fixture(html`
<${tag}
.validators=${[new Required()]}
>${inputSlot}</${tag}>
`);
`));
expect(el.hasFeedbackFor).to.deep.equal(['error']);

@@ -469,3 +489,3 @@ expect(el.validationStates.error).to.have.a.property('Required');

};
const el = await fixture(html`
const el = /** @type {LionField} */ (await fixture(html`
<${tag}

@@ -476,3 +496,3 @@ .modelValue=${'init-string'}

>${inputSlot}</${tag}>
`);
`));

@@ -494,3 +514,3 @@ expect(formatterSpy.callCount).to.equal(0);

it('renders correctly all slot elements in light DOM', async () => {
const el = await fixture(html`
const el = /** @type {LionField} */ (await fixture(html`
<${tag}>

@@ -506,3 +526,3 @@ <label slot="label">[label]</label>

</${tag}>
`);
`));

@@ -531,3 +551,3 @@ const names = [

it('delegates property value', async () => {
const el = await fixture(html`<${tag}>${inputSlot}</${tag}>`);
const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}</${tag}>`));
expect(el._inputNode.value).to.equal('');

@@ -540,7 +560,7 @@ el.value = 'one';

it('delegates property selectionStart and selectionEnd', async () => {
const el = await fixture(html`
const el = /** @type {LionField} */ (await fixture(html`
<${tag}
.modelValue=${'Some text to select'}
>${unsafeHTML(inputSlotString)}</${tag}>
`);
`));

@@ -547,0 +567,0 @@ el.selectionStart = 5;

@@ -22,3 +22,3 @@ import { expect, fixture, html } from '@open-wc/testing';

// eslint-disable-next-line no-unused-vars
const [a, _1, b, _2, bChild, _3, c, _4] = el.querySelectorAll('div');
const [a, _1, b, _2, bChild, _3, c, _4] = Array.from(el.querySelectorAll('div'));
const unorderedNodes = [bChild, c, a, b];

@@ -44,3 +44,3 @@ const result = getAriaElementsInRightDomOrder(unorderedNodes);

// eslint-disable-next-line no-unused-vars
const [a, _1, b, _2, bChild, _3, c, _4] = el.querySelectorAll('div');
const [a, _1, b, _2, bChild, _3, c, _4] = Array.from(el.querySelectorAll('div'));
const unorderedNodes = [bChild, c, a, b];

@@ -67,3 +67,3 @@ const result = getAriaElementsInRightDomOrder(unorderedNodes, { reverse: true });

// eslint-disable-next-line no-unused-vars
const [a, _1, b, _2, bChild, _3, c, _4] = el.querySelectorAll('div');
const [a, _1, b, _2, bChild, _3, c, _4] = Array.from(el.querySelectorAll('div'));
const unorderedNodes = [bChild, c, a, b];

@@ -70,0 +70,0 @@ const result = getAriaElementsInRightDomOrder(unorderedNodes);

import { expect, fixtureSync, defineCE, unsafeStatic, html, fixture } from '@open-wc/testing';
import sinon from 'sinon';
import { UpdatingElement } from '@lion/core';
import { LitElement } from '@lion/core';
import { SyncUpdatableMixin } from '../../src/utils/SyncUpdatableMixin.js';

@@ -11,34 +11,40 @@

let hasCalledUpdateSync = false;
class UpdatableImplementation extends SyncUpdatableMixin(LitElement) {
static get properties() {
return {
propA: { type: String },
propB: {
type: String,
attribute: 'prop-b',
},
};
}
const tagString = defineCE(
class extends SyncUpdatableMixin(UpdatingElement) {
static get properties() {
return {
propA: { type: String },
propB: {
type: String,
attribute: 'prop-b',
},
};
}
constructor() {
super();
this.propA = 'init-a';
this.propB = 'init-b';
}
constructor() {
super();
this.propA = 'init-a';
this.propB = 'init-b';
}
/** @param {import('lit-element').PropertyValues } changedProperties */
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
hasCalledFirstUpdated = true;
}
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
hasCalledFirstUpdated = true;
}
/**
* @param {string} name
* @param {*} oldValue
*/
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
hasCalledUpdateSync = true;
}
}
updateSync(...args) {
super.updateSync(...args);
hasCalledUpdateSync = true;
}
},
);
const tagString = defineCE(UpdatableImplementation);
const tag = unsafeStatic(tagString);
const el = fixtureSync(html`<${tag} prop-b="b"></${tag}>`);
const el = /** @type {UpdatableImplementation} */ (fixtureSync(
html`<${tag} prop-b="b"></${tag}>`,
));

@@ -62,37 +68,43 @@ // Getters setters work as expected, without running property effects

const tagString = defineCE(
class extends SyncUpdatableMixin(UpdatingElement) {
static get properties() {
return {
propA: { type: String },
propB: {
type: String,
attribute: 'prop-b',
},
derived: { type: String },
};
}
class UpdatableImplementation extends SyncUpdatableMixin(LitElement) {
static get properties() {
return {
propA: { type: String },
propB: {
type: String,
attribute: 'prop-b',
},
derived: { type: String },
};
}
constructor() {
super();
this.propA = 'init-a';
this.propB = 'init-b';
}
constructor() {
super();
this.propA = 'init-a';
this.propB = 'init-b';
}
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
/**
* @param {string} name
* @param {*} oldValue
*/
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
if (name === 'propB') {
this._runPropertyEffect();
}
if (name === 'propB') {
this._runPropertyEffect();
}
}
_runPropertyEffect() {
hasCalledRunPropertyEffect = true;
this.derived = this.propA + this.propB;
}
},
);
_runPropertyEffect() {
hasCalledRunPropertyEffect = true;
this.derived = this.propA + this.propB;
}
}
const tagString = defineCE(UpdatableImplementation);
const tag = unsafeStatic(tagString);
const el = fixtureSync(html`<${tag} prop-b="b" .propA="${'a'}"></${tag}>`);
const el = /** @type {UpdatableImplementation} */ (fixtureSync(
html`<${tag} prop-b="b" .propA="${'a'}"></${tag}>`,
));

@@ -107,9 +119,15 @@ // Derived

const el2 = await fixture(html`<${tag} .propA="${'a'}"></${tag}>`);
const el2 = /** @type {UpdatableImplementation} */ (await fixture(
html`<${tag} .propA="${'a'}"></${tag}>`,
));
expect(el2.derived).to.equal('ainit-b');
const el3 = await fixture(html`<${tag} .propB="${'b'}"></${tag}>`);
const el3 = /** @type {UpdatableImplementation} */ (await fixture(
html`<${tag} .propB="${'b'}"></${tag}>`,
));
expect(el3.derived).to.equal('init-ab');
const el4 = await fixture(html`<${tag} .propA=${'a'} .propB="${'b'}"></${tag}>`);
const el4 = /** @type {UpdatableImplementation} */ (await fixture(
html`<${tag} .propA=${'a'} .propB="${'b'}"></${tag}>`,
));
expect(el4.derived).to.equal('ab');

@@ -122,32 +140,43 @@ });

const tagString = defineCE(
class extends SyncUpdatableMixin(UpdatingElement) {
static get properties() {
return {
prop: { type: String },
};
}
// @ts-ignore the private override is on purpose
class UpdatableImplementation extends SyncUpdatableMixin(LitElement) {
static get properties() {
return {
prop: { type: String },
};
}
constructor() {
super();
this.prop = 'a';
constructor() {
super();
this.prop = 'a';
}
/**
* @param {string} name
* @param {*} oldValue
*/
_requestUpdate(name, oldValue) {
// @ts-ignore the private override is on purpose
super._requestUpdate(name, oldValue);
if (name === 'prop') {
propChangedCount += 1;
}
}
_requestUpdate(name, oldValue) {
super._requestUpdate(name, oldValue);
if (name === 'prop') {
propChangedCount += 1;
}
/**
* @param {string} name
* @param {*} oldValue
*/
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
if (name === 'prop') {
propUpdateSyncCount += 1;
}
}
}
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
if (name === 'prop') {
propUpdateSyncCount += 1;
}
}
},
);
const tagString = defineCE(UpdatableImplementation);
const tag = unsafeStatic(tagString);
const el = fixtureSync(html`<${tag}></${tag}>`);
const el = /** @type {UpdatableImplementation} */ (fixtureSync(html`<${tag}></${tag}>`));
el.prop = 'a';

@@ -166,36 +195,42 @@ // Getters setters work as expected, without running property effects

it('calls "updateSync" immediately when the observed property is changed (newValue !== oldValue)', async () => {
const tagString = defineCE(
class extends SyncUpdatableMixin(UpdatingElement) {
static get properties() {
return {
propA: { type: String },
propB: {
type: String,
attribute: 'prop-b',
},
derived: { type: String },
};
}
class UpdatableImplementation extends SyncUpdatableMixin(LitElement) {
static get properties() {
return {
propA: { type: String },
propB: {
type: String,
attribute: 'prop-b',
},
derived: { type: String },
};
}
constructor() {
super();
this.propA = 'init-a';
this.propB = 'init-b';
}
constructor() {
super();
this.propA = 'init-a';
this.propB = 'init-b';
}
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
/**
* @param {string} name
* @param {*} oldValue
*/
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
if (name === 'propB') {
this._runPropertyEffect();
}
if (name === 'propB') {
this._runPropertyEffect();
}
}
_runPropertyEffect() {
this.derived = this.propA + this.propB;
}
},
);
_runPropertyEffect() {
this.derived = this.propA + this.propB;
}
}
const tagString = defineCE(UpdatableImplementation);
const tag = unsafeStatic(tagString);
const el = fixtureSync(html`<${tag} prop-b="b" .propA="${'a'}"></${tag}>`);
const el = /** @type {UpdatableImplementation} */ (fixtureSync(
html`<${tag} prop-b="b" .propA="${'a'}"></${tag}>`,
));
const spy = sinon.spy(el, '_runPropertyEffect');

@@ -216,46 +251,61 @@ expect(spy.callCount).to.equal(0);

it('supports "hasChanged" from UpdatingElement', async () => {
const tagString = defineCE(
class extends SyncUpdatableMixin(UpdatingElement) {
static get properties() {
return {
complexProp: {
type: Object,
hasChanged: (result, prevResult) => {
// Simple way of doing a deep comparison
if (JSON.stringify(result) !== JSON.stringify(prevResult)) {
return true;
}
return false;
},
class UpdatableImplementation extends SyncUpdatableMixin(LitElement) {
static get properties() {
return {
complexProp: {
type: Object,
/**
* @param {Object} result
* @param {Object} prevResult
*/
hasChanged: (result, prevResult) => {
// Simple way of doing a deep comparison
if (JSON.stringify(result) !== JSON.stringify(prevResult)) {
return true;
}
return false;
},
};
}
},
};
}
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
constructor() {
super();
this.complexProp = {};
}
if (name === 'complexProp') {
this._onComplexPropChanged();
}
/**
* @param {string} name
* @param {*} oldValue
*/
updateSync(name, oldValue) {
super.updateSync(name, oldValue);
if (name === 'complexProp') {
this._onComplexPropChanged();
}
}
_onComplexPropChanged() {
// do smth
}
},
);
_onComplexPropChanged() {
// do smth
}
}
const tagString = defineCE(UpdatableImplementation);
const tag = unsafeStatic(tagString);
const el = fixtureSync(html`<${tag}></${tag}>`);
const el = /** @type {UpdatableImplementation} */ (fixtureSync(html`<${tag}></${tag}>`));
const spy = sinon.spy(el, '_onComplexPropChanged');
await el.updateComplete;
expect(spy.callCount).to.equal(0);
// Constructor sets it first, so start at 1
expect(spy.callCount).to.equal(1);
el.complexProp = { key1: true };
expect(spy.callCount).to.equal(1);
el.complexProp = { key1: false };
expect(spy.callCount).to.equal(2);
el.complexProp = { key1: false };
expect(spy.callCount).to.equal(2);
expect(spy.callCount).to.equal(3);
el.complexProp = { key1: false };
expect(spy.callCount).to.equal(3);
});
});
});
SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap

Packages

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc