Merge pull request #1593 from ing-bank/feat/input-tel

feat(input-tel): new component LionInputTel
This commit is contained in:
Thijs Louisse 2022-03-16 16:57:09 +01:00 committed by GitHub
commit dde778ec9d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
123 changed files with 22217 additions and 342 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/input-tel-dropdown': minor
---
New component LionInpuTelDropdown

View file

@ -0,0 +1,5 @@
---
'@lion/validate-messages': patch
---
fix(validate-messages): typo IsData message

View file

@ -0,0 +1,5 @@
---
'@lion/form-core': patch
---
FormControl: allow a label-sr-only flag to provide visually hidden labels

View file

@ -0,0 +1,5 @@
---
'@lion/form-core': patch
---
form-core: expose 'mimicUserInput' test-helper

View file

@ -0,0 +1,5 @@
---
'@lion/form-core': minor
---
Validation: allow enums as outcome of a Validator

View file

@ -0,0 +1,5 @@
---
'@lion/input-tel': minor
---
New component "LionInputTel"

View file

@ -3,3 +3,4 @@ CHANGELOG.md
bundlesize/
_site
_site-dev
.history

View file

@ -0,0 +1,28 @@
# Inputs >> Input Tel Dropdown >> Examples ||30
```js script
import { html } from '@mdjs/mdjs-preview';
import '@lion/select-rich/define';
import './src/intl-input-tel-dropdown.js';
```
## Input Tel International
A visually advanced Subclasser implementation of `LionInputTelDropdown`.
Inspired by:
- [intl-tel-input](https://intl-tel-input.com/)
- [react-phone-input-2](https://github.com/bl00mber/react-phone-input-2)
```js preview-story
export const IntlInputTelDropdown = () => html`
<intl-input-tel-dropdown
.preferredRegions="${['NL', 'PH']}"
.modelValue=${'+639608920056'}
label="Telephone number"
help-text="Advanced dropdown and styling"
name="phoneNumber"
></intl-input-tel-dropdown>
`;
```

View file

@ -0,0 +1,72 @@
# Inputs >> Input Tel Dropdown >> Features ||20
```js script
import { html } from '@mdjs/mdjs-preview';
import { ref, createRef } from '@lion/core';
import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
import { PhoneUtilManager } from '@lion/input-tel';
import '@lion/input-tel-dropdown/define';
import '../../../docs/systems/form/assets/h-output.js';
```
## Input Tel Dropdown
When `.allowedRegions` is not configured, all regions/countries will be available in the dropdown
list. Once a region is chosen, its country/dial code will be adjusted with that of the new locale.
```js preview-story
export const InputTelDropdown = () => html`
<lion-input-tel-dropdown
label="Select region via dropdown"
help-text="Shows all regions by default"
name="phoneNumber"
></lion-input-tel-dropdown>
<h-output
.show="${['modelValue', 'activeRegion']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```
## Allowed regions
When `.allowedRegions` is configured, only those regions/countries will be available in the dropdown
list.
```js preview-story
export const allowedRegions = () => html`
<lion-input-tel-dropdown
label="Select region via dropdown"
help-text="With region code 'NL'"
.modelValue=${'+31612345678'}
name="phoneNumber"
.allowedRegions=${['NL', 'DE', 'GB']}
></lion-input-tel-dropdown>
<h-output
.show="${['modelValue', 'activeRegion']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```
## Preferred regions
When `.preferredRegions` is configured, they will show up on top of the dropdown list to
enhance user experience.
```js preview-story
export const preferredRegionCodes = () => html`
<lion-input-tel-dropdown
label="Select region via dropdown"
help-text="Preferred regions show on top"
.modelValue=${'+31612345678'}
name="phoneNumber"
.allowedRegions=${['NL', 'DE', 'GB', 'BE', 'US', 'CA']}
.preferredRegions=${['NL', 'DE']}
></lion-input-tel-dropdown>
<h-output
.show="${['modelValue', 'activeRegion']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```

View file

@ -0,0 +1,3 @@
# Inputs >> Input Tel Dropdown ||20
-> go to Overview

View file

@ -0,0 +1,36 @@
# Inputs >> Input Tel Dropdown >> Overview ||10
Extension of Input Tel that prefixes a dropdown list that shows all possible regions / countries.
```js script
import { html } from '@mdjs/mdjs-preview';
import '@lion/input-tel-dropdown/define';
```
```js preview-story
export const main = () => {
return html`
<lion-input-tel-dropdown label="Telephone number" name="phoneNumber"></lion-input-tel-dropdown>
`;
};
```
## Features
- Extends our [input-tel](../input-tel/overview.md)
- Shows dropdown list with all possible regions
- Shows only allowed regions in dropdown list when .allowedRegions is configured
- Highlights regions on top of dropdown list when .preferredRegions is configured
- Generates template meta data for advanced
## Installation
```bash
npm i --save @lion/input-tel-dropdown
```
```js
import { LionInputTelDropdown } from '@lion/input-tel-dropdown';
// or
import '@lion/input-tel-dropdown/define';
```

View file

@ -0,0 +1,124 @@
import { html, css, ScopedElementsMixin, ref, repeat } from '@lion/core';
import { LionInputTelDropdown } from '@lion/input-tel-dropdown';
import {
IntlSelectRich,
IntlOption,
IntlSeparator,
} from '../../select-rich/src/intl-select-rich.js';
/**
* @typedef {import('@lion/input-tel-dropdown/types').TemplateDataForDropdownInputTel}TemplateDataForDropdownInputTel
*/
// Example implementation for https://intl-tel-input.com/
export class IntlInputTelDropdown extends ScopedElementsMixin(LionInputTelDropdown) {
/**
* @configure LitElement
* @enhance LionInputTelDropdown
*/
static styles = [
super.styles,
css`
:host,
::slotted(*) {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.42857143;
color: #333;
}
:host {
max-width: 300px;
}
.input-group__container {
width: 100%;
height: 34px;
font-size: 14px;
line-height: 1.42857143;
color: #555;
background-color: #fff;
background-image: none;
border: 1px solid #ccc;
border-radius: 4px;
-webkit-box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%);
box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%);
-webkit-transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
-o-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
}
.input-group__input {
padding: 6px;
box-sizing: border-box;
}
.input-group__input ::slotted(input) {
border: none;
outline: none;
}
:host([focused]) .input-group__container {
border-color: #66afe9;
outline: 0;
-webkit-box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%);
box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%);
}
`,
];
static templates = {
...(super.templates || {}),
/**
* @param {TemplateDataForDropdownInputTel} templateDataForDropdown
*/
dropdown: templateDataForDropdown => {
const { refs, data } = templateDataForDropdown;
// TODO: once spread directive available, use it per ref (like ref(refs?.dropdown?.ref))
return html`
<intl-select-rich
${ref(refs?.dropdown?.ref)}
label="${refs?.dropdown?.labels?.country}"
label-sr-only
@model-value-changed="${refs?.dropdown?.listeners['model-value-changed']}"
style="${refs?.dropdown?.props?.style}"
>
${data?.regionMetaListPreferred?.length
? html` ${repeat(
data.regionMetaListPreferred,
regionMeta => regionMeta.regionCode,
regionMeta =>
html`${this.templates.dropdownOption(templateDataForDropdown, regionMeta)} `,
)}<intl-separator></intl-separator>`
: ''}
${repeat(
data.regionMetaList,
regionMeta => regionMeta.regionCode,
regionMeta =>
html`${this.templates.dropdownOption(templateDataForDropdown, regionMeta)} `,
)}
</intl-select-rich>
`;
},
/**
* @param {TemplateDataForDropdownInputTel} templateDataForDropdown
* @param {RegionMeta} regionMeta
*/
// eslint-disable-next-line class-methods-use-this
dropdownOption: (templateDataForDropdown, regionMeta) => html`
<intl-option .choiceValue="${regionMeta.regionCode}" .regionMeta="${regionMeta}">
</intl-option>
`,
};
/**
* @configure ScopedElementsMixin
*/
static scopedElements = {
...super.scopedElements,
'intl-select-rich': IntlSelectRich,
'intl-option': IntlOption,
'intl-separator': IntlSeparator,
};
}
customElements.define('intl-input-tel-dropdown', IntlInputTelDropdown);

View file

@ -0,0 +1,254 @@
# Inputs >> Input Tel >> Features ||20
```js script
import { html } from '@mdjs/mdjs-preview';
import { ref, createRef } from '@lion/core';
import { Unparseable } from '@lion/form-core';
import { localize } from '@lion/localize';
import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
import { PhoneUtilManager } from '@lion/input-tel';
import '@lion/input-tel/define';
import './src/h-region-code-table.js';
import '../../../docs/systems/form/assets/h-output.js';
```
## Regions: some context
Say we have the following telephone number from Madrid, Spain: `+34919930432`.
It contains a [country code](https://en.wikipedia.org/wiki/Country_code) (34), an [area code](https://en.wikipedia.org/wiki/Telephone_numbering_plan#Area_code) (91) and a [dial code](https://en.wikipedia.org/wiki/Mobile_dial_code) (+34 91).
Input Tel interprets phone numbers based on their [region code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2): a two character long region representation('ES' in the telephone number above).
The table below lists all possible regions worldwide. When [allowed regions](#allowed-regions) are not configured,
all of them will be supported as values of Input Tel.
```js story
export const regionCodesTable = () => html`<h-region-code-table></h-region-code-table>`;
```
### Active region
The active region (accessible via readonly accessor `.activeRegion`) determines how validation and formatting
should be applied. It is dependent on the following factors:
- [allowed regions](#allowed-regions): a list that determines what is allowed to become .activeRegion. If
[.allowedRegions has only one entry](#restrict-to-one-region), .activeRegion will always be this value.
- the modelValue or viewValue: once it contains sufficient info to derive its region code (and
the derived code is inside [allowed regions](#allowed-regions) if configured)
- active locale (and the derived locale is inside [allowed regions](#allowed-regions) if configured)
What follows from the list above is that .activeRegion can change dynamically, after a value
change in the text box by the user (or when locales or allowed regions would be changed by the
Application Developer).
### How active region is computed
The following heuristic will be applied:
1. check for **allowed regions**: if one region defined in .allowedRegions, use it.
2. check for **user input**: try to derive active region from user input
3. check for **locale**: try to get the region from locale (`html[lang]` attribute)
```js preview-story
export const heuristic = () => {
const initialAllowedRegions = ['CN', 'ES'];
const [inputTelRef, outputRef, selectRef] = [createRef(), createRef(), createRef()];
const setDerivedActiveRegionScenario = (
scenarioToSet,
inputTel = inputTelRef.value,
output = outputRef.value,
) => {
if (scenarioToSet === 'only-allowed-region') {
// activeRegion will be the top allowed region, which is 'NL'
inputTel.modelValue = undefined;
inputTel.allowedRegions = ['NL']; // activeRegion will always be the only option
output.innerText = '.activeRegion (NL) is only allowed region';
} else if (scenarioToSet === 'user-input') {
// activeRegion will be based on phone number => 'BE'
inputTel.allowedRegions = ['NL', 'BE', 'DE'];
inputTel.modelValue = '+3261234567'; // BE number
output.innerText = '.activeRegion (BE) is derived (since within allowedRegions)';
} else if (scenarioToSet === 'locale') {
localize.locale = 'en-GB';
// activeRegion will be `html[lang]`
inputTel.modelValue = undefined;
inputTel.allowedRegions = undefined;
output.innerText = `.activeRegion (${inputTel._langIso}) set to locale when inside allowed or all regions`;
} else {
output.innerText = '';
}
};
return html`
<select
aria-label="Set scenario"
@change="${({ target }) => setDerivedActiveRegionScenario(target.value)}"
>
<option value="">--- select scenario ---</option>
<option value="only-allowed-region">1. only allowed region</option>
<option value="user-input">2. user input</option>
<option value="locale">3. locale</option>
</select>
<output style="display:block; min-height: 1.5em;" id="myOutput" ${ref(outputRef)}></output>
<lion-input-tel
${ref(inputTelRef)}
@model-value-changed="${({ detail }) => {
if (detail.isTriggeredByUser && selectRef.value) {
selectRef.value.value = '';
}
}}"
name="phoneNumber"
label="Active region"
.allowedRegions="${initialAllowedRegions}"
></lion-input-tel>
<h-output
.show="${[
'activeRegion',
{
name: 'all or allowed regions',
processor: el => JSON.stringify(el._allowedOrAllRegions),
},
'modelValue',
]}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
};
```
## Allowed regions
`.allowedRegions` is an array of one or more region codes.
Once it is configured, validation and formatting will be restricted to those
values that are present in this list.
> Note that for [InputTelDropdown](../input-tel-dropdown/index.md), only allowed regions will
> be shown in the dropdown list.
```js preview-story
export const allowedRegions = () => html`
<lion-input-tel
label="Allowed regions 'NL', 'BE', 'DE'"
help-text="Type '+31'(NL), '+32'(BE) or '+49'(DE) and see how activeRegion changes"
.allowedRegions="${['NL', 'BE', 'DE']}"
.modelValue="${'+31612345678'}"
name="phoneNumber"
></lion-input-tel>
<h-output
.show="${['modelValue', 'activeRegion']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```
### Restrict to one region
When one allowed region is configured, validation and formatting will be restricted to just that
region (that means that changes of the region via viewValue won't have effect).
```js preview-story
export const oneAllowedRegion = () => html`
<lion-input-tel
label="Only allowed region 'DE'"
help-text="Restricts validation / formatting to one region"
.allowedRegions="${['DE']}"
.modelValue="${'+31612345678'}"
name="phoneNumber"
></lion-input-tel>
<h-output
.show="${['modelValue', 'activeRegion', 'validationStates']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```
## Format strategy
Determines what the formatter output should look like.
Formatting strategies as provided by awesome-phonenumber / google-libphonenumber.
Possible values:
| strategy | output |
| :------------ | ---------------------: |
| e164 | `+46707123456` |
| international | `+46 70 712 34 56` |
| national | `070-712 34 56` |
| significant | `707123456` |
| rfc3966 | `tel:+46-70-712-34-56` |
Also see:
- [awesome-phonenumber documentation](https://www.npmjs.com/package/awesome-phonenumber)
```js preview-story
export const formatStrategy = () => {
const inputTel = createRef();
return html`
<select @change="${({ target }) => (inputTel.value.formatStrategy = target.value)}">
<option value="e164">e164</option>
<option value="international">international</option>
<option value="national">national</option>
<option value="significant">significant</option>
<option value="rfc3966">rfc3966</option>
</select>
<lion-input-tel
${ref(inputTel)}
label="Format strategy"
help-text="Choose a strategy above"
.modelValue=${'+46707123456'}
format-strategy="national"
name="phoneNumber"
></lion-input-tel>
<h-output
.show="${['modelValue', 'formatStrategy']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
};
```
## Live format
Type '6' in the example below to see how the phone number is formatted during typing.
See [awesome-phonenumber documentation](https://www.npmjs.com/package/awesome-phonenumber)
```js preview-story
export const liveFormat = () => html`
<lion-input-tel
label="Realtime format on user input"
help-text="Partial numbers are also formatted"
.modelValue=${new Unparseable('+31')}
format-strategy="international"
live-format
name="phoneNumber"
></lion-input-tel>
`;
```
## Active phone number type
The readonly acessor `.activePhoneNumberType` outputs the current phone number type, based on
the textbox value.
Possible types: `fixed-line`, `fixed-line-or-mobile`, `mobile`, `pager`, `personal-number`, `premium-rate`, `shared-cost`, `toll-free`, `uan`, `voip`, `unknown`
Also see:
- [awesome-phonenumber documentation](https://www.npmjs.com/package/awesome-phonenumber)
```js preview-story
export const activePhoneNumberType = () => html`
<lion-input-tel
label="Active phone number type"
.modelValue="${'+31612345678'}"
format-strategy="international"
name="phoneNumber"
></lion-input-tel>
<h-output
.show="${['activePhoneNumberType']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```

View file

@ -0,0 +1,3 @@
# Inputs >> Input Tel ||20
-> go to Overview

View file

@ -0,0 +1,59 @@
# Inputs >> Input Tel >> Overview ||10
Input field for entering phone numbers, including validation, formatting and mobile keyboard support.
```js script
import { html } from '@mdjs/mdjs-preview';
import { ref, createRef } from '@lion/core';
import { PhoneUtilManager } from '@lion/input-tel';
import '@lion/input-tel/define';
import '../../../docs/systems/form/assets/h-output.js';
```
```js preview-story
export const main = () => {
return html`
<lion-input-tel
.modelValue="${'+639921343959'}"
live-format
label="Telephone number"
name="phoneNumber"
></lion-input-tel>
<h-output
.show="${[
'activeRegion',
{
name: 'all or allowed regions',
processor: el => JSON.stringify(el._allowedOrAllRegions),
},
'modelValue',
]}" 'modelValue']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
};
```
## Features
- Extends our [input](../input/overview.md)
- Shows a mobile telephone keypad on mobile (by having a native `<input inputmode="tel">` inside)
- Can be configured with a list of allowed region codes
- Will be preconfigured with region derived from locale
- Has the [e164 standard format](https://en.wikipedia.org/wiki/E.164) as modelValue
- Uses [awesome-phonenumber](https://www.npmjs.com/package/awesome-phonenumber) (a performant, concise version of [google-lib-phonenumber](https://www.npmjs.com/package/google-libphonenumber)):
- Formats phone numbers, based on region code
- Validates phone numbers, based on region code
- Lazy loads awesome-phonenumber, so that the first paint of this component will be brought to your screen as quick as possible
## Installation
```bash
npm i --save @lion/input-tel
```
```js
import { LionInputTel } from '@lion/input-tel';
// or
import '@lion/input-tel/define';
```

View file

@ -0,0 +1,107 @@
import { LitElement, css, html, repeat, ScopedStylesController } from '@lion/core';
import { regionMetaList } from '../../select-rich/src/regionMetaList.js';
export class HRegionCodeTable extends LitElement {
static properties = {
regionMeta: Object,
};
constructor() {
super();
/** @type {ScopedStylesController} */
this.scopedStylesController = new ScopedStylesController(this);
}
/**
* @param {CSSResult} scope
*/
static scopedStyles(scope) {
return css`
/* Custom input range styling comes here, be aware that this won't work for polyfilled browsers */
.${scope} .sr-only {
position: absolute;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(100%);
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap;
border: 0;
margin: 0;
padding: 0;
}
.${scope} table {
position: relative;
height: 300px;
}
.${scope} th {
border-left: none;
border-right: none;
position: sticky;
top: -1px;
}
.${scope} th .backdrop {
background-color: white;
opacity: 0.95;
filter: blur(4px);
position: absolute;
inset: -5px;
}
.${scope} th .content {
position: relative;
}
.${scope} td {
border-left: none;
border-right: none;
}
`;
}
// Render to light dom, so global table styling will be applied
createRenderRoot() {
return this;
}
render() {
const finalRegionMetaList = this.regionMetaList || regionMetaList;
return html`
<table role="table">
<caption class="sr-only">
Region codes
</caption>
<thead>
<tr>
<th align="left">
<span class="backdrop"></span><span class="content">country name</span>
</th>
<th align="right">
<span class="backdrop"></span><span class="content">region code</span>
</th>
<th align="right">
<span class="backdrop"></span><span class="content">country code</span>
</th>
</tr>
</thead>
<tbody>
${repeat(
finalRegionMetaList,
regionMeta => regionMeta.regionCode,
({ regionCode, countryCode, flagSymbol, nameForLocale }) =>
html` <tr>
<td align="left"><span aria-hidden="true">${flagSymbol}</span> ${nameForLocale}</td>
<td align="right">${regionCode}</td>
<td align="right">${countryCode}</td>
</tr>`,
)}
</tbody>
</table>
`;
}
}
customElements.define('h-region-code-table', HRegionCodeTable);

View file

@ -0,0 +1,31 @@
# Inputs >> Select Rich >> Examples ||30
```js script
import { html } from '@mdjs/mdjs-preview';
import { repeat } from '@lion/core';
import '@lion/select-rich/define';
import './src/intl-select-rich.js';
import { regionMetaList } from './src/regionMetaList.js';
```
## Select Rich International
A visually advanced Subclasser implementation of `LionSelectRich`.
Inspired by:
- [intl-tel-input](https://intl-tel-input.com/)
```js story
export const IntlSelectRich = () => html`
<intl-select-rich label="Choose a region" name="regions">
${repeat(
regionMetaList,
regionMeta => regionMeta.regionCode,
regionMeta =>
html` <intl-option .choiceValue="${regionMeta.regionCode}" .regionMeta="${regionMeta}">
</intl-option>`,
)}
</intl-select-rich>
`;
```

View file

@ -11,7 +11,7 @@ import '@lion/select-rich/define';
You can set the full `modelValue` for each option, which includes the checked property for whether it is checked or not.
```html
<lion-option .modelValue=${{ value: 'red', checked: false }}>Red</lion-option>
<lion-option .modelValue="${{ value: 'red', checked: false }}">Red</lion-option>
```
## Options with HTML
@ -21,15 +21,15 @@ The main feature of this rich select that makes it rich, is that your options ca
```js preview-story
export const optionsWithHTML = () => html`
<lion-select-rich label="Favorite color" name="color">
<lion-option .modelValue=${{ value: 'red', checked: false }}>
<lion-option .choiceValue="${'red'}">
<p style="color: red;">I am red</p>
<p>and multi Line</p>
</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>
<lion-option .choiceValue="${'hotpink'}" checked>
<p style="color: hotpink;">I am hotpink</p>
<p>and multi Line</p>
</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>
<lion-option .choiceValue="${'teal'}">
<p style="color: teal;">I am teal</p>
<p>and multi Line</p>
</lion-option>
@ -49,19 +49,19 @@ export const manyOptionsWithScrolling = () => html`
}
</style>
<lion-select-rich id="scrollSelectRich" label="Favorite color" name="color">
<lion-option .modelValue=${{ value: 'red', checked: false }}>
<lion-option .modelValue="${{ value: 'red', checked: false }}">
<p style="color: red;">I am red</p>
</lion-option>
<lion-option .modelValue=${{ value: 'hotpink', checked: true }}>
<lion-option .modelValue="${{ value: 'hotpink', checked: true }}">
<p style="color: hotpink;">I am hotpink</p>
</lion-option>
<lion-option .modelValue=${{ value: 'teal', checked: false }}>
<lion-option .modelValue="${{ value: 'teal', checked: false }}">
<p style="color: teal;">I am teal</p>
</lion-option>
<lion-option .modelValue=${{ value: 'green', checked: false }}>
<lion-option .modelValue="${{ value: 'green', checked: false }}">
<p style="color: green;">I am green</p>
</lion-option>
<lion-option .modelValue=${{ value: 'blue', checked: false }}>
<lion-option .modelValue"=${{ value: 'blue', checked: false }}">
<p style="color: blue;">I am blue</p>
</lion-option>
</lion-select-rich>
@ -126,7 +126,7 @@ export const renderOptions = ({ shadowRoot }) => {
{ type: 'visacard', label: 'Visa Card', amount: 0, active: false },
];
function showOutput(ev) {
shadowRoot.getElementById('demoRenderOutput').innerHTML = JSON.stringify(
shadowRoot.querySelector('#demoRenderOutput').innerHTML = JSON.stringify(
ev.target.modelValue,
null,
2,
@ -305,49 +305,63 @@ export const singleOptionRemoveAdd = () => {
## Custom Invoker
You can provide a custom invoker using the invoker slot.
This means it will get the selected value(s) as an input property `.selectedElement`.
LionSelectRich will give it acces to:
You can use this `selectedElement` to then render the content to your own invoker.
- the currently selected option via `.selectedElement`
- LionSelectRich itself, via `.hostElement`
Code of an advanced custom invoker is shown below (this is the code for the
invoker used in [IntlSelectRich](./examples.md)).
The invoker is usually added in the invoker slot of the LionSelectRich subclass.
However, it would also be possible for an application developer to provide the invoker
by putting it in light dom:
```html
<lion-select-rich>
<my-invoker-button slot="invoker"></my-invoker-button>
<intl-select-invoker slot="invoker"></intl-select-invoker>
...
</lion-select-rich>
```
An example of how such a custom invoker class could look like:
```js
class MyInvokerButton extends LitElement() {
static get properties() {
return {
selectedElement: {
type: Object,
};
}
}
_contentTemplate() {
if (this.selectedElement) {
const labelNodes = Array.from(this.selectedElement.childNodes);
// Nested html in the selected option
if (labelNodes.length > 0) {
// Cloning is important if you plan on passing nodes straight to a lit template
return labelNodes.map(node => node.cloneNode(true));
}
// Or if it is just text inside the selected option, no html
return this.selectedElement.textContent;
}
return ``;
import { LionSelectInvoker } from '@lion/select-rich';
class IntlSelectInvoker extends LionSelectInvoker {
/**
* 1. Add your own styles
* @configure LitElement
* @enhance LionSelectInvoker
*/
static styles = [
/** <your custom styles> see IntlSelectRich listed above */
];
/**
* 2. Take back control of slots (LionSelectInvoker adds slots you most likely don't want)
* @configure SlotMixin
* @override LionSelectInvoker
*/
get slots() {
return {};
}
/**
* 3. Add you custom render function
* @override LionSelectInvoker
*/
render() {
return html`
<div>
${this._contentTemplate()}
</div>
`;
const ctor = /** @type {typeof LionSelectInvoker} */ (this.constructor);
return ctor._mainTemplate(this._templateData);
}
get _templateData() {
return {
data: { selectedElement: this.selectedElement, hostElement: this.hostElement },
};
}
static _mainTemplate(templateData) {
/** <your custom template> see IntlSelectRich listed above */
}
}
```

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

View file

@ -0,0 +1,193 @@
import { html, css, LitElement } from '@lion/core';
import { LionSelectRich, LionOption, LionSelectInvoker } from '@lion/select-rich';
import { flagStyles } from './flagStyles.js';
/**
* @typedef {import('@lion/core').RenderOptions} RenderOptions
* @typedef {import('@lion/input-tel/types/types').RegionAndCountryCode} RegionAndCountryCode
* @typedef {import('@lion/input-tel/types/types').TemplateDataForDropdownInputTel} TemplateDataForDropdownInputTel
* @typedef {{countryCode: string; regionCode: string; nameForRegion: string; nameForLocale: string}} RegionMetaList
* @typedef {TemplateDataForDropdownInputTel & {data: {regionMetaList:RegionMetaList}}} TemplateDataForIntlInputTel
*/
export class IntlOption extends LionOption {
static properties = { regionMeta: { type: Object } };
static styles = [
super.styles,
flagStyles,
css`
:host {
padding: 5px 10px;
outline: none;
}
:host(:hover),
:host([active]),
:host([checked]) {
background-color: rgba(0, 0, 0, 0.05);
}
`,
];
get _regionCode() {
return this.choiceValue?.toUpperCase();
}
render() {
const ctor = /** @type {typeof IntlOption} */ (this.constructor);
return ctor._contentTemplate({
data: this.regionMeta,
});
}
static _contentTemplate({ data: { regionCode, countryCode, nameForLocale, nameForRegion } }) {
return html`
<div class="iti__flag-box">
<div class="iti__flag iti__${regionCode?.toLowerCase()}"></div>
</div>
<span class="iti__country-name"> ${nameForLocale} (${nameForRegion}) </span>
<span class="iti__dial-code">+${countryCode}</span>
`;
}
}
customElements.define('intl-option', IntlOption);
class IntlSelectInvoker extends LionSelectInvoker {
/**
* @configure LitElement
* @enhance LionSelectInvoker
*/
static styles = [
super.styles,
flagStyles,
css`
:host {
/** TODO: avoid importants; should actually be configured in overlay */
width: auto !important;
background-color: transparent;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
#content-wrapper {
display: flex;
align-items: center;
}
`,
];
/**
* @configure SlotMixin
* @override LionSelectInvoker
*/
get slots() {
return {};
}
/**
* @override LionSelectInvoker
*/
render() {
const ctor = /** @type {typeof LionSelectInvoker} */ (this.constructor);
return ctor._mainTemplate(this._templateData);
}
get _templateData() {
return {
data: { selectedElement: this.selectedElement, hostElement: this.hostElement },
};
}
static _mainTemplate(templateData) {
return html` <div id="content-wrapper">${this._contentTemplate(templateData)}</div> `;
}
static _contentTemplate({ data: { selectedElement, hostElement } }) {
if (!selectedElement) {
return ``;
}
return html`
<div class="iti__flag iti__${selectedElement.regionMeta.regionCode?.toLowerCase()}"></div>
<div class="iti__arrow iti__arrow--${hostElement.opened ? 'up' : 'down'}"></div>
`;
}
}
customElements.define('intl-select-invoker', IntlSelectInvoker);
export class IntlSeparator extends LitElement {
static styles = [
css`
:host {
display: block;
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid #ccc;
}
`,
];
/**
* @lifecycle platform
*/
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'separator');
}
}
customElements.define('intl-separator', IntlSeparator);
export class IntlSelectRich extends LionSelectRich {
static styles = [
super.styles,
css`
:host,
::slotted(*) {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.42857143;
color: #333;
}
::slotted([role='listbox']) {
margin-left: -3px;
display: block;
white-space: nowrap;
max-height: 200px;
overflow-y: scroll;
position: absolute;
z-index: 2;
list-style: none;
text-align: left;
padding: 0;
margin: 0 0 0 -1px;
box-shadow: 1px 1px 4px rgb(0 0 0 / 20%);
background-color: white;
border: 1px solid #ccc;
-webkit-overflow-scrolling: touch;
outline: none;
}
.form-field__group-two,
.input-group,
.input-group__container,
.input-group__input {
height: 100%;
}
`,
];
/**
* @configure ScopedElementsMixin
*/
static scopedElements = { 'intl-select-invoker': IntlSelectInvoker };
/**
* @configure SlotMixin
*/
slots = {
...super.slots,
invoker: () => html`<intl-select-invoker></intl-select-invoker>`,
};
}
customElements.define('intl-select-rich', IntlSelectRich);

File diff suppressed because it is too large Load diff

View file

@ -3,39 +3,67 @@ import { LionField } from '@lion/form-core';
import { LionFieldset } from '@lion/fieldset';
export class HelperOutput extends LitElement {
static get properties() {
return {
field: Object,
show: Array,
};
}
static properties = {
field: Object,
show: Array,
title: String,
readyPromise: Object,
};
static get styles() {
return [
css`
:host {
display: block;
margin-top: 16px;
}
static styles = [
css`
:host {
display: block;
margin-top: 8px;
}
table,
th,
td {
border: 1px solid #ccc;
padding: 4px;
font-size: 12px;
}
code {
font-size: 8px;
background-color: #eee;
}
table {
border-collapse: collapse;
}
caption {
position: absolute;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(100%);
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap;
border: 0;
margin: 0;
padding: 0;
}
caption {
text-align: left;
}
`,
];
}
table,
th,
td {
border-bottom: 1px solid rgb(204, 204, 204);
border-image: initial;
padding: 4px;
font-size: 12px;
border-left: none;
border-right: none;
text-align: left;
}
td {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
table {
border-collapse: collapse;
}
caption {
text-align: left;
}
`,
];
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
@ -47,15 +75,25 @@ export class HelperOutput extends LitElement {
}
}
this.__rerender = this.__rerender.bind(this);
this.field.addEventListener('model-value-changed', this.__rerender);
this.field.addEventListener('mousemove', this.__rerender);
this.field.addEventListener('blur', this.__rerender);
this.field.addEventListener('focusin', this.__rerender);
this.field.addEventListener('focusout', this.__rerender);
const storyRoot = this.field.closest('[mdjs-story-name]');
storyRoot.addEventListener('model-value-changed', this.__rerender);
storyRoot.addEventListener('mousemove', this.__rerender);
// this.field.addEventListener('blur', this.__rerender);
storyRoot.addEventListener('focusin', this.__rerender);
storyRoot.addEventListener('focusout', this.__rerender);
storyRoot.addEventListener('change', this.__rerender);
if (this.field._inputNode.form) {
this.field._inputNode.form.addEventListener('submit', this.__rerender);
}
if (this.readyPromise) {
this.readyPromise.then(() => {
this.__rerender();
});
}
}
__rerender() {
@ -67,29 +105,47 @@ export class HelperOutput extends LitElement {
}
// eslint-disable-next-line class-methods-use-this
__renderProp(p) {
if (typeof p === 'boolean') {
return p === true ? '✓' : '';
__renderProp(prop) {
const field = this.field || {};
let resultText = '';
if (typeof prop === 'string') {
const p = field[prop];
if (typeof p === 'boolean') {
return p === true ? '✓' : '';
}
if (typeof p === 'undefined') {
return html`<code>undefined</code>`;
}
if (typeof p === 'object' && p !== null) {
return JSON.stringify(p);
}
resultText = p;
} else {
resultText = prop.processor(field);
}
if (typeof p === 'undefined') {
return '?';
}
return p;
return html`<span title="${resultText}">${resultText}</span>`;
}
constructor() {
super();
this.title = 'States';
}
render() {
const field = this.field || {};
const computePropName = prop => (typeof prop === 'string' ? prop : prop.name);
return html`
<table>
<caption>
Interaction States
${this.title}
</caption>
<tr>
${this.show.map(prop => html`<th>${prop}</th>`)}
${this.show.map(prop => html`<th>${computePropName(prop)}</th>`)}
</tr>
<tr></tr>
<tr>
${this.show.map(prop => html`<td>${this.__renderProp(field[prop])}</td>`)}
${this.show.map(prop => html`<td>${this.__renderProp(prop)}</td>`)}
</tr>
</table>
`;

View file

@ -14,6 +14,7 @@ import '@lion/input-amount/define';
import '@lion/input-date/define';
import '@lion/input-datepicker/define';
import '@lion/input-email/define';
import '@lion/input-tel/define';
import '@lion/input-iban/define';
import '@lion/input-range/define';
import '@lion/input-stepper/define';
@ -70,6 +71,7 @@ export const main = () => {
<lion-input-amount name="money" label="Money"></lion-input-amount>
<lion-input-iban name="iban" label="Iban"></lion-input-iban>
<lion-input-email name="email" label="Email"></lion-input-email>
<lion-input-tel name="tel" label="Telephone number"></lion-input-tel>
<lion-checkbox-group
label="What do you like?"
name="checkers"

View file

@ -4,6 +4,8 @@
import { html } from '@mdjs/mdjs-preview';
import '@lion/input/define';
import { Unparseable } from '@lion/form-core';
import { liveFormatPhoneNumber } from '@lion/input-tel';
import { Unparseable } from '@lion/form-core';
import './assets/h-output.js';
```
@ -171,6 +173,56 @@ export const preprocessors = () => {
};
```
### Live formatters
Live formatters are a specific type of preprocessor, that format a view value during typing.
Examples:
- a phone number that, during typing formats `+316` as `+31 6`
- a date that follows a date mask and automatically inserts '-' characters
Type '6' in the example below and see that a space will be added and the caret in the text box
will be automatically moved along.
```js preview-story
export const liveFormatters = () => {
return html`
<lion-input
label="Live Format"
.modelValue="${new Unparseable('+31')}"
help-text="Uses .preprocessor to format during typing"
.preprocessor=${(viewValue, { currentCaretIndex, prevViewValue }) => {
return liveFormatPhoneNumber(viewValue, {
regionCode: 'NL',
formatStrategy: 'international',
currentCaretIndex,
prevViewValue,
});
}}
></lion-input>
<h-output .show="${['modelValue']}"></h-output>
`;
};
```
Note that these live formatters need to make an educated guess based on the current (incomplete) view
value what the users intentions are. When implemented correctly, they can create a huge improvement
in user experience.
Next to a changed viewValue, they are also responsible for taking care of the
caretIndex. For instance, if `+316` is changed to `+31 6`, the caret needs to be moved one position
to the right (to compensate for the extra inserted space).
#### When to use a live formatter and when a regular formatter?
Although it might feel more logical to configure live formatters inside the `.formatter` function,
it should be configured inside the `.preprocessor` function. The table below shows differences
between the two mentioned methods
| Function | Value type recieved | Reflected back to user on | Supports incomplete values | Supports caret index |
| :------------ | :------------------ | :------------------------ | :------------------------- | :------------------- |
| .formatter | modelValue | blur (leave) | No | No |
| .preprocessor | viewValue | keyup (live) | Yes | Yes |
## Flow Diagrams
Below we show three flow diagrams to show the flow of formatting, serializing and parsing user input, with the example of a date input:

View file

@ -141,6 +141,12 @@ Then we follow the steps below:
the glue to integrate all other functionality like parsing/formatting/serializing, validating,
tracking interaction states etc.
> N.B. Make sure you never override other property getters than the one mentioned in this tutorial,
> because those properties will loose their reactivity (they won't be considered anymore in the
> update loop of LitElement).
> Whenever a .modelValue/.formattedValue/.serializedValue needs to be computed, use
> [parser/formatter/serializer](../../docs/systems/form/formatting-and-parsing.md)
Implement with the following code:
```js preview-story
@ -165,6 +171,7 @@ export const createAField = () => {
}
get value() {
// Remember we should always return type 'string' here
return this._inputNode.value;
}
@ -183,6 +190,9 @@ export const createAField = () => {
```
That was all!
Now that your .modelValue is connected your component is fully compatible with our form system
> Is your `.modelValue` still undefined? Please make sure you're `.value` is of type 'string'.
Now you can enhance your slider by writing custom Validators for it or by
writing a parser to get a custom modelValue type.

View file

@ -35,10 +35,14 @@ export class ScopedStylesController {
}
__setupStyleTag() {
// Make it win from other elements on the page.
// TODO: consider adding an id here to always win, since we are simulating shadow dom
// behavior here.
const highSpecifictyScope = `${this.scopedClass}.${this.scopedClass}`;
this.__styleTag.textContent = /** @type {typeof ScopedStylesController} */ (
this.host.constructor
)
.scopedStyles(unsafeCSS(this.scopedClass))
.scopedStyles(unsafeCSS(highSpecifictyScope))
.toString();
this.host.insertBefore(this.__styleTag, this.host.childNodes[0]);
}

View file

@ -43,6 +43,7 @@ const FormControlMixinImplementation = superclass =>
name: { type: String, reflect: true },
readOnly: { type: Boolean, attribute: 'readonly', reflect: true },
label: String, // FIXME: { attribute: false } breaks a bunch of tests, but shouldn't...
labelSrOnly: { type: Boolean, attribute: 'label-sr-only', reflect: true },
helpText: { type: String, attribute: 'help-text' },
modelValue: { attribute: false },
_ariaLabelledNodes: { attribute: false },
@ -186,6 +187,12 @@ const FormControlMixinImplementation = superclass =>
*/
this.label = '';
/**
* The label will only be visible for srceen readers when true
* @type {boolean}
*/
this.labelSrOnly = false;
/**
* The helpt text for the input node.
* When no value is defined, textContent of [slot=help-text] will be used
@ -699,6 +706,20 @@ const FormControlMixinImplementation = superclass =>
color: var(--disabled-text-color, #767676);
}
:host([label-sr-only]) .form-field__label {
position: absolute;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(100%);
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap;
border: 0;
margin: 0;
padding: 0;
}
/***********************
{block} .input-group
*********************/

View file

@ -7,7 +7,7 @@ import { ValidateMixin } from './validate/ValidateMixin.js';
/**
* @typedef {import('../types/FormatMixinTypes').FormatMixin} FormatMixin
* @typedef {import('@lion/localize/types/LocalizeMixinTypes').FormatNumberOptions} FormatOptions
* @typedef {import('../types/FormatMixinTypes').FormatOptions} FormatOptions
* @typedef {import('../types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails
*/
@ -107,20 +107,26 @@ const FormatMixinImplementation = superclass =>
}
/**
* Preprocesses the viewValue before it's parsed to a modelValue. Can be used to filter
* invalid input amongst others.
* Preprocessors could be considered 'live formatters'. Their result is shown to the user
* on keyup instead of after blurring the field. The biggest difference between preprocessors
* and formatters is their moment of execution: preprocessors are run before modelValue is
* computed (and work based on view value), whereas formatters are run after the parser (and
* are based on modelValue)
* Automatically formats code while typing. It depends on a preprocessro that smartly
* updates the viewValue and caret position for best UX.
* @example
* ```js
* preprocessor(viewValue) {
* // only use digits
* return viewValue.replace(/\D/g, '');
* }
* ```
* @param {string} v - the raw value from the <input> after keyUp/Down event
* @returns {string} preprocessedValue: the result of preprocessing for invalid input
* @param {FormatOptions & { prevViewValue: string; currentCaretIndex: number }} opts - the raw value from the <input> after keyUp/Down event
* @returns {{ viewValue:string; caretIndex:number; }|string|undefined} preprocessedValue: the result of preprocessing for invalid input
*/
preprocessor(v) {
return v;
// eslint-disable-next-line no-unused-vars
preprocessor(v, opts) {
return undefined;
}
/**
@ -204,6 +210,7 @@ const FormatMixinImplementation = superclass =>
}
this._reflectBackFormattedValueToUser();
this.__preventRecursiveTrigger = false;
this.__prevViewValue = this.value;
}
/**
@ -218,13 +225,12 @@ const FormatMixinImplementation = superclass =>
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 '';
}
// A.2) Handle edge cases We might have no view value yet, for instance because
// 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
@ -263,8 +269,7 @@ const FormatMixinImplementation = superclass =>
if (
this._isHandlingUserInput &&
this.hasFeedbackFor &&
this.hasFeedbackFor.length &&
this.hasFeedbackFor?.length &&
this.hasFeedbackFor.includes('error') &&
this._inputNode
) {
@ -282,6 +287,8 @@ const FormatMixinImplementation = superclass =>
}
/**
* Responds to modelValue changes in the synchronous cycle (most subclassers should listen to
* the asynchronous cycle ('modelValue' in the .updated lifecycle))
* @param {{ modelValue: unknown; }[]} args
* @protected
*/
@ -320,7 +327,7 @@ const FormatMixinImplementation = superclass =>
*/
_syncValueUpwards() {
if (!this.__isHandlingComposition) {
this.value = this.preprocessor(this.value);
this.__handlePreprocessor();
}
const prevFormatted = this.formattedValue;
this.modelValue = this._callParser(this.value);
@ -330,8 +337,45 @@ const FormatMixinImplementation = superclass =>
if (prevFormatted === this.formattedValue && this.__prevViewValue !== this.value) {
this._calculateValues();
}
/** @type {string} */
this.__prevViewValue = this.value;
}
/**
* Handle view value and caretIndex, depending on return type of .preprocessor.
* @private
*/
__handlePreprocessor() {
const unprocessedValue = this.value;
let currentCaretIndex = this.value.length;
// Be gentle with Safari
if (
this._inputNode &&
'selectionStart' in this._inputNode &&
/** @type {HTMLInputElement} */ (this._inputNode)?.type !== 'range'
) {
currentCaretIndex = /** @type {number} */ (this._inputNode.selectionStart);
}
const preprocessedValue = this.preprocessor(this.value, {
...this.formatOptions,
currentCaretIndex,
prevViewValue: this.__prevViewValue,
});
this.__prevViewValue = unprocessedValue;
if (preprocessedValue === undefined) {
// Make sure we do no set back original value, so we preserve
// caret index (== selectionStart/selectionEnd)
return;
}
if (typeof preprocessedValue === 'string') {
this.value = preprocessedValue;
} else if (typeof preprocessedValue === 'object') {
const { viewValue, caretIndex } = preprocessedValue;
this.value = viewValue;
if (caretIndex && this._inputNode && 'selectionStart' in this._inputNode) {
this._inputNode.selectionStart = caretIndex;
this._inputNode.selectionEnd = caretIndex;
}
}
}
/**
@ -351,7 +395,7 @@ const FormatMixinImplementation = superclass =>
/**
* Every time .formattedValue is attempted to sync to the view value (on change/blur and on
* modelValue change), this condition is checked. When enhancing it, it's recommended to
* call `super._reflectBackOn()`
* call via `return this._myExtraCondition && super._reflectBackOn()`
* @overridable
* @return {boolean}
* @protected
@ -490,6 +534,9 @@ const FormatMixinImplementation = superclass =>
};
}
/**
* @private
*/
__onPaste() {
this._isPasting = true;
this.formatOptions.mode = 'pasted';
@ -510,6 +557,9 @@ const FormatMixinImplementation = superclass =>
if (typeof this.modelValue === 'undefined') {
this._syncValueUpwards();
}
/** @type {string} */
this.__prevViewValue = this.value;
this._reflectBackFormattedValueToUser();
if (this._inputNode) {

View file

@ -17,6 +17,10 @@ import { FormControlMixin } from '../FormControlMixin.js';
/**
* @typedef {import('../../types/validate/ValidateMixinTypes').ValidateMixin} ValidateMixin
* @typedef {import('../../types/validate/ValidateMixinTypes').ValidationType} ValidationType
* @typedef {import('../../types/validate/ValidateMixinTypes').ValidateHost} ValidateHost
* @typedef {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} ValidateHostConstructor
* @typedef {{validator:Validator; outcome:boolean|string}} ValidationResultEntry
* @typedef {{[type:string]: {[validatorName:string]:boolean|string}}} ValidationStates
*/
/**
@ -159,7 +163,7 @@ export const ValidateMixinImplementation = superclass =>
*/
this.showsFeedbackFor = [];
// TODO: [v1] make this fully private (preifix __)?
// TODO: [v1] make this fully private (prefix __)?
/**
* A temporary storage to transition from hasFeedbackFor to showsFeedbackFor
* @type {ValidationType[]}
@ -171,7 +175,7 @@ export const ValidateMixinImplementation = superclass =>
/**
* The outcome of a validation 'round'. Keyed by ValidationType and Validator name
* @readOnly
* @type {Object.<string, Object.<string, boolean>>}
* @type {ValidationStates}
*/
this.validationStates = {};
@ -206,44 +210,45 @@ export const ValidateMixinImplementation = superclass =>
/**
* The amount of feedback messages that will visible in LionValidationFeedback
* @configurable
* @protected
*/
this._visibleMessagesAmount = 1;
/**
* @type {Validator[]}
* @type {ValidationResultEntry[]}
* @private
*/
this.__syncValidationResult = [];
/**
* @type {Validator[]}
* @type {ValidationResultEntry[]}
* @private
*/
this.__asyncValidationResult = [];
/**
* Aggregated result from sync Validators, async Validators and ResultValidators
* @type {Validator[]}
* @type {ValidationResultEntry[]}
* @private
*/
this.__validationResult = [];
/**
* @type {Validator[]}
* @type {ValidationResultEntry[]}
* @private
*/
this.__prevValidationResult = [];
/**
* @type {Validator[]}
* @type {ValidationResultEntry[]}
* @private
*/
this.__prevShownValidationResult = [];
/**
* The updated children validity affects the validity of the parent. Helper to recompute
* validatity of parent FormGroup
* validity of parent FormGroup
* @private
*/
this.__childModelValueChanged = false;
@ -337,7 +342,7 @@ export const ValidateMixinImplementation = superclass =>
* Triggered by:
* - modelValue change
* - change in the 'validators' array
* - change in the config of an individual Validator
* - change in the config of an individual Validator
*
* Three situations are handled:
* - a1) the FormControl is empty: further execution is halted. When the Required Validator
@ -384,6 +389,14 @@ export const ValidateMixinImplementation = superclass =>
* @desc step a1-3 + b (as explained in `validate()`)
*/
async __executeValidators() {
/**
* Allows Application Developer to wait for (async) validation
* @example
* ```js
* await el.validateComplete;
* ```
* @type {Promise<boolean>}
*/
this.validateComplete = new Promise(resolve => {
this.__validateCompleteResolve = resolve;
});
@ -410,7 +423,7 @@ export const ValidateMixinImplementation = superclass =>
const isEmpty = this.__isEmpty(value);
if (isEmpty) {
if (requiredValidator) {
this.__syncValidationResult = [requiredValidator];
this.__syncValidationResult = [{ validator: requiredValidator, outcome: true }];
}
this.__finishValidation({ source: 'sync' });
return;
@ -451,9 +464,12 @@ export const ValidateMixinImplementation = superclass =>
*/
__executeSyncValidators(syncValidators, value, { hasAsync }) {
if (syncValidators.length) {
this.__syncValidationResult = syncValidators.filter(v =>
v.execute(value, v.param, { node: this }),
);
this.__syncValidationResult = syncValidators
.map(v => ({
validator: v,
outcome: /** @type {boolean|string} */ (v.execute(value, v.param, { node: this })),
}))
.filter(v => Boolean(v.outcome));
}
this.__finishValidation({ source: 'sync', hasAsync });
}
@ -468,10 +484,15 @@ export const ValidateMixinImplementation = superclass =>
if (asyncValidators.length) {
this.isPending = true;
const resultPromises = asyncValidators.map(v => v.execute(value, v.param, { node: this }));
const booleanResults = await Promise.all(resultPromises);
this.__asyncValidationResult = booleanResults
.map((r, i) => asyncValidators[i]) // Create an array of Validators
.filter((v, i) => booleanResults[i]); // Only leave the ones returning true
const asyncExecutionResults = await Promise.all(resultPromises);
this.__asyncValidationResult = asyncExecutionResults
.map((r, i) => ({
validator: asyncValidators[i],
outcome: /** @type {boolean|string} */ (asyncExecutionResults[i]),
}))
.filter(v => Boolean(v.outcome));
this.__finishValidation({ source: 'async' });
this.isPending = false;
}
@ -479,7 +500,7 @@ export const ValidateMixinImplementation = superclass =>
/**
* step b (as explained in `validate()`), called by __finishValidation
* @param {Validator[]} regularValidationResult result of steps 1-3
* @param {{validator:Validator; outcome: boolean|string;}[]} regularValidationResult result of steps 1-3
* @private
*/
__executeResultValidators(regularValidationResult) {
@ -490,13 +511,21 @@ export const ValidateMixinImplementation = superclass =>
})
);
return resultValidators.filter(v =>
v.executeOnResults({
regularValidationResult,
prevValidationResult: this.__prevValidationResult,
prevShownValidationResult: this.__prevShownValidationResult,
}),
);
// Map everything to Validator[] for backwards compatibility
return resultValidators
.map(v => ({
validator: v,
outcome: /** @type {boolean|string} */ (
v.executeOnResults({
regularValidationResult: regularValidationResult.map(entry => entry.validator),
prevValidationResult: this.__prevValidationResult.map(entry => entry.validator),
prevShownValidationResult: this.__prevShownValidationResult.map(
entry => entry.validator,
),
})
),
}))
.filter(v => Boolean(v.outcome));
}
/**
@ -512,35 +541,32 @@ export const ValidateMixinImplementation = superclass =>
const resultOutCome = this.__executeResultValidators(syncAndAsyncOutcome);
this.__validationResult = [...resultOutCome, ...syncAndAsyncOutcome];
// this._storeResultsOnInstance(this.__validationResult);
const ctor =
/** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (
this.constructor
);
const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
/** @type {Object.<string, Object.<string, boolean>>} */
/** @type {ValidationStates} */
const validationStates = ctor.validationTypes.reduce(
(acc, type) => ({ ...acc, [type]: {} }),
{},
);
this.__validationResult.forEach(v => {
if (!validationStates[v.type]) {
validationStates[v.type] = {};
this.__validationResult.forEach(({ validator, outcome }) => {
if (!validationStates[validator.type]) {
validationStates[validator.type] = {};
}
const vCtor = /** @type {typeof Validator} */ (v.constructor);
validationStates[v.type][vCtor.validatorName] = true;
const vCtor = /** @type {typeof Validator} */ (validator.constructor);
validationStates[validator.type][vCtor.validatorName] = outcome;
});
this.validationStates = validationStates;
this.hasFeedbackFor = [...new Set(this.__validationResult.map(v => v.type))];
this.hasFeedbackFor = [
...new Set(this.__validationResult.map(({ validator }) => validator.type)),
];
/** private event that should be listened to by LionFieldSet */
this.dispatchEvent(new Event('validate-performed', { bubbles: true }));
if (source === 'async' || !hasAsync) {
if (this.__validateCompleteResolve) {
// @ts-ignore [allow-private]
this.__validateCompleteResolve();
this.__validateCompleteResolve(true);
}
}
}
@ -587,10 +613,7 @@ export const ValidateMixinImplementation = superclass =>
console.error(errorMessage, this);
throw new Error(errorMessage);
}
const ctor =
/** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (
this.constructor
);
const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
if (ctor.validationTypes.indexOf(v.type) === -1) {
const vCtor = /** @type {typeof Validator} */ (v.constructor);
// throws in constructor are not visible to end user so we do both
@ -640,14 +663,14 @@ export const ValidateMixinImplementation = superclass =>
*/
/**
* @param {Validator[]} validators list of objects having a .getMessage method
* @param {ValidationResultEntry[]} validationResults list of objects having a .getMessage method
* @return {Promise.<FeedbackMessage[]>}
* @private
*/
async __getFeedbackMessages(validators) {
async __getFeedbackMessages(validationResults) {
let fieldName = await this.fieldName;
return Promise.all(
validators.map(async validator => {
validationResults.map(async ({ validator, outcome }) => {
if (validator.config.fieldName) {
fieldName = await validator.config.fieldName;
}
@ -656,6 +679,7 @@ export const ValidateMixinImplementation = superclass =>
modelValue: this.modelValue,
formControl: this,
fieldName,
outcome,
});
return { message, type: validator.type, validator };
}),
@ -690,10 +714,19 @@ export const ValidateMixinImplementation = superclass =>
if (this.showsFeedbackFor.length > 0) {
this.__feedbackQueue.add(async () => {
/** @type {Validator[]} */
this.__prioritizedResult = this._prioritizeAndFilterFeedback({
validationResult: this.__validationResult,
const prioritizedValidators = this._prioritizeAndFilterFeedback({
validationResult: this.__validationResult.map(entry => entry.validator),
});
this.__prioritizedResult = prioritizedValidators
.map(v => {
const found = /** @type {ValidationResultEntry} */ (
this.__validationResult.find(r => v === r.validator)
);
return found;
})
.filter(Boolean);
if (this.__prioritizedResult.length > 0) {
this.__prevShownValidationResult = this.__prioritizedResult;
}
@ -732,12 +765,12 @@ export const ValidateMixinImplementation = superclass =>
}
/**
* Allows the end user to specify when a feedback message should be shown
* Allows the Application Developer to specify when a feedback message should be shown
* @example
* ```js
* feedbackCondition(type, meta, defaultCondition) {
* if (type === 'info') {
* return return;
* return true;
* } else if (type === 'prefilledOnly') {
* return meta.prefilled;
* }
@ -775,7 +808,9 @@ export const ValidateMixinImplementation = superclass =>
);
}
/** @param {import('@lion/core').PropertyValues} changedProperties */
/**
* @param {import('@lion/core').PropertyValues} changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
@ -783,10 +818,7 @@ export const ValidateMixinImplementation = superclass =>
changedProperties.has('shouldShowFeedbackFor') ||
changedProperties.has('hasFeedbackFor')
) {
const ctor =
/** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (
this.constructor
);
const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
// Necessary typecast because types aren't smart enough to understand that we filter out undefined
this.showsFeedbackFor = /** @type {string[]} */ (
ctor.validationTypes
@ -822,10 +854,7 @@ export const ValidateMixinImplementation = superclass =>
* @protected
*/
_updateShouldShowFeedbackFor() {
const ctor =
/** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (
this.constructor
);
const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
// Necessary typecast because types aren't smart enough to understand that we filter out undefined
const newShouldShowFeedbackFor = /** @type {string[]} */ (
@ -848,18 +877,15 @@ export const ValidateMixinImplementation = superclass =>
}
/**
* Orders all active validators in this.__validationResult. Can
* also filter out occurrences (based on interaction states)
* Orders all active validators in this.__validationResult.
* Can also filter out occurrences (based on interaction states)
* @overridable
* @param {{ validationResult: Validator[] }} opts
* @return {Validator[]} ordered list of Validators with feedback messages visible to the end user
* @protected
*/
_prioritizeAndFilterFeedback({ validationResult }) {
const ctor =
/** @type {typeof import('../../types/validate/ValidateMixinTypes').ValidateHost} */ (
this.constructor
);
const ctor = /** @type {ValidateHostConstructor} */ (this.constructor);
const types = ctor.validationTypes;
// Sort all validators based on the type provided.
const res = validationResult

View file

@ -1,44 +1,65 @@
/**
* @typedef {object} MessageData
* @property {*} [MessageData.modelValue]
* @property {string} [MessageData.fieldName]
* @property {HTMLElement} [MessageData.formControl]
* @property {string} [MessageData.type]
* @property {Object.<string,?>} [MessageData.config]
* @property {string} [MessageData.name]
* @typedef {import('./types').FeedbackMessageData} FeedbackMessageData
* @typedef {import('./types').ValidatorParam} ValidatorParam
* @typedef {import('./types').ValidatorConfig} ValidatorConfig
* @typedef {import('./types').ValidatorOutcome} ValidatorOutcome
* @typedef {import('./types').ValidatorName} ValidatorName
* @typedef {import('./types').ValidationType} ValidationType
* @typedef {import('../FormControlMixin').FormControlHost} FormControlHost
*/
export class Validator {
// TODO: support attribute validators like <my-el my-validator=${dynamicParam}></my-el> =>
// register in a ValidateService that is read by Validator and adds these attrs in properties
// object.
// They would become like configurable
// [global attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes)
// for FormControls.
export class Validator extends EventTarget {
/**
*
* @param {?} [param]
* @param {Object.<string,?>} [config]
* @param {ValidatorParam} [param]
* @param {ValidatorConfig} [config]
*/
constructor(param, config) {
this.__fakeExtendsEventTarget();
super();
/** @type {?} */
/** @type {ValidatorParam} */
this.__param = param;
/** @type {Object.<string,?>} */
/** @type {ValidatorConfig} */
this.__config = config || {};
this.type = (config && config.type) || 'error'; // Default type supported by ValidateMixin
}
static get validatorName() {
return '';
}
static get async() {
return false;
/** @type {ValidationType} */
this.type = config?.type || 'error'; // Default type supported by ValidateMixin
}
/**
* @desc The function that returns a Boolean
* @param {?} [modelValue]
* @param {?} [param]
* @param {{}} [config]
* @returns {Boolean|Promise<Boolean>}
* The name under which validation results get registered. For convience and predictability, this
* should always be the same as the constructor name (since it will be obfuscated in js builds,
* we need to provide it separately).
* @type {ValidatorName}
*/
static validatorName = '';
/**
* Whether the validator is asynchronous or not. When true., this means execute function returns
* a Promise. This can be handy for:
* - server side calls
* - validations that are dependent on lazy loaded resources (they can be async until the dependency
* is loaded)
* @type {boolean}
*/
static async = false;
/**
* The function that returns a validity outcome. When we need to shpw feedback,
* it should return true, otherwise false. So when an error\info|warning|success message
* needs to be shown, return true. For async Validators, the function canretun a Promise.
* It's also possible to return an enum. Let's say that a phone number can have multiple
* states: 'invalid-country-code' | 'too-long' | 'too-short'
* Those states can be retrieved in the getMessage
* @param {any} modelValue
* @param {ValidatorParam} [param]
* @param {ValidatorConfig} [config]
* @returns {ValidatorOutcome|Promise<ValidatorOutcome>}
*/
// eslint-disable-next-line no-unused-vars, class-methods-use-this
execute(modelValue, param, config) {
@ -51,22 +72,55 @@ export class Validator {
return true;
}
/**
* The first argument of the constructor, for instance 3 in `new MinLength(3)`. Will
* be stored on Validator instance and passed to `execute` function
* @example
* ```js
* // Store reference to Validator instance
* const myValidatorInstance = new MyValidator(1);
* // Use this instance initially on a FormControl (that uses ValidateMixin)
* render(html`<validatable-element .validators="${[myValidatorInstance]}"></validatable-element>`, document.body);
* // Based on some event, we need to change the param
* myValidatorInstance.param = 2;
* ```
* @property {ValidatorParam}
*/
set param(p) {
this.__param = p;
if (this.dispatchEvent) {
this.dispatchEvent(new Event('param-changed'));
}
/**
* This event is listened for by ValidateMixin. Whenever the validation parameter has
* changed, the FormControl will revalidate itself
*/
this.dispatchEvent(new Event('param-changed'));
}
get param() {
return this.__param;
}
/**
* The second argument of the constructor, for instance
* `new MinLength(3, {getFeedMessage: async () => 'too long'})`.
* Will be stored on Validator instance and passed to `execute` function.
* @example
* ```js
* // Store reference to Validator instance
* const myValidatorInstance = new MyValidator(1, {getMessage() => 'x'});
* // Use this instance initially on a FormControl (that uses ValidateMixin)
* render(html`<validatable-element .validators="${[myValidatorInstance]}"></validatable-element>`, document.body);
* // Based on some event, we need to change the param
* myValidatorInstance.config = {getMessage() => 'y'};
* ```
* @property {ValidatorConfig}
*/
set config(c) {
this.__config = c;
if (this.dispatchEvent) {
this.dispatchEvent(new Event('config-changed'));
}
/**
* This event is listened for by ValidateMixin. Whenever the validation config has
* changed, the FormControl will revalidate itself
*/
this.dispatchEvent(new Event('config-changed'));
}
get config() {
@ -74,9 +128,29 @@ export class Validator {
}
/**
* @overridable
* @param {MessageData} [data]
* @returns {Promise<string|Node>}
* This is a protected method that usually should not be overridden. It is called by ValidateMixin
* and it gathers data to be passed to getMessage functions found:
* - `this.config.getMessage`, locally provided by consumers of the Validator (overrides global getMessage)
* - `MyValidator.getMessage`, globally provided by creators or consumers of the Validator
*
* Confusion can arise because of similarities with former mentioned methods. In that regard, a
* better name for this function would have been _pepareDataAndCallHighestPrioGetMessage.
* @example
* ```js
* class MyValidator extends Validator {
* // ...
* // 1. globally defined
* static async getMessage() {
* return 'lowest prio, defined globally by Validator author'
* }
* }
* // 2. globally overridden
* MyValidator.getMessage = async() => 'overrides already configured message';
* // 3. locally overridden
* new MyValidator(myParam, { getMessage: async() => 'locally defined, always wins' });
* ```
* @param {Partial<FeedbackMessageData>} [data]
* @returns {Promise<string|Element>}
* @protected
*/
async _getMessage(data) {
@ -101,9 +175,20 @@ export class Validator {
}
/**
* Called inside Validator.prototype._getMessage (see explanation).
* @example
* ```js
* class MyValidator extends Validator {
* static async getMessage() {
* return 'lowest prio, defined globally by Validator author'
* }
* }
* // globally overridden
* MyValidator.getMessage = async() => 'overrides already configured message';
* ```
* @overridable
* @param {MessageData} [data]
* @returns {Promise<string|Node>}
* @param {Partial<FeedbackMessageData>} [data]
* @returns {Promise<string|Element>}
*/
// eslint-disable-next-line no-unused-vars
static async getMessage(data) {
@ -111,12 +196,38 @@ export class Validator {
}
/**
* @param {HTMLElement} formControl
* Validators are allowed to have knowledge about FormControls.
* In some cases (in case of the Required Validator) we wanted to enhance accessibility by
* adding [aria-required]. Also, it would be possible to write an advanced MinLength
* Validator that adds a .preprocessor that restricts from typing too many characters
* (like the native [minlength] validator).
* Will be called when Validator is added to FormControl.validators.
* @example
* ```js
* onFormControlConnect(formControl) {
* if(formControl.inputNode) {
* inputNode.setAttribute('aria-required', 'true');
* }
* }
*
* ```
* @configurable
* @param {FormControlHost} formControl
*/
onFormControlConnect(formControl) {} // eslint-disable-line
/**
* @param {HTMLElement} formControl
* Also see `onFormControlConnect`.
* Will be called when Validator is removed from FormControl.validators.
* @example
* ```js
* onFormControlDisconnect(formControl) {
* if(formControl.inputNode) {
* inputNode.removeAttribute('aria-required');
* }
* }
* @configurable
* @param {FormControlHost} formControl
*/
onFormControlDisconnect(formControl) {} // eslint-disable-line
@ -130,41 +241,6 @@ export class Validator {
* - Or, when a webworker was started, its process could be aborted and then restarted.
*/
abortExecution() {} // eslint-disable-line
/**
* @private
*/
__fakeExtendsEventTarget() {
const delegate = document.createDocumentFragment();
/**
*
* @param {string} type
* @param {EventListener} listener
* @param {Object} [opts]
*/
const delegatedAddEventListener = (type, listener, opts) =>
delegate.addEventListener(type, listener, opts);
/**
* @param {string} type
* @param {EventListener} listener
* @param {Object} [opts]
*/
const delegatedRemoveEventListener = (type, listener, opts) =>
delegate.removeEventListener(type, listener, opts);
/**
* @param {Event|CustomEvent} event
*/
const delegatedDispatchEvent = event => delegate.dispatchEvent(event);
this.addEventListener = delegatedAddEventListener;
this.removeEventListener = delegatedRemoveEventListener;
this.dispatchEvent = delegatedDispatchEvent;
}
}
// For simplicity, a default validator only handles one state:

View file

@ -0,0 +1,60 @@
import { FormControlHost } from '../../FormControlMixin';
export { ValidationType } from '../../types/ValidateMixinTypes';
/**
* The name under which validation results get registered. For convience and predictability, this
* should always be the same as the constructor name (since it will be obfuscated in js builds,
* we need to provide it separately).
* @example
* ```js
* class MyValidator extends Validator {
* static validatorName = 'MyValidator';
* // etc...
* }
* ```
*/
export type ValidatorName = string;
/**
* The first argument of the constructor, for instance 3 in `new MinLength(3)`. Will
* be stored on Validator instance and passed to `execute` function
*/
export type ValidatorParam = any;
/**
* The second argument of the constructor, for instance
* `new MinLength(3, {getFeedbackMessage: async () => 'too long'})`.
* Will be stored on Validator instance and passed to `execute` function
*/
export type ValidatorConfig = {
getMessage?: (data: Partial<FeedbackMessageData>) => Promise<string | Element>;
type?: ValidationType;
node?: FormControlHost;
fieldName?: string | Promise<string>;
};
/**
* Output of the `execute` function that returns a validity outcome. When we need to shpw feedback,
* it should return true, otherwise false. So when an error\info|warning|success message
* needs to be shown, return true.
* It's also possible to return an enum. Let's say that a phone number can have multiple
* states: 'invalid-country-code' | 'too-long' | 'too-short'
* Those states can be retrieved in the getMessage function.
*/
export type ValidatorOutcome = string | boolean;
/**
* Data object that will be provided to `getMessage()` method
*/
export type FeedbackMessageData = {
modelValue: any;
/** Value configured in FormControl.fieldName. Contributes to accessible error messages */
fieldName: string;
name: ValidatorName;
formControl: FormControlHost;
type: ValidationType;
/* Outcome of Validator.execute function. Can be a boolean (true) or enum */
outcome: ValidatorOutcome;
config: ValidatorConfig;
params: ValidatorParam;
};

View file

@ -1,2 +1,3 @@
export * from './ExampleValidators.js';
export * from './getFormControlMembers.js';
export * from './mimicUserInput.js';

View file

@ -0,0 +1,16 @@
// @ts-nocheck
/**
* @param {HTMLElement} formControl
* @param {?} newViewValue
* @param {{caretIndex?:number}} config
*/
export function mimicUserInput(formControl, newViewValue, { caretIndex } = {}) {
formControl.value = newViewValue; // eslint-disable-line no-param-reassign
if (caretIndex) {
// eslint-disable-next-line no-param-reassign
formControl._inputNode.selectionStart = caretIndex;
// eslint-disable-next-line no-param-reassign
formControl._inputNode.selectionEnd = caretIndex;
}
formControl._inputNode.dispatchEvent(new Event('input', { bubbles: true }));
}

View file

@ -4,7 +4,7 @@ import { aTimeout, defineCE, expect, fixture, html, unsafeStatic } from '@open-w
import sinon from 'sinon';
import { Unparseable, Validator } from '../index.js';
import { FormatMixin } from '../src/FormatMixin.js';
import { getFormControlMembers } from '../test-helpers/getFormControlMembers.js';
import { getFormControlMembers, mimicUserInput } from '../test-helpers/index.js';
/**
* @typedef {import('../types/FormControlMixinTypes').FormControlHost} FormControlHost
@ -34,22 +34,6 @@ class FormatClass extends FormatMixin(LitElement) {
}
}
/**
* @param {FormatClass} formControl
* @param {?} newViewValue
* @param {{caretIndex?:number}} config
*/
function mimicUserInput(formControl, newViewValue, { caretIndex } = {}) {
formControl.value = newViewValue; // eslint-disable-line no-param-reassign
if (caretIndex) {
// eslint-disable-next-line no-param-reassign
formControl._inputNode.selectionStart = caretIndex;
// eslint-disable-next-line no-param-reassign
formControl._inputNode.selectionEnd = caretIndex;
}
formControl._inputNode.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* @param {{tagString?: string, modelValueType?: modelValueType}} [customConfig]
*/
@ -57,6 +41,7 @@ export function runFormatMixinSuite(customConfig) {
const cfg = {
tagString: null,
childTagString: null,
modelValueType: String,
...customConfig,
};
@ -633,6 +618,58 @@ export function runFormatMixinSuite(customConfig) {
_inputNode.dispatchEvent(new Event('compositionend', { bubbles: true }));
expect(preprocessorSpy.callCount).to.equal(1);
});
describe('Live Formatters', () => {
it('receives meta object with { prevViewValue: string; currentCaretIndex: number; }', async () => {
const spy = sinon.spy();
const valInitial = generateValueBasedOnType();
const el = /** @type {FormatClass} */ (
await fixture(
html`<${tag} .modelValue="${valInitial}" .preprocessor=${spy}><input slot="input"></${tag}>`,
)
);
const viewValInitial = el.value;
const valToggled = generateValueBasedOnType({ toggleValue: true });
mimicUserInput(el, valToggled, { caretIndex: 1 });
expect(spy.args[0][0]).to.equal(el.value);
const formatOptions = spy.args[0][1];
expect(formatOptions.prevViewValue).to.equal(viewValInitial);
expect(formatOptions.currentCaretIndex).to.equal(1);
});
it('updates return viewValue and caretIndex', async () => {
/**
* @param {string} viewValue
* @param {{ prevViewValue: string; currentCaretIndex: number; }} meta
*/
function myPreprocessor(viewValue, { currentCaretIndex }) {
return { viewValue: `${viewValue}q`, caretIndex: currentCaretIndex + 1 };
}
const el = /** @type {FormatClass} */ (
await fixture(
html`<${tag} .modelValue="${'xyz'}" .preprocessor=${myPreprocessor}><input slot="input"></${tag}>`,
)
);
mimicUserInput(el, 'wxyz', { caretIndex: 1 });
expect(el._inputNode.value).to.equal('wxyzq');
expect(el._inputNode.selectionStart).to.equal(2);
});
it('does not update when undefined is returned', async () => {
const el = /** @type {FormatClass} */ (
await fixture(
html`<${tag} .modelValue="${'xyz'}" live-format .liveFormatter=${() =>
undefined}><input slot="input"></${tag}>`,
)
);
mimicUserInput(el, 'wxyz', { caretIndex: 1 });
expect(el._inputNode.value).to.equal('wxyz');
// Make sure we do not put our already existing value back, because caret index would be lost
expect(el._inputNode.selectionStart).to.equal(1);
});
});
});
});
});

View file

@ -70,7 +70,7 @@ export function runValidateMixinSuite(customConfig) {
*/
describe('Validation initiation', () => {
it('throws and console.errors if adding not Validator instances to the validators array', async () => {
it('throws and console.errors if adding non Validator instances to the validators array', async () => {
// we throw and console error as constructor throw are not visible to the end user
const stub = sinon.stub(console, 'error');
const el = /** @type {ValidateElement} */ (await fixture(html`<${tag}></${tag}>`));
@ -93,7 +93,7 @@ export function runValidateMixinSuite(customConfig) {
stub.restore();
});
it('throws and console error if adding a not supported Validator type', async () => {
it('throws a console error if adding a non supported Validator type', async () => {
// we throw and console error to improve DX
const stub = sinon.stub(console, 'error');
const errorMessage = `This component does not support the validator type "major error" used in "MajorValidator". You may change your validators type or add it to the components "static get validationTypes() {}".`;
@ -720,7 +720,10 @@ export function runValidateMixinSuite(customConfig) {
// @ts-ignore [allow-private] in test
const totalValidationResult = el.__validationResult;
expect(totalValidationResult).to.eql([resultV, validator]);
expect(totalValidationResult).to.eql([
{ validator: resultV, outcome: true },
{ validator, outcome: true },
]);
});
});
@ -1049,7 +1052,7 @@ export function runValidateMixinSuite(customConfig) {
await fixture(html`
<${tag}
.modelValue=${'123'}
.validators=${[new MinLength(3, { message: 'foo' })]}>
.validators=${[new MinLength(3, { getMessage: async () => 'foo' })]}>
<input slot="input">
</${tag}>`)
);
@ -1352,14 +1355,14 @@ export function runValidateMixinSuite(customConfig) {
};
}
// @ts-ignore
/**
* @param {string} type
*/
_showFeedbackConditionFor(type) {
switch (type) {
case 'error':
// @ts-ignore
return ['A', 'B'].includes(this.modelValue);
default:
// @ts-ignore
return ['B', 'C'].includes(this.modelValue);
}
}
@ -1384,12 +1387,10 @@ export function runValidateMixinSuite(customConfig) {
['D', []],
]) {
el.modelValue = modelValue;
// eslint-disable-next-line no-await-in-loop
await el.updateComplete;
// eslint-disable-next-line no-await-in-loop
await el.feedbackComplete;
// @ts-ignore
// @ts-expect-error [allow-protected]
const resultOrder = el._feedbackNode.feedbackData.map(v => v.type);
expect(resultOrder).to.deep.equal(expected);
}

View file

@ -7,6 +7,10 @@ import sinon from 'sinon';
import { DefaultSuccess, MinLength, Required, ValidateMixin, Validator } from '../index.js';
import { AlwaysInvalid } from '../test-helpers/index.js';
/**
* @typedef {import('../src/validate/types').FeedbackMessageData} FeedbackMessageData
*/
export function runValidateMixinFeedbackPart() {
describe('Validity Feedback', () => {
beforeEach(() => {
@ -303,7 +307,7 @@ export function runValidateMixinFeedbackPart() {
await fixture(html`
<${tag}
.submitted=${true}
.validators=${[new MinLength(3, { getMessage: () => 'custom via config' })]}
.validators=${[new MinLength(3, { getMessage: async () => 'custom via config' })]}
>${lightDom}</${tag}>
`)
);
@ -397,7 +401,7 @@ export function runValidateMixinFeedbackPart() {
});
});
describe('Meta data', () => {
describe('FeedbackMessageData', () => {
it('".getMessage()" gets a reference to formControl, params, modelValue and type', async () => {
class ValidateElementCustomTypes extends ValidateMixin(LitElement) {
static get validationTypes() {
@ -431,6 +435,7 @@ export function runValidateMixinFeedbackPart() {
fieldName: '',
type: 'x',
name: 'MinLength',
outcome: true,
});
const instanceMessageSpy = sinon.spy();
@ -457,6 +462,7 @@ export function runValidateMixinFeedbackPart() {
fieldName: '',
type: 'error',
name: 'MinLength',
outcome: true,
});
});
@ -485,41 +491,85 @@ export function runValidateMixinFeedbackPart() {
fieldName: 'myField',
type: 'error',
name: 'MinLength',
outcome: true,
});
});
});
it('".getMessage()" gets .fieldName defined on Validator config', async () => {
const constructorValidator = new MinLength(4, {
fieldName: new Promise(resolve => resolve('myFieldViaCfg')),
it('".getMessage()" gets .fieldName defined on Validator config', async () => {
const constructorValidator = new MinLength(4, {
fieldName: new Promise(resolve => resolve('myFieldViaCfg')),
});
const ctorValidator = /** @type {typeof MinLength} */ (constructorValidator.constructor);
const spy = sinon.spy(ctorValidator, 'getMessage');
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.submitted=${true}
.validators=${[constructorValidator]}
.modelValue=${'cat'}
.fieldName=${new Promise(resolve => resolve('myField'))}
>${lightDom}</${tag}>
`)
);
await el.updateComplete;
await el.feedbackComplete;
// ignore fieldName Promise as it will always be unique
const compare = spy.args[0][0];
delete compare?.config?.fieldName;
expect(compare).to.eql({
config: {},
params: 4,
modelValue: 'cat',
formControl: el,
fieldName: 'myFieldViaCfg',
type: 'error',
name: 'MinLength',
outcome: true,
});
});
const ctorValidator = /** @type {typeof MinLength} */ (constructorValidator.constructor);
const spy = sinon.spy(ctorValidator, 'getMessage');
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.submitted=${true}
.validators=${[constructorValidator]}
.modelValue=${'cat'}
.fieldName=${new Promise(resolve => resolve('myField'))}
>${lightDom}</${tag}>
`)
);
await el.updateComplete;
await el.feedbackComplete;
it('".getMessage()" gets .outcome, which can be "true" or an enum', async () => {
class EnumOutComeValidator extends Validator {
static validatorName = 'EnumOutCome';
// ignore fieldName Promise as it will always be unique
const compare = spy.args[0][0];
delete compare?.config?.fieldName;
expect(compare).to.eql({
config: {},
params: 4,
modelValue: 'cat',
formControl: el,
fieldName: 'myFieldViaCfg',
type: 'error',
name: 'MinLength',
execute() {
return 'a-string-instead-of-bool';
}
/**
* @param {FeedbackMessageData} meta
* @returns
*/
static async getMessage({ outcome }) {
const results = {
'a-string-instead-of-bool': 'Msg based on enum output',
};
return results[/** @type {string} */ (outcome)];
}
}
const enumOutComeValidator = new EnumOutComeValidator();
const spy = sinon.spy(
/** @type {typeof EnumOutComeValidator} */ (enumOutComeValidator.constructor),
'getMessage',
);
const el = /** @type {ValidateElement} */ (
await fixture(html`
<${tag}
.submitted=${true}
.validators=${[enumOutComeValidator]}
.modelValue=${'cat'}
>${lightDom}</${tag}>
`)
);
await el.updateComplete;
await el.feedbackComplete;
const getMessageArs = spy.args[0][0];
expect(getMessageArs.outcome).to.equal('a-string-instead-of-bool');
});
});

View file

@ -80,6 +80,43 @@ describe('FormControlMixin', () => {
expect(el.label).to.equal('');
});
/**
* N.B. For platform controls, the same would be achieved with <input aria-label="My label">
* However, since FormControl is usually not the activeElement (_inputNode is), this
* will not have the desired effect on for instance lion-input
*/
it('supports "label-sr-only" to make label visually hidden, but accessible for screen reader users', async () => {
const el = /** @type {FormControlMixinClass} */ (
await fixture(html`
<${tag} label-sr-only>
<label slot="label">Email <span>address</span></label>
${inputSlot}
</${tag}>`)
);
const expectedValues = {
position: 'absolute',
top: '0px',
width: '1px',
height: '1px',
overflow: 'hidden',
clipPath: 'inset(100%)',
clip: 'rect(1px, 1px, 1px, 1px)',
whiteSpace: 'nowrap',
borderWidth: '0px',
margin: '0px',
padding: '0px',
};
const labelStyle = window.getComputedStyle(
// @ts-ignore
el.shadowRoot?.querySelector('.form-field__label'),
);
Object.entries(expectedValues).forEach(([key, val]) => {
expect(labelStyle[key]).to.equal(val);
});
});
it('can have a help-text', async () => {
const elAttr = /** @type {FormControlMixinClass} */ (
await fixture(html`

View file

@ -48,7 +48,7 @@ describe('Validator', () => {
it('throws when executing a Validator without a name', async () => {
class MyValidator extends Validator {}
expect(() => {
new MyValidator().execute();
new MyValidator().execute(undefined);
}).to.throw(
'A validator needs to have a name! Please set it via "static get validatorName() { return \'IsCat\'; }"',
);
@ -61,6 +61,7 @@ describe('Validator', () => {
}
}
// @ts-ignore needed for test
const vali = new MyValidator({}, { getMessage: 'This is the custom error message' });
const { getMessage } = getProtectedMembers(vali);
@ -76,8 +77,8 @@ describe('Validator', () => {
});
it('receives a config object (optionally) as a second argument on instantiation', async () => {
const vali = new Validator('myParam', { my: 'config' });
expect(vali.config).to.eql({ my: 'config' });
const vali = new Validator('myParam', { fieldName: 'X' });
expect(vali.config).to.eql({ fieldName: 'X' });
});
it('has access to name, type, params, config in getMessage provided by config', () => {
@ -87,7 +88,7 @@ describe('Validator', () => {
return 'MyValidator';
}
}
const vali = new MyValidator('myParam', { my: 'config', getMessage: configSpy });
const vali = new MyValidator('myParam', { fieldName: 'X', getMessage: configSpy });
const { getMessage } = getProtectedMembers(vali);
getMessage();
@ -95,7 +96,7 @@ describe('Validator', () => {
name: 'MyValidator',
type: 'error',
params: 'myParam',
config: { my: 'config', getMessage: configSpy },
config: { fieldName: 'X', getMessage: configSpy },
});
});
@ -114,7 +115,7 @@ describe('Validator', () => {
return '';
}
}
const vali = new MyValidator('myParam', { my: 'config' });
const vali = new MyValidator('myParam', { fieldName: 'X' });
const { getMessage } = getProtectedMembers(vali);
getMessage();
@ -122,7 +123,7 @@ describe('Validator', () => {
name: 'MyValidator',
type: 'error',
params: 'myParam',
config: { my: 'config' },
config: { fieldName: 'X' },
});
});
@ -137,12 +138,12 @@ describe('Validator', () => {
});
it('fires "config-changed" event on config change', async () => {
const vali = new Validator('foo', { foo: 'bar' });
const vali = new Validator('foo', { fieldName: 'X' });
const cb = sinon.spy(() => {});
if (vali.addEventListener) {
vali.addEventListener('config-changed', cb);
}
vali.config = { bar: 'foo' };
vali.config = { fieldName: 'Y' };
expect(cb.callCount).to.equal(1);
});

View file

@ -36,6 +36,8 @@ export type ModelValueEventDetails = {
declare interface HTMLElementWithValue extends HTMLElement {
value: string;
selectionStart?: number;
selectionEnd?: number;
}
export declare class FormControlHost {

View file

@ -4,6 +4,7 @@ import { FormatNumberOptions } from '@lion/localize/types/LocalizeMixinTypes';
import { ValidateHost } from './validate/ValidateMixinTypes';
import { FormControlHost } from './FormControlMixinTypes';
export type FormatOptions = { mode: 'pasted' | 'auto' } & object;
export declare class FormatHost {
/**
* Converts viewValue to modelValue
@ -12,7 +13,7 @@ export declare class FormatHost {
* @param {FormatOptions} opts
* @returns {*} modelValue
*/
parser(v: string, opts: FormatNumberOptions): unknown;
parser(v: string, opts: FormatOptions): unknown;
/**
* Converts modelValue to formattedValue (formattedValue will be synced with
@ -23,7 +24,7 @@ export declare class FormatHost {
* @param {FormatOptions} opts
* @returns {string} formattedValue
*/
formatter(v: unknown, opts?: FormatNumberOptions): string;
formatter(v: unknown, opts?: FormatOptions): string;
/**
* Converts `.modelValue` to `.serializedValue`
@ -44,19 +45,27 @@ export declare class FormatHost {
deserializer(v: string): unknown;
/**
* Preprocesses the viewValue before it's parsed to a modelValue. Can be used to filter
* invalid input amongst others.
* Preprocessors could be considered 'live formatters'. Their result is shown to the user
* on keyup instead of after blurring the field. The biggest difference between preprocessors
* and formatters is their moment of execution: preprocessors are run before modelValue is
* computed (and work based on view value), whereas formatters are run after the parser (and
* are based on modelValue)
* Automatically formats code while typing. It depends on a preprocessro that smartly
* updates the viewValue and caret position for best UX.
* @example
* ```js
* preprocessor(viewValue) {
* // only use digits
* return viewValue.replace(/\D/g, '');
* }
* ```
* @param {string} v - the raw value from the <input> after keyUp/Down event
* @returns {string} preprocessedValue: the result of preprocessing for invalid input
* @param {FormatOptions & { prevViewValue: string; currentCaretIndex: number }} opts - the raw value from the <input> after keyUp/Down event
* @returns {{ viewValue:string; caretIndex:number; }|string|undefined} preprocessedValue: the result of preprocessing for invalid input
*/
preprocessor(v: string): string;
preprocessor(
v: string,
options: FormatOptions & { prevViewValue: string; currentCaretIndex: number },
): { viewValue: string; caretIndex: number } | string | undefined;
/**
* The view value is the result of the formatter function (when available).
@ -99,7 +108,7 @@ export declare class FormatHost {
/**
* Configuration object that will be available inside the formatter function
*/
formatOptions: FormatNumberOptions;
formatOptions: FormatOptions;
/**
* The view value. Will be delegated to `._inputNode.value`

View file

@ -104,7 +104,7 @@ export declare class ValidateHost {
* Triggered by:
* - modelValue change
* - change in the 'validators' array
* - change in the config of an individual Validator
* - change in the config of an individual Validator
*
* Three situations are handled:
* - a1) the FormControl is empty: further execution is halted. When the Required Validator

View file

@ -40,6 +40,7 @@ describe('Form inside dialog Integrations', () => {
'lion-input-amount',
'lion-input-iban',
'lion-input-email',
'lion-input-tel',
'lion-checkbox-group',
' lion-checkbox',
' lion-checkbox',

View file

@ -159,6 +159,7 @@ describe(`Submitting/Resetting/Clearing Form`, async () => {
},
range: '',
rsvp: '',
tel: '',
terms: [],
comments: '',
});

View file

@ -32,6 +32,7 @@ describe('Form Integrations', () => {
terms: [],
notifications: { value: '', checked: false },
rsvp: '',
tel: '',
comments: '',
});
});
@ -59,6 +60,7 @@ describe('Form Integrations', () => {
terms: [],
notifications: '',
rsvp: '',
tel: '',
comments: '',
});
});
@ -112,6 +114,7 @@ describe('Form Integrations', () => {
'lion-input-amount',
'lion-input-iban',
'lion-input-email',
'lion-input-tel',
'lion-checkbox-group',
' lion-checkbox',
' lion-checkbox',

View file

@ -46,7 +46,7 @@ describe('Form Validation Integrations', () => {
await fixture(html`
<${elTag}
.validators=${[
new Required(null, { getMessage: () => 'error' }),
new Required(null, { getMessage: async () => 'error' }),
new WarnValidator(null, { getMessage: () => 'warning' }),
new DefaultSuccess(),
]}

View file

@ -8,6 +8,7 @@ import '@lion/input-datepicker/define';
import '@lion/input-amount/define';
import '@lion/input-iban/define';
import '@lion/input-email/define';
import '@lion/input-tel/define';
import '@lion/checkbox-group/define';
import '@lion/radio-group/define';
import '@lion/select/define';
@ -71,6 +72,7 @@ export class UmbrellaForm extends LitElement {
<lion-input-amount name="money" label="Money"></lion-input-amount>
<lion-input-iban name="iban" label="Iban"></lion-input-iban>
<lion-input-email name="email" label="Email"></lion-input-email>
<lion-input-tel name="tel" label="Telephone Number"></lion-input-tel>
<lion-checkbox-group
label="What do you like?"
name="checkers"

View file

@ -5,6 +5,11 @@ import { IsNumber } from '@lion/form-core';
import { formatAmount, formatCurrencyLabel } from './formatters.js';
import { parseAmount } from './parsers.js';
/**
* @typedef {import('@lion/form-core/types/FormatMixinTypes').FormatOptions} FormatOptions
* @typedef {FormatOptions & {locale?:string;currency:string|undefined}} AmountFormatOptions
*/
/**
* `LionInputAmount` is a class for an amount custom form element (`<lion-input-amount>`).
*
@ -87,9 +92,10 @@ export class LionInputAmount extends LocalizeMixin(LionInput) {
if (changedProperties.has('locale') && this.locale !== changedProperties.get('locale')) {
if (this.locale) {
this.formatOptions.locale = this.locale;
/** @type {AmountFormatOptions} */
(this.formatOptions).locale = this.locale;
} else {
delete this.formatOptions.locale;
delete (/** @type {AmountFormatOptions} */ (this.formatOptions).locale);
}
this.__reformat();
}
@ -141,7 +147,8 @@ export class LionInputAmount extends LocalizeMixin(LionInput) {
return;
}
this.formatOptions.currency = currency || undefined;
/** @type {AmountFormatOptions} */
(this.formatOptions).currency = currency || undefined;
if (currency) {
if (!this.__currencyDisplayNodeIsConnected) {
this.appendChild(this.__currencyDisplayNode);

View file

@ -110,7 +110,7 @@ export class IsIBAN extends Validator {
* @param {string} [data.type]
* @param {Object.<string,?>} [data.config]
* @param {string} [data.name]
* @returns {Promise<string|Node>}
* @returns {Promise<string|Element>}
*/
static async getMessage(data) {
await loadTranslations();
@ -153,7 +153,7 @@ export class IsCountryIBAN extends IsIBAN {
* @param {string} [data.type]
* @param {Object.<string,?>} [data.config]
* @param {string} [data.name]
* @returns {Promise<string|Node>}
* @returns {Promise<string|Element>}
*/
static async getMessage(data) {
await loadTranslations();
@ -200,7 +200,7 @@ export class IsNotCountryIBAN extends IsIBAN {
* @param {string} [data.type]
* @param {Object.<string,?>} [data.config]
* @param {string} [data.name]
* @returns {Promise<string|Node>}
* @returns {Promise<string|Element>}
*/
static async getMessage(data) {
await loadTranslations();

View file

@ -0,0 +1,3 @@
# Lion Input Tel
[=> See Source <=](../../docs/components/inputs/input-tel/overview.md)

View file

@ -0,0 +1 @@
import './lion-input-tel-dropdown.js';

View file

@ -0,0 +1,3 @@
# Lion Input Tel Dropdown Features
[=> See Source <=](../../../docs/components/inputs/input-tel-dropdown/features.md)

View file

@ -0,0 +1,3 @@
# Lion Input Tel Dropdown Overview
[=> See Source <=](../../../docs/components/inputs/input-tel-dropdown/overview.md)

View file

@ -0,0 +1 @@
export { LionInputTelDropdown } from './src/LionInputTelDropdown.js';

View file

@ -0,0 +1,3 @@
import { LionInputTelDropdown } from './src/LionInputTelDropdown.js';
customElements.define('lion-input-tel-dropdown', LionInputTelDropdown);

View file

@ -0,0 +1,57 @@
{
"name": "@lion/input-tel-dropdown",
"version": "0.0.0",
"description": "Input field for entering phone numbers with the help of a dropdown region list",
"license": "MIT",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/input-tel-dropdown"
},
"main": "index.js",
"module": "index.js",
"files": [
"*.d.ts",
"*.js",
"custom-elements.json",
"docs",
"src",
"test",
"types"
],
"scripts": {
"custom-elements-manifest": "custom-elements-manifest analyze --litelement --exclude \"docs/**/*\" \"test-helpers/**/*\"",
"debug": "cd ../../ && npm run debug -- --group input-tel-dropdown",
"debug:firefox": "cd ../../ && npm run debug:firefox -- --group input-tel-dropdown",
"debug:webkit": "cd ../../ && npm run debug:webkit -- --group input-tel-dropdown",
"publish-docs": "node ../../packages-node/publish-docs/src/cli.js --github-url https://github.com/ing-bank/lion/ --git-root-dir ../../",
"prepublishOnly": "npm run publish-docs && npm run custom-elements-manifest",
"test": "cd ../../ && npm run test:browser -- --group input-tel-dropdown"
},
"sideEffects": [
"lion-input-tel-dropdown.js"
],
"dependencies": {
"@lion/core": "0.21.1",
"@lion/input-tel": "0.0.0",
"@lion/localize": "0.23.0"
},
"keywords": [
"input",
"input-tel-dropdown",
"lion",
"web-components"
],
"publishConfig": {
"access": "public"
},
"customElements": "custom-elements.json",
"exports": {
".": "./index.js",
"./define": "./define.js",
"./test-suites": "./test-suites/index.js",
"./docs/*": "./docs/*"
}
}

View file

@ -0,0 +1,375 @@
// @ts-expect-error ref, createRef are exported (?)
import { render, html, css, ref, createRef } from '@lion/core';
import { LionInputTel } from '@lion/input-tel';
/**
* Note: one could consider to implement LionInputTelDropdown as a
* [combobox](https://www.w3.org/TR/wai-aria-practices-1.2/#combobox).
* However, the country dropdown does not directly set the textbox value, it only determines
* its region code. Therefore it does not comply to this criterium:
* "A combobox is an input widget with an associated popup that enables users to select a value for
* the combobox from a collection of possible values. In some implementations,
* the popup presents allowed values, while in other implementations, the popup presents suggested
* values, and users may either select one of the suggestions or type a value".
* We therefore decided to consider the dropdown a helper mechanism that does not set, but
* contributes to and helps format and validate the actual value.
*/
/**
* @typedef {import('lit/directives/ref.js').Ref} Ref
* @typedef {import('@lion/core').RenderOptions} RenderOptions
* @typedef {import('@lion/form-core/types/FormatMixinTypes').FormatHost} FormatHost
* @typedef {import('@lion/input-tel/types').FormatStrategy} FormatStrategy
* @typedef {import('@lion/input-tel/types').RegionCode} RegionCode
* @typedef {import('../types').TemplateDataForDropdownInputTel} TemplateDataForDropdownInputTel
* @typedef {import('../types').OnDropdownChangeEvent} OnDropdownChangeEvent
* @typedef {import('../types').DropdownRef} DropdownRef
* @typedef {import('../types').RegionMeta} RegionMeta
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
* @typedef {import('@lion/select-rich').LionSelectRich} LionSelectRich
* @typedef {import('@lion/overlays').OverlayController} OverlayController
* @typedef {TemplateDataForDropdownInputTel & {data: {regionMetaList:RegionMeta[]}}} TemplateDataForIntlInputTel
*/
// eslint-disable-next-line prefer-destructuring
/**
* @param {string} char
*/
function getRegionalIndicatorSymbol(char) {
return String.fromCodePoint(0x1f1e6 - 65 + char.toUpperCase().charCodeAt(0));
}
/**
* LionInputTelDropdown renders a dropdown like element next to the text field, inside the
* prefix slot. This could be a LionSelect, a LionSelectRich or a native select.
* By default, the native `<select>` element is used for this, so that it's as lightweight as
* possible. Also, it doesn't need to be a `FormControl`, because it's purely a helper element
* to provide better UX: the modelValue (the text field) contains all needed info, since it's in
* `e164` format that contains all info (both region code and national phone number).
*/
export class LionInputTelDropdown extends LionInputTel {
/**
* @configure LitElement
* @type {any}
*/
static properties = { preferredRegions: { type: Array } };
refs = {
/** @type {DropdownRef} */
dropdown: /** @type {DropdownRef} */ (createRef()),
};
/**
* This method provides a TemplateData object to be fed to pure template functions, a.k.a.
* Pure Templates. The goal is to totally decouple presentation from logic here, so that
* Subclassers can override all content without having to loose private info contained
* within the template function that was overridden.
*
* Subclassers would need to make sure all the contents of the TemplateData object are implemented
* by making sure they are coupled to the right 'ref' ([data-ref=dropdown] in this example),
* with the help of lit's spread operator directive.
* To enhance this process, the TemplateData object is completely typed. Ideally, this would be
* enhanced by providing linters that make sure all of their required members are implemented by
* a Subclasser.
* When a Subclasser wants to add more data, this can be done via:
* @example
* ```js
* get _templateDataDropdown() {
* return {
* ...super._templateDataDropdown,
* myExtraData: { x: 1, y: 2 },
* }
* }
* ```
* @overridable
* @type {TemplateDataForDropdownInputTel}
*/
get _templateDataDropdown() {
const refs = {
dropdown: {
ref: this.refs.dropdown,
props: {
style: `height: 100%;`,
},
listeners: {
change: this._onDropdownValueChange,
'model-value-changed': this._onDropdownValueChange,
},
labels: {
// TODO: localize this
selectCountry: 'Select country',
},
},
};
return {
refs,
data: {
activeRegion: this.activeRegion,
regionMetaList: this.__regionMetaList,
regionMetaListPreferred: this.__regionMetaListPreferred,
},
};
}
static templates = {
dropdown: (/** @type {TemplateDataForDropdownInputTel} */ templateDataForDropdown) => {
const { refs, data } = templateDataForDropdown;
const renderOption = (/** @type {RegionMeta} */ regionMeta) =>
html`${this.templates.dropdownOption(templateDataForDropdown, regionMeta)} `;
// TODO: once spread directive available, use it per ref
return html`
<select
${ref(refs?.dropdown?.ref)}
aria-label="${refs?.dropdown?.labels?.selectCountry}"
@change="${refs?.dropdown?.listeners?.change}"
style="${refs?.dropdown?.props?.style}"
>
${data?.regionMetaListPreferred?.length
? html`
${data.regionMetaListPreferred.map(renderOption)}
<option disabled>---------------</option>
${data?.regionMetaList?.map(renderOption)}
`
: html` ${data?.regionMetaList?.map(renderOption)}`}
</select>
`;
},
/**
* @param {TemplateDataForDropdownInputTel} templateDataForDropdown
* @param {RegionMeta} contextData
*/
// eslint-disable-next-line class-methods-use-this
dropdownOption: (templateDataForDropdown, { regionCode, countryCode, flagSymbol }) => html`
<option value="${regionCode}">${regionCode} (+${countryCode}) &nbsp; ${flagSymbol}</option>
`,
};
/**
* @configure LitElement
* @enhance LionInputTel
*/
static styles = [
super.styles,
css`
/**
* We need to align the height of the dropdown with the height of the text field.
* We target the HTMLDivElement 'this.__dropdownRenderParent' here. Its child,
* [data-ref=dropdown], recieves a 100% height as well via inline styles (since we
* can't target from shadow styles).
*/
::slotted([slot='prefix']) {
height: 100%;
}
`,
];
/**
* @configure SlotMixin
*/
get slots() {
return {
...super.slots,
prefix: () => this.__dropdownRenderParent,
};
}
/**
* @configure LocalizeMixin
*/
onLocaleUpdated() {
super.onLocaleUpdated();
// @ts-expect-error relatively new platform api
this.__namesForLocale = new Intl.DisplayNames([this._langIso], {
type: 'region',
});
this.__createRegionMeta();
this._scheduleLightDomRender();
}
/**
* @enhance LionInputTel
*/
_onPhoneNumberUtilReady() {
super._onPhoneNumberUtilReady();
this.__createRegionMeta();
}
/**
* @lifecycle platform
*/
constructor() {
super();
/**
* Regions that will be shown on top of the dropdown
* @type {string[]}
*/
this.preferredRegions = [];
/** @type {HTMLDivElement} */
this.__dropdownRenderParent = document.createElement('div');
/**
* Contains everything needed for rendering region options:
* region code, country code, display name according to locale, display name
* @type {RegionMeta[]}
*/
this.__regionMetaList = [];
/**
* A filtered `this.__regionMetaList`, containing all regions provided in `preferredRegions`
* @type {RegionMeta[]}
*/
this.__regionMetaListPreferred = [];
/** @type {EventListener} */
this._onDropdownValueChange = this._onDropdownValueChange.bind(this);
/** @type {EventListener} */
this.__syncRegionWithDropdown = this.__syncRegionWithDropdown.bind(this);
}
/**
* @lifecycle LitElement
* @param {import('lit-element').PropertyValues } changedProperties
*/
willUpdate(changedProperties) {
super.willUpdate(changedProperties);
if (changedProperties.has('allowedRegions')) {
this.__createRegionMeta();
}
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('_needsLightDomRender')) {
this.__renderDropdown();
}
if (changedProperties.has('activeRegion')) {
this.__syncRegionWithDropdown();
}
}
/**
* @protected
* @param {OnDropdownChangeEvent} event
*/
_onDropdownValueChange(event) {
const isInitializing = event.detail?.initialize || !this._phoneUtil;
if (isInitializing) {
return;
}
const prevActiveRegion = this.activeRegion;
this._setActiveRegion(
/** @type {RegionCode} */ (event.target.value || event.target.modelValue),
);
// Change region code in text box
// From: https://bl00mber.github.io/react-phone-input-2.html
if (prevActiveRegion !== this.activeRegion && !this.focused && this._phoneUtil) {
const prevCountryCode = this._phoneUtil.getCountryCodeForRegionCode(prevActiveRegion);
const countryCode = this._phoneUtil.getCountryCodeForRegionCode(this.activeRegion);
if (countryCode && !this.modelValue) {
// When textbox is empty, prefill it with country code
this.modelValue = `+${countryCode}`;
} else if (prevCountryCode && countryCode) {
// When textbox is not empty, replace country code
this.modelValue = this._callParser(
this.value.replace(`+${prevCountryCode}`, `+${countryCode}`),
);
}
}
// Put focus on text box
const overlayController = event.target._overlayCtrl;
if (overlayController?.isShown) {
setTimeout(() => {
this._inputNode.focus();
});
} else {
// For native select
this._inputNode.focus();
}
}
/**
* Abstract away rendering to light dom, so that we can rerender when needed
* @private
*/
__renderDropdown() {
const ctor = /** @type {typeof LionInputTelDropdown} */ (this.constructor);
// If the user locally overrode the templates, get those on the instance
const templates = this.templates || ctor.templates;
render(
templates.dropdown(this._templateDataDropdown),
this.__dropdownRenderParent,
/** @type {RenderOptions} */ ({
scopeName: this.localName,
eventContext: this,
}),
);
this.__syncRegionWithDropdown();
}
/**
* @private
*/
__syncRegionWithDropdown(regionCode = this.activeRegion) {
const dropdownElement = this.refs.dropdown?.value;
if (!dropdownElement || !regionCode) {
return;
}
if ('modelValue' in dropdownElement) {
/** @type {* & FormatHost} */ (dropdownElement).modelValue = regionCode;
} else {
/** @type {HTMLSelectElement} */ (dropdownElement).value = regionCode;
}
}
/**
* Prepares data for options, like "Greece (Ελλάδα)", where "Greece" is `nameForLocale` and
* "Ελλάδα" `nameForRegion`.
* This should be run on change of:
* - allowedRegions
* - _phoneUtil loaded
* - locale
* @private
*/
__createRegionMeta() {
if (!this._allowedOrAllRegions?.length || !this.__namesForLocale) {
return;
}
this.__regionMetaList = [];
this.__regionMetaListPreferred = [];
this._allowedOrAllRegions.forEach(regionCode => {
// @ts-expect-error Intl.DisplayNames platform api not yet typed
const namesForRegion = new Intl.DisplayNames([regionCode.toLowerCase()], {
type: 'region',
});
const countryCode =
this._phoneUtil && this._phoneUtil.getCountryCodeForRegionCode(regionCode);
const flagSymbol =
getRegionalIndicatorSymbol(regionCode[0]) + getRegionalIndicatorSymbol(regionCode[1]);
const destinationList = this.preferredRegions.includes(regionCode)
? this.__regionMetaListPreferred
: this.__regionMetaList;
destinationList.push({
regionCode,
countryCode,
flagSymbol,
nameForLocale: this.__namesForLocale.of(regionCode),
nameForRegion: namesForRegion.of(regionCode),
});
});
}
}

View file

@ -0,0 +1,213 @@
import {
expect,
fixture as _fixture,
fixtureSync as _fixtureSync,
html,
defineCE,
unsafeStatic,
aTimeout,
} from '@open-wc/testing';
import sinon from 'sinon';
// @ts-ignore
import { PhoneUtilManager } from '@lion/input-tel';
// @ts-ignore
import { mockPhoneUtilManager, restorePhoneUtilManager } from '@lion/input-tel/test-helpers';
import { LionInputTelDropdown } from '../src/LionInputTelDropdown.js';
/**
* @typedef {import('@lion/core').TemplateResult} TemplateResult
* @typedef {HTMLSelectElement|HTMLElement & {modelValue:string}} DropdownElement
* @typedef {import('../types').TemplateDataForDropdownInputTel} TemplateDataForDropdownInputTel
*/
const fixture = /** @type {(arg: string | TemplateResult) => Promise<LionInputTelDropdown>} */ (
_fixture
);
const fixtureSync = /** @type {(arg: string | TemplateResult) => LionInputTelDropdown} */ (
_fixtureSync
);
/**
* @param {DropdownElement} dropdownEl
* @returns {string}
*/
function getDropdownValue(dropdownEl) {
if ('modelValue' in dropdownEl) {
return dropdownEl.modelValue;
}
return dropdownEl.value;
}
/**
* @param {DropdownElement} dropdownEl
* @param {string} value
*/
function mimicUserChangingDropdown(dropdownEl, value) {
if ('modelValue' in dropdownEl) {
// eslint-disable-next-line no-param-reassign
dropdownEl.modelValue = value;
dropdownEl.dispatchEvent(
new CustomEvent('model-value-changed', { detail: { isTriggeredByUser: true } }),
);
} else {
// eslint-disable-next-line no-param-reassign
dropdownEl.value = value;
dropdownEl.dispatchEvent(new Event('change'));
}
}
/**
* @param {{ klass:LionInputTelDropdown }} config
*/
// @ts-ignore
export function runInputTelDropdownSuite({ klass = LionInputTelDropdown } = {}) {
// @ts-ignore
const tagName = defineCE(/** @type {* & HTMLElement} */ (class extends klass {}));
const tag = unsafeStatic(tagName);
describe('LionInputTelDropdown', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
describe('Dropdown display', () => {
it('calls `templates.dropdown` with TemplateDataForDropdownInputTel object', async () => {
const el = fixtureSync(html` <${tag}
.modelValue="${'+31612345678'}"
.allowedRegions="${['NL', 'PH']}"
.preferredRegions="${['PH']}"
></${tag}> `);
const spy = sinon.spy(
/** @type {typeof LionInputTelDropdown} */ (el.constructor).templates,
'dropdown',
);
await el.updateComplete;
const dropdownNode = el.refs.dropdown.value;
const templateDataForDropdown = /** @type {TemplateDataForDropdownInputTel} */ (
spy.args[0][0]
);
expect(templateDataForDropdown).to.eql(
/** @type {TemplateDataForDropdownInputTel} */ ({
data: {
activeRegion: 'NL',
regionMetaList: [
{
countryCode: 31,
flagSymbol: '🇳🇱',
nameForLocale: 'Netherlands',
nameForRegion: 'Nederland',
regionCode: 'NL',
},
],
regionMetaListPreferred: [
{
countryCode: 63,
flagSymbol: '🇵🇭',
nameForLocale: 'Philippines',
nameForRegion: 'Philippines',
regionCode: 'PH',
},
],
},
refs: {
dropdown: {
labels: { selectCountry: 'Select country' },
listeners: {
// @ts-expect-error [allow-protected]
change: el._onDropdownValueChange,
// @ts-expect-error [allow-protected]
'model-value-changed': el._onDropdownValueChange,
},
props: { style: 'height: 100%;' },
ref: { value: dropdownNode },
},
},
}),
);
});
it('syncs dropdown value initially from activeRegion', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['DE']}"></${tag}> `);
expect(getDropdownValue(/** @type {DropdownElement} */ (el.refs.dropdown.value))).to.equal(
'DE',
);
});
it('renders to prefix slot in light dom', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['DE']}"></${tag}> `);
const prefixSlot = /** @type {HTMLElement} */ (
/** @type {HTMLElement} */ (el.refs.dropdown.value).parentElement
);
expect(prefixSlot.getAttribute('slot')).to.equal('prefix');
expect(prefixSlot.slot).to.equal('prefix');
expect(prefixSlot.parentElement).to.equal(el);
});
it('rerenders light dom when PhoneUtil loaded', async () => {
const { resolveLoaded } = mockPhoneUtilManager();
const el = await fixture(html` <${tag} .allowedRegions="${['DE']}"></${tag}> `);
// @ts-ignore
const spy = sinon.spy(el, '_scheduleLightDomRender');
resolveLoaded(undefined);
await aTimeout(0);
expect(spy).to.have.been.calledOnce;
restorePhoneUtilManager();
});
});
describe('On dropdown value change', () => {
it('changes the currently active country code in the textbox', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${[
'NL',
'BE',
]}" .modelValue="${'+31612345678'}"></${tag}> `,
);
// @ts-ignore
mimicUserChangingDropdown(el.refs.dropdown.value, 'BE');
await el.updateComplete;
expect(el.activeRegion).to.equal('BE');
expect(el.modelValue).to.equal('+32612345678');
await el.updateComplete;
expect(el.value).to.equal('+32612345678');
});
it('focuses the textbox right after selection', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${[
'NL',
'BE',
]}" .modelValue="${'+31612345678'}"></${tag}> `,
);
// @ts-ignore
mimicUserChangingDropdown(el.refs.dropdown.value, 'BE');
await el.updateComplete;
// @ts-expect-error
expect(el._inputNode).to.equal(document.activeElement);
});
it('prefills country code when text box is empty', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['NL', 'BE']}"></${tag}> `);
// @ts-ignore
mimicUserChangingDropdown(el.refs.dropdown.value, 'BE');
await el.updateComplete;
await el.updateComplete;
expect(el.value).to.equal('+32');
});
});
describe('On activeRegion change', () => {
it('updates dropdown value ', async () => {
const el = await fixture(html` <${tag} .modelValue="${'+31612345678'}"></${tag}> `);
expect(el.activeRegion).to.equal('NL');
// @ts-expect-error [allow protected]
el._setActiveRegion('BE');
await el.updateComplete;
expect(getDropdownValue(/** @type {DropdownElement} */ (el.refs.dropdown.value))).to.equal(
'BE',
);
});
});
});
}

View file

@ -0,0 +1,52 @@
import { runInputTelSuite } from '@lion/input-tel/test-suites';
// @ts-ignore
import { ref, repeat, html } from '@lion/core';
import '@lion/select-rich/define';
import { LionInputTelDropdown } from '../src/LionInputTelDropdown.js';
import { runInputTelDropdownSuite } from '../test-suites/LionInputTelDropdown.suite.js';
/**
* @typedef {import('@lion/core').TemplateResult} TemplateResult
* @typedef {HTMLSelectElement|HTMLElement & {modelValue:string}} DropdownElement
* @typedef {import('../types').TemplateDataForDropdownInputTel} TemplateDataForDropdownInputTel
* @typedef {import('../types').RegionMeta} RegionMeta
*/
class WithFormControlInputTelDropdown extends LionInputTelDropdown {
static templates = {
...(super.templates || {}),
/**
* @param {TemplateDataForDropdownInputTel} templateDataForDropdown
*/
dropdown: templateDataForDropdown => {
const { refs, data } = templateDataForDropdown;
// TODO: once spread directive available, use it per ref (like ref(refs?.dropdown?.ref))
return html`
<lion-select-rich
${ref(refs?.dropdown?.ref)}
label="${refs?.dropdown?.labels?.country}"
label-sr-only
@model-value-changed="${refs?.dropdown?.listeners['model-value-changed']}"
style="${refs?.dropdown?.props?.style}"
>
${repeat(
data.regionMetaList,
regionMeta => regionMeta.regionCode,
regionMeta =>
html` <lion-option .choiceValue="${regionMeta.regionCode}"> </lion-option> `,
)}
</lion-select-rich>
`;
},
};
}
// @ts-expect-error
runInputTelSuite({ klass: LionInputTelDropdown });
runInputTelDropdownSuite();
describe('WithFormControlInputTelDropdown', () => {
// @ts-expect-error
// Runs it for LionSelectRich, which uses .modelValue/@model-value-changed instead of .value/@change
runInputTelDropdownSuite({ klass: WithFormControlInputTelDropdown });
});

View file

@ -0,0 +1,44 @@
import { RegionCode } from '@lion/input-tel/types/types';
import { LionSelectRich } from '@lion/select-rich';
import { LionCombobox } from '@lion/combobox';
type RefTemplateData = {
ref?: { value?: HTMLElement };
props?: { [key: string]: any };
listeners?: { [key: string]: any };
labels?: { [key: string]: any };
};
export type RegionMeta = {
countryCode: number;
regionCode: RegionCode;
nameForRegion: string;
nameForLocale: string;
flagSymbol: string;
};
export type OnDropdownChangeEvent = Event & {
target: { value?: string; modelValue?: string; _overlayCtrl?: OverlayController };
detail?: { initialize: boolean };
};
export type DropdownRef = { value: HTMLSelectElement | LionSelectRich | LionCombobox | undefined };
export type TemplateDataForDropdownInputTel = {
refs: {
dropdown: RefTemplateData & {
ref: DropdownRef;
props: { style: string };
listeners: {
change: (event: OnDropdownChangeEvent) => void;
'model-value-changed': (event: OnDropdownChangeEvent) => void;
};
labels: { selectCountry: string };
};
};
data: {
activeRegion: string | undefined;
regionMetaList: RegionMeta[];
regionMetaListPreferred: RegionMeta[];
};
};

View file

@ -0,0 +1,3 @@
# Lion Input Tel
[=> See Source <=](../../docs/components/inputs/input-tel/overview.md)

View file

@ -0,0 +1 @@
import './lion-input-tel.js';

View file

@ -0,0 +1,3 @@
# Lion Input Tel Features
[=> See Source <=](../../../docs/components/inputs/input-tel/features.md)

View file

@ -0,0 +1,3 @@
# Lion Input Tel Overview
[=> See Source <=](../../../docs/components/inputs/input-tel/overview.md)

View file

@ -0,0 +1,3 @@
export { LionInputTel } from './src/LionInputTel.js';
export { PhoneUtilManager } from './src/PhoneUtilManager.js';
export { liveFormatPhoneNumber } from './src/preprocessors.js';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
import { LionInputTel } from './src/LionInputTel.js';
customElements.define('lion-input-tel', LionInputTel);

View file

@ -0,0 +1,62 @@
{
"name": "@lion/input-tel",
"version": "0.0.0",
"description": "Input field for entering phone numbers, including validation, formatting and mobile keyboard support.",
"license": "MIT",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/input-tel"
},
"main": "index.js",
"module": "index.js",
"files": [
"*.d.ts",
"*.js",
"custom-elements.json",
"docs",
"src",
"test",
"test-helpers",
"test-suites",
"translations",
"types"
],
"scripts": {
"custom-elements-manifest": "custom-elements-manifest analyze --litelement --exclude \"docs/**/*\" \"test-helpers/**/*\"",
"debug": "cd ../../ && npm run debug -- --group input-tel",
"debug:firefox": "cd ../../ && npm run debug:firefox -- --group input-tel",
"debug:webkit": "cd ../../ && npm run debug:webkit -- --group input-tel",
"publish-docs": "node ../../packages-node/publish-docs/src/cli.js --github-url https://github.com/ing-bank/lion/ --git-root-dir ../../",
"prepublishOnly": "npm run publish-docs && npm run custom-elements-manifest",
"test": "cd ../../ && npm run test:browser -- --group input-tel"
},
"sideEffects": [
"lion-input-tel.js"
],
"dependencies": {
"@lion/core": "0.21.1",
"@lion/form-core": "0.16.0",
"@lion/input": "0.16.0",
"@lion/localize": "0.23.0"
},
"keywords": [
"input",
"input-tel",
"lion",
"web-components"
],
"publishConfig": {
"access": "public"
},
"customElements": "custom-elements.json",
"exports": {
".": "./index.js",
"./define": "./define.js",
"./test-suites": "./test-suites/index.js",
"./test-helpers": "./test-helpers/index.js",
"./docs/*": "./docs/*"
}
}

View file

@ -0,0 +1,317 @@
import { Unparseable } from '@lion/form-core';
import { LocalizeMixin, localize } from '@lion/localize';
import { LionInput } from '@lion/input';
import { PhoneUtilManager } from './PhoneUtilManager.js';
import { liveFormatPhoneNumber } from './preprocessors.js';
import { formatPhoneNumber } from './formatters.js';
import { parsePhoneNumber } from './parsers.js';
import { IsPhoneNumber } from './validators.js';
/**
* @typedef {import('../types').FormatStrategy} FormatStrategy
* @typedef {import('../types').RegionCode} RegionCode
* @typedef {import('../types').PhoneNumberType} PhoneNumberType
* @typedef {import('@lion/form-core/types/FormatMixinTypes').FormatOptions} FormatOptions
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
* @typedef {FormatOptions & {regionCode: RegionCode; formatStrategy: FormatStrategy}} FormatOptionsTel
*/
export class LionInputTel extends LocalizeMixin(LionInput) {
/**
* @configure LitElement
*/
static properties = {
allowedRegions: { type: Array },
formatStrategy: { type: String, attribute: 'format-strategy' },
activeRegion: { type: String },
_phoneUtil: { type: Object, state: true },
_needsLightDomRender: { type: Number, state: true },
_derivedRegionCode: { type: String, state: true },
};
/**
* Currently active region based on:
* 1. allowed regions: get the region from configured allowed regions (if one entry)
* 2. user input: try to derive active region from user input
* 3. locale: try to get the region from locale (`html[lang]` attribute)
* @readonly
* @property {RegionCode|undefined}activeRegion
*/
get activeRegion() {
return this.__activeRegion;
}
// @ts-ignore read only
// eslint-disable-next-line class-methods-use-this, no-empty-function
set activeRegion(v) {}
/**
* Type of phone number, derived from textbox value. Enum with values:
* -'fixed-line'
* -'fixed-line-or-mobile'
* -'mobile'
* -'pager'
* -'personal-number'
* -'premium-rate'
* -'shared-cost'
* -'toll-free'
* -'uan'
* -'voip'
* -'unknown'
* See https://www.npmjs.com/package/awesome-phonenumber
* @readonly
* @property {PhoneNumberType|undefined} activePhoneNumberType
*/
get activePhoneNumberType() {
let pn;
try {
pn = this._phoneUtil && this._phoneUtil(this.modelValue, this.activeRegion);
// eslint-disable-next-line no-empty
} catch (_) {}
return pn?.g?.type || 'unknown';
}
// @ts-ignore read only
// eslint-disable-next-line class-methods-use-this, no-empty-function
set activePhoneNumberType(v) {}
/**
* Protected setter for activeRegion, only meant for subclassers
* @protected
* @param {RegionCode|undefined} newValue
*/
_setActiveRegion(newValue) {
const oldValue = this.activeRegion;
this.__activeRegion = newValue;
this.requestUpdate('activeRegion', oldValue);
}
/**
* Used for rendering the region/country list
* @property _allowedOrAllRegions
* @type {RegionCode[]}
*/
get _allowedOrAllRegions() {
return (
(this.allowedRegions?.length
? this.allowedRegions
: this._phoneUtil?.getSupportedRegionCodes()) || []
);
}
/**
* @property _phoneUtilLoadComplete
* @protected
* @type {Promise<PhoneNumber>}
*/
// eslint-disable-next-line class-methods-use-this
get _phoneUtilLoadComplete() {
return PhoneUtilManager.loadComplete;
}
/**
* @lifecycle platform
*/
constructor() {
super();
/**
* Determines what the formatter output should look like.
* Formatting strategies as provided by google-libphonenumber
* See: https://www.npmjs.com/package/google-libphonenumber
* @type {FormatStrategy}
*/
this.formatStrategy = 'international';
/**
* The regions that should be considered when international phone numbers are detected.
* (when not configured, all regions worldwide will be considered)
* @type {RegionCode[]}
*/
this.allowedRegions = [];
/** @private */
this.__isPhoneNumberValidatorInstance = new IsPhoneNumber();
/** @configures ValidateMixin */
this.defaultValidators.push(this.__isPhoneNumberValidatorInstance);
// Expose awesome-phonenumber lib for Subclassers
/**
* @protected
* @type {PhoneNumber|null}
*/
this._phoneUtil = PhoneUtilManager.isLoaded
? /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber)
: null;
/**
* Helper that triggers a light dom render aligned with update loop.
* TODO: combine with render fn of SlotMixin
* @protected
* @type {number}
*/
this._needsLightDomRender = 0;
if (!PhoneUtilManager.isLoaded) {
PhoneUtilManager.loadComplete.then(() => {
this._onPhoneNumberUtilReady();
});
}
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
// This will trigger the right keyboard on mobile
this._inputNode.inputMode = 'tel';
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('activeRegion')) {
// Make sure new modelValue is computed, but prevent formattedValue from being set when focused
this.__isUpdatingRegionWhileFocused = this.focused;
this._calculateValues({ source: null });
this.__isUpdatingRegionWhileFocused = false;
this.__isPhoneNumberValidatorInstance.param = this.activeRegion;
/** @type {FormatOptionsTel} */
(this.formatOptions).regionCode = /** @type {RegionCode} */ (this.activeRegion);
}
if (changedProperties.has('formatStrategy')) {
this._calculateValues({ source: null });
/** @type {FormatOptionsTel} */
(this.formatOptions).formatStrategy = this.formatStrategy;
}
if (changedProperties.has('modelValue') || changedProperties.has('allowedRegions')) {
this.__calculateActiveRegion();
}
}
/**
* @configure LocalizeMixin
*/
onLocaleUpdated() {
super.onLocaleUpdated();
const localeSplitted = localize.locale.split('-');
/**
* @protected
* @type {RegionCode}
*/
this._langIso = /** @type {RegionCode} */ (
localeSplitted[localeSplitted.length - 1].toUpperCase()
);
this.__calculateActiveRegion();
}
/**
* @configure FormatMixin
* @param {string} modelValue
* @returns {string}
*/
formatter(modelValue) {
return formatPhoneNumber(modelValue, {
regionCode: /** @type {RegionCode} */ (this.activeRegion),
formatStrategy: this.formatStrategy,
});
}
/**
* @configure FormatMixin
* @param {string} viewValue a phone number without (or with) country code, like '06 12345678'
* @returns {string} a trimmed phone number with country code, like '+31612345678'
*/
parser(viewValue) {
return parsePhoneNumber(viewValue, {
regionCode: /** @type {RegionCode} */ (this.activeRegion),
});
}
/**
* @configure FormatMixin
* @param {string} viewValue
* @param {object} options
* @param {string} options.prevViewValue
* @param {number} options.currentCaretIndex
* @returns {{ viewValue: string; caretIndex: number; } | undefined }
*/
preprocessor(viewValue, { currentCaretIndex, prevViewValue }) {
return liveFormatPhoneNumber(viewValue, {
regionCode: /** @type {RegionCode} */ (this.activeRegion),
formatStrategy: this.formatStrategy,
currentCaretIndex,
prevViewValue,
});
}
/**
* Do not reflect back .formattedValue during typing (this normally wouldn't happen when
* FormatMixin calls _calculateValues based on user input, but for LionInputTel we need to
* call it on .activeRegion change)
* @enhance FormatMixin
* @returns {boolean}
*/
_reflectBackOn() {
return !this.__isUpdatingRegionWhileFocused && super._reflectBackOn();
}
/**
* @protected
*/
_onPhoneNumberUtilReady() {
// This should trigger a rerender in shadow dom
this._phoneUtil = /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber);
// This should trigger a rerender in light dom
this._scheduleLightDomRender();
// Format when libPhoneNumber is loaded
this._calculateValues({ source: null });
this.__calculateActiveRegion();
}
/**
* This allows to hook into the update hook
* @protected
*/
_scheduleLightDomRender() {
this._needsLightDomRender += 1;
}
/**
* @private
*/
__calculateActiveRegion() {
// 1. Get the region from preconfigured allowed region (if one entry)
if (this.allowedRegions?.length === 1) {
this._setActiveRegion(this.allowedRegions[0]);
return;
}
// 2. Try to derive action region from user value
const value = !(this.modelValue instanceof Unparseable) ? this.modelValue : this.value;
const regionDerivedFromValue = value && this._phoneUtil && this._phoneUtil(value).g?.regionCode;
if (regionDerivedFromValue && this._allowedOrAllRegions.includes(regionDerivedFromValue)) {
this._setActiveRegion(regionDerivedFromValue);
return;
}
// 3. Try to get the region from locale
if (this._langIso && this._allowedOrAllRegions.includes(this._langIso)) {
this._setActiveRegion(this._langIso);
return;
}
// 4. Not derivable
this._setActiveRegion(undefined);
}
}

View file

@ -0,0 +1,38 @@
/** @type {(value: any) => void} */
let resolveLoaded;
/**
* - Handles lazy loading of the (relatively large) google-libphonenumber library, allowing
* for quick first paints
* - Maintains one instance of phoneNumberUtil that can be shared across multiple places
* - Allows for easy mocking in unit tests
*/
export class PhoneUtilManager {
static async loadLibPhoneNumber() {
const PhoneNumber = (await import('../lib/awesome-phonenumber-esm.js')).default;
this.PhoneNumber = PhoneNumber;
resolveLoaded(undefined);
return PhoneNumber;
}
/**
* Check if google-libphonenumber has been loaded
*/
static get isLoaded() {
return Boolean(this.PhoneNumber);
}
}
/**
* Wait till google-libphonenumber has been loaded
* @example
* ```js
* await PhoneUtilManager.loadComplete;
* ```
*/
PhoneUtilManager.loadComplete = new Promise(resolve => {
resolveLoaded = resolve;
});
// initialize
PhoneUtilManager.loadLibPhoneNumber();

View file

@ -0,0 +1,57 @@
import { PhoneUtilManager } from './PhoneUtilManager.js';
/**
* @typedef {import('../types').FormatStrategy} FormatStrategy
* @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
*/
/**
* @param {string} modelValue
* @param {object} options
* @param {RegionCode} options.regionCode
* @param {FormatStrategy} [options.formatStrategy='international']
* @returns {string}
*/
export function formatPhoneNumber(modelValue, { regionCode, formatStrategy = 'international' }) {
// Do not format when not loaded
if (!PhoneUtilManager.isLoaded) {
return modelValue;
}
// eslint-disable-next-line prefer-destructuring
const PhoneNumber = /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber);
let pn;
try {
pn = new PhoneNumber(modelValue, regionCode); // phoneNumberUtil.parse(modelValue, regionCode);
// eslint-disable-next-line no-empty
} catch (_) {}
if (modelValue?.length >= 4 && modelValue?.length <= 16 && pn?.isValid()) {
let formattedValue;
switch (formatStrategy) {
case 'e164':
formattedValue = pn.getNumber('e164'); // -> '+46707123456' (default)
break;
case 'international':
formattedValue = pn.getNumber('international'); // -> '+46 70 712 34 56'
break;
case 'national':
formattedValue = pn.getNumber('national'); // -> '070-712 34 56'
break;
case 'rfc3966':
formattedValue = pn.getNumber('rfc3966'); // -> 'tel:+46-70-712-34-56'
break;
case 'significant':
formattedValue = pn.getNumber('significant'); // -> '707123456'
break;
default:
break;
}
return formattedValue;
}
return modelValue;
}

View file

@ -0,0 +1,33 @@
import { PhoneUtilManager } from './PhoneUtilManager.js';
/**
* @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
*/
/**
* @param {string} viewValue
* @param {{regionCode:RegionCode;}} options
* @returns {string}
*/
export function parsePhoneNumber(viewValue, { regionCode }) {
// Do not format when not loaded
if (!PhoneUtilManager.isLoaded) {
return viewValue;
}
// eslint-disable-next-line prefer-destructuring
const PhoneNumber = /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber);
let pn;
try {
pn = PhoneNumber(viewValue, regionCode);
// eslint-disable-next-line no-empty
} catch (_) {}
if (pn) {
return pn.getNumber('e164');
}
return viewValue;
}

View file

@ -0,0 +1,51 @@
import { PhoneUtilManager } from './PhoneUtilManager.js';
import { formatPhoneNumber } from './formatters.js';
/**
* @typedef {import('../types').FormatStrategy} FormatStrategy
* @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
*/
/**
* @param {string} viewValue
* @param {object} options
* @param {RegionCode} options.regionCode
* @param {string} options.prevViewValue
* @param {number} options.currentCaretIndex
* @param {FormatStrategy} options.formatStrategy
* @returns {{viewValue:string; caretIndex:number;}|undefined}
*/
export function liveFormatPhoneNumber(
viewValue,
{ regionCode, formatStrategy, prevViewValue, currentCaretIndex },
) {
const diff = viewValue.length - prevViewValue.length;
// Do not format when not loaded
if (diff <= 0 || !PhoneUtilManager.isLoaded) {
return undefined;
}
// eslint-disable-next-line prefer-destructuring
const PhoneNumber = /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber);
const ayt = PhoneNumber.getAsYouType(regionCode);
for (const char of viewValue) {
if (char !== '') {
ayt.addChar(char);
}
}
const newViewValue = formatPhoneNumber(ayt.number(), { regionCode, formatStrategy });
/**
* Given following situation:
* - viewValue: `+316123`
* - currentCaretIndex: 2 (inbetween 3 and 1)
* - prevViewValue `+36123` (we inserted '1' at position 2)
* => we should get `+31 6123`, and new caretIndex should be 3, and not newViewValue.length
*/
const diffBetweenNewAndCurrent = newViewValue.length - viewValue.length;
const newCaretIndex = currentCaretIndex + diffBetweenNewAndCurrent;
return newViewValue ? { viewValue: newViewValue, caretIndex: newCaretIndex } : undefined;
}

View file

@ -0,0 +1,70 @@
import { Validator } from '@lion/form-core';
import { PhoneUtilManager } from './PhoneUtilManager.js';
/**
* @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
*/
/**
* @param {string} modelValue
* @param {RegionCode} regionCode
* @returns {false|'invalid-country-code'|'unknown'|'too-long'|'too-short'}
*/
function hasFeedback(modelValue, regionCode) {
// eslint-disable-next-line prefer-destructuring
const PhoneNumber = /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber);
let invalidCountryCode = false;
if (regionCode && modelValue?.length >= 4 && modelValue?.length <= 16) {
let pn;
try {
pn = PhoneNumber(modelValue, regionCode);
invalidCountryCode = pn.g.regionCode !== regionCode;
if (invalidCountryCode) {
return 'invalid-country-code';
}
// eslint-disable-next-line no-empty
} catch (_) {}
// too-short/too-long info seems to be not there (we get 'is-possible'?)
const enumValue = !pn.isValid() ? pn.g.possibility : false;
if (enumValue === 'is-possible') {
return 'unknown';
}
return enumValue;
}
return 'unknown';
}
export class IsPhoneNumber extends Validator {
static validatorName = 'IsPhoneNumber';
static get async() {
// Will be run as async the first time if PhoneUtilManager hasn't loaded yet, sync afterwards
return !PhoneUtilManager.isLoaded;
}
/**
* @param {string} modelValue telephone number without country prefix
* @param {RegionCode} regionCode
*/
// eslint-disable-next-line class-methods-use-this
execute(modelValue, regionCode) {
if (!PhoneUtilManager.isLoaded) {
// Return a Promise once not loaded yet. Since async Validators are meant for things like
// loading server side data (in this case a lib), we continue as a sync Validator once loaded
return new Promise(resolve => {
PhoneUtilManager.loadComplete.then(() => {
resolve(hasFeedback(modelValue, regionCode));
});
});
}
return hasFeedback(modelValue, regionCode);
}
// TODO: add a file for loadDefaultMessages
static async getMessage() {
return 'Not a valid phone number';
}
}

View file

@ -0,0 +1 @@
export { mockPhoneUtilManager, restorePhoneUtilManager } from './mockPhoneUtilManager.js';

View file

@ -0,0 +1,25 @@
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
const originalLoadComplete = PhoneUtilManager.loadComplete;
const originalIsLoaded = PhoneUtilManager.isLoaded;
export function mockPhoneUtilManager() {
/** @type {(value: any) => void} */
let resolveLoaded;
let isLoaded = false;
PhoneUtilManager.loadComplete = new Promise(resolve => {
resolveLoaded = () => {
isLoaded = true;
resolve(undefined);
};
});
Object.defineProperty(PhoneUtilManager, 'isLoaded', { get: () => isLoaded });
// @ts-ignore
return { resolveLoaded };
}
export function restorePhoneUtilManager() {
PhoneUtilManager.loadComplete = originalLoadComplete;
Object.defineProperty(PhoneUtilManager, 'isLoaded', { get: () => originalIsLoaded });
}

View file

@ -0,0 +1,384 @@
import {
expect,
fixture as _fixture,
fixtureSync as _fixtureSync,
html,
defineCE,
unsafeStatic,
aTimeout,
} from '@open-wc/testing';
import sinon from 'sinon';
import { mimicUserInput } from '@lion/form-core/test-helpers';
import { localize } from '@lion/localize';
import { LionInputTel } from '../src/LionInputTel.js';
import { IsPhoneNumber } from '../src/validators.js';
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
import {
mockPhoneUtilManager,
restorePhoneUtilManager,
} from '../test-helpers/mockPhoneUtilManager.js';
/**
* @typedef {import('@lion/core').TemplateResult} TemplateResult
* @typedef {import('../types').RegionCode} RegionCode
*/
const fixture = /** @type {(arg: string | TemplateResult) => Promise<LionInputTel>} */ (_fixture);
const fixtureSync = /** @type {(arg: string | TemplateResult) => LionInputTel} */ (_fixtureSync);
// const isPhoneNumberUtilLoadComplete = el => el._phoneUtilLoadComplete;
const getRegionCodeBasedOnLocale = () => {
const localeSplitted = localize.locale.split('-');
return /** @type {RegionCode} */ (localeSplitted[localeSplitted.length - 1]).toUpperCase();
};
/**
* @param {{ klass:LionInputTel }} config
*/
// @ts-ignore
export function runInputTelSuite({ klass = LionInputTel } = {}) {
// @ts-ignore
const tagName = defineCE(/** @type {* & HTMLElement} */ (class extends klass {}));
const tag = unsafeStatic(tagName);
describe('LionInputTel', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
describe('Region codes', () => {
describe('Readonly accessor `.activeRegion`', () => {
// 1. **allowed regions**: try to get the region from preconfigured allowed region (first entry)
it('takes .allowedRegions[0] when only one allowed region configured', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${['DE']}" .modelValue="${'+31612345678'}" ></${tag}> `,
);
await el.updateComplete;
expect(el.activeRegion).to.equal('DE');
});
it('returns undefined when multiple .allowedRegions, but no modelValue match', async () => {
// involve locale, so we are sure it does not fall back on locale
const currentCode = getRegionCodeBasedOnLocale();
const allowedRegions = ['BE', 'DE', 'CN'];
const el = await fixture(
html` <${tag} .modelValue="${'+31612345678'}" .allowedRegions="${allowedRegions.filter(
ar => ar !== currentCode,
)}"></${tag}> `,
);
expect(el.activeRegion).to.equal(undefined);
});
// 2. **user input**: try to derive active region from user input
it('deducts it from modelValue when provided', async () => {
const el = await fixture(html` <${tag} .modelValue="${'+31612345678'}"></${tag}> `);
// Region code for country code '31' is 'NL'
expect(el.activeRegion).to.equal('NL');
});
it('.modelValue takes precedence over .allowedRegions when both preconfigured and .modelValue updated', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${[
'DE',
'BE',
'NL',
]}" .modelValue="${'+31612345678'}" ></${tag}> `,
);
expect(el.activeRegion).to.equal('NL');
});
// 3. **locale**: try to get the region from locale (`html[lang]` attribute)
it('automatically bases it on current locale when nothing preconfigured', async () => {
const el = await fixture(html` <${tag}></${tag}> `);
const currentCode = getRegionCodeBasedOnLocale();
expect(el.activeRegion).to.equal(currentCode);
});
it('returns undefined when locale not within allowed regions', async () => {
const currentCode = getRegionCodeBasedOnLocale();
const allowedRegions = ['NL', 'BE', 'DE'];
const el = await fixture(
html` <${tag} .allowedRegions="${allowedRegions.filter(
ar => ar !== currentCode,
)}"></${tag}> `,
);
expect(el.activeRegion).to.equal(undefined);
});
});
it('can preconfigure the region code via prop', async () => {
const currentCode = getRegionCodeBasedOnLocale();
const newCode = currentCode === 'DE' ? 'NL' : 'DE';
const el = await fixture(html` <${tag} .allowedRegions="${[newCode]}"></${tag}> `);
expect(el.activeRegion).to.equal(newCode);
});
it.skip('reformats when region code is changed on the fly', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${['NL']}" .modelValue="${'+31612345678'}"></${tag}> `,
);
await el.updateComplete;
expect(el.formattedValue).to.equal('+31 6 12345678');
el.allowedRegions = ['NL'];
await el.updateComplete;
expect(el.formattedValue).to.equal('612345678');
});
});
describe('Readonly accessor `.activePhoneNumberType`', () => {
const types = [
{ type: 'fixed-line', number: '030 1234567', allowedRegions: ['NL'] },
{ type: 'mobile', number: '06 12345678', allowedRegions: ['NL'] },
// { type: 'fixed-line-or-mobile', number: '030 1234567' },
// { type: 'pager', number: '06 12345678' },
// { type: 'personal-number', number: '06 12345678' },
// { type: 'premium-rate', number: '06 12345678' },
// { type: 'shared-cost', : '06 12345678' },
// { type: 'toll-free', number: '06 12345678' },
// { type: 'uan', number: '06 12345678' },
// { type: 'voip', number: '06 12345678' },
// { type: 'unknown', number: '06 12345678' },
];
for (const { type, number, allowedRegions } of types) {
it(`returns "${type}" for ${type} numbers`, async () => {
const el = await fixture(html` <${tag} .allowedRegions="${allowedRegions}"></${tag}> `);
mimicUserInput(el, number);
await aTimeout(0);
expect(el.activePhoneNumberType).to.equal(type);
});
}
});
describe('User interaction', () => {
it('sets inputmode to "tel" for mobile keyboard', async () => {
const el = await fixture(html` <${tag}></${tag}> `);
// @ts-expect-error [allow-protected] inside tests
expect(el._inputNode.inputMode).to.equal('tel');
});
it('formats according to locale', async () => {
const el = await fixture(
html` <${tag} .modelValue="${'+31612345678'}" .allowedRegions="${['NL']}"></${tag}> `,
);
await aTimeout(0);
expect(el.formattedValue).to.equal('+31 6 12345678');
});
it('does not reflect back formattedValue after activeRegion change when input still focused', async () => {
const el = await fixture(html` <${tag} .modelValue="${'+639608920056'}"></${tag}> `);
expect(el.activeRegion).to.equal('PH');
el.focus();
mimicUserInput(el, '+31612345678');
await el.updateComplete;
await el.updateComplete;
expect(el.activeRegion).to.equal('NL');
expect(el.formattedValue).to.equal('+31 6 12345678');
expect(el.value).to.equal('+31612345678');
});
});
// https://www.npmjs.com/package/google-libphonenumber
// https://en.wikipedia.org/wiki/E.164
describe('Values', () => {
it('stores a modelValue in E164 format', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['NL']}"></${tag}> `);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.modelValue).to.equal('+31612345678');
});
it('stores a serializedValue in E164 format', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['NL']}"></${tag}> `);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.serializedValue).to.equal('+31612345678');
});
it('stores a formattedValue according to format strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="national" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('06 12345678');
});
describe('Format strategies', () => {
it('supports "national" strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="national" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('06 12345678');
});
it('supports "international" strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="international" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('+31 6 12345678');
});
it('supports "e164" strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="e164" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('+31612345678');
});
it('supports "rfc3966" strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="rfc3966" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('tel:+31-6-12345678');
});
it('supports "significant" strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="significant" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('612345678');
});
});
// TODO: this should be allowed for in FormatMixin =>
// in _onModelValueChanged we can add a hook '_checkModelValueFormat'. This needs to be
// called whenever .modelValue is supplied by devleloper (not when being internal result
// of parser call).
// Alternatively, we could be forgiving by attempting to treat it as a view value and
// correct the format (although strictness will be preferred...)
it.skip('does not allow modelValues in non E164 format', async () => {
const el = await fixture(
html` <${tag} .modelValue="${'612345678'}" .allowedRegions="${['NL']}"></${tag}> `,
);
expect(el.modelValue).to.equal(undefined);
});
});
describe('Validation', () => {
it('applies IsPhoneNumber as default validator', async () => {
const el = await fixture(html` <${tag}></${tag}> `);
expect(el.defaultValidators.find(v => v instanceof IsPhoneNumber)).to.be.not.undefined;
});
it('configures IsPhoneNumber with regionCode before first validation', async () => {
const el = fixtureSync(
html` <${tag} .allowedRegions="${['NL']}" .modelValue="${'612345678'}"></${tag}> `,
);
const spy = sinon.spy(el, 'validate');
const validatorInstance = /** @type {IsPhoneNumber} */ (
el.defaultValidators.find(v => v instanceof IsPhoneNumber)
);
await el.updateComplete;
expect(validatorInstance.param).to.equal('NL');
expect(spy).to.have.been.called;
spy.restore();
});
it('updates IsPhoneNumber param on regionCode change', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${['NL']}" .modelValue="${'612345678'}"></${tag}> `,
);
const validatorInstance = /** @type {IsPhoneNumber} */ (
el.defaultValidators.find(v => v instanceof IsPhoneNumber)
);
// @ts-expect-error allow protected in tests
el._setActiveRegion('DE');
await el.updateComplete;
expect(validatorInstance.param).to.equal('DE');
});
});
describe('User interaction', () => {
it('sets inputmode to "tel" for mobile keyboard', async () => {
const el = await fixture(html` <${tag}></${tag}> `);
// @ts-expect-error [allow-protected] inside tests
expect(el._inputNode.inputMode).to.equal('tel');
});
it('formats according to locale', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['NL']}"></${tag}> `);
await PhoneUtilManager.loadComplete;
await el.updateComplete;
el.modelValue = '612345678';
expect(el.formattedValue).to.equal('+31 6 12345678');
});
});
describe('Live format', () => {
it('calls .preprocessor on keyup', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['NL']}"></${tag}> `);
mimicUserInput(el, '+316');
await aTimeout(0);
expect(el.value).to.equal('+31 6');
});
});
describe('Accessibility', () => {
describe('Audit', () => {
it('passes a11y audit', async () => {
const el = await fixture(html`<${tag} label="tel" .modelValue=${'0123456789'}></${tag}>`);
await expect(el).to.be.accessible();
});
it('passes a11y audit when readonly', async () => {
const el = await fixture(
html`<${tag} label="tel" readonly .modelValue=${'0123456789'}></${tag}>`,
);
await expect(el).to.be.accessible();
});
it('passes a11y audit when disabled', async () => {
const el = await fixture(
html`<${tag} label="tel" disabled .modelValue=${'0123456789'}></${tag}>`,
);
await expect(el).to.be.accessible();
});
});
});
describe('Lazy loading awesome-phonenumber', () => {
/** @type {(value:any) => void} */
let resolveLoaded;
beforeEach(() => {
({ resolveLoaded } = mockPhoneUtilManager());
});
afterEach(() => {
restorePhoneUtilManager();
});
it('reformats once lib has been loaded', async () => {
const el = await fixture(
html` <${tag} .modelValue="${'612345678'}" .allowedRegions="${['NL']}"></${tag}> `,
);
expect(el.formattedValue).to.equal('612345678');
resolveLoaded(undefined);
await aTimeout(0);
expect(el.formattedValue).to.equal('+31 6 12345678');
});
it('validates once lib has been loaded', async () => {
const el = await fixture(
html` <${tag} .modelValue="${'+31612345678'}" .allowedRegions="${['DE']}"></${tag}> `,
);
expect(el.hasFeedbackFor).to.eql([]);
resolveLoaded(undefined);
await aTimeout(0);
expect(el.hasFeedbackFor).to.eql(['error']);
});
});
});
}

View file

@ -0,0 +1 @@
export { runInputTelSuite } from './LionInputTel.suite.js';

View file

@ -0,0 +1,3 @@
import { runInputTelSuite } from '../test-suites/LionInputTel.suite.js';
runInputTelSuite();

View file

@ -0,0 +1,28 @@
import { expect } from '@open-wc/testing';
import { formatPhoneNumber } from '../src/formatters.js';
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
describe('formatPhoneNumber', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
it('formats a phone number according to provided formatStrategy', () => {
expect(formatPhoneNumber('0707123456', { regionCode: 'SE', formatStrategy: 'e164' })).to.equal(
'+46707123456',
);
expect(
formatPhoneNumber('+46707123456', { regionCode: 'SE', formatStrategy: 'international' }),
).to.equal('+46 70 712 34 56');
expect(
formatPhoneNumber('+46707123456', { regionCode: 'SE', formatStrategy: 'national' }),
).to.equal('070-712 34 56');
expect(
formatPhoneNumber('+46707123456', { regionCode: 'SE', formatStrategy: 'rfc3966' }),
).to.equal('tel:+46-70-712-34-56');
expect(
formatPhoneNumber('+46707123456', { regionCode: 'SE', formatStrategy: 'significant' }),
).to.equal('707123456');
});
});

View file

@ -0,0 +1,16 @@
import { expect } from '@open-wc/testing';
import { parsePhoneNumber } from '../src/parsers.js';
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
describe('parsePhoneNumber', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
it('parses a a view value to e164 standard', () => {
expect(parsePhoneNumber('0707123456', { regionCode: 'SE' })).to.equal('+46707123456');
expect(parsePhoneNumber('0707123456', { regionCode: 'NL' })).to.equal('+31707123456');
expect(parsePhoneNumber('0707123456', { regionCode: 'DE' })).to.equal('+49707123456');
});
});

View file

@ -0,0 +1,32 @@
import { expect } from '@open-wc/testing';
import { liveFormatPhoneNumber } from '../src/preprocessors.js';
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
describe('liveFormatPhoneNumber', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
it('live formats an incomplete view value', () => {
expect(
liveFormatPhoneNumber('+316123', {
regionCode: 'NL',
formatStrategy: 'international',
prevViewValue: '+36123',
currentCaretIndex: 2,
}),
).to.eql({ viewValue: '+31 6 123', caretIndex: 4 });
});
it('live formats a complete view value', () => {
expect(
liveFormatPhoneNumber('+31612345678', {
regionCode: 'NL',
formatStrategy: 'international',
prevViewValue: '+3161234578',
currentCaretIndex: 10,
}),
).to.eql({ caretIndex: 12, viewValue: '+31 6 12345678' });
});
});

View file

@ -0,0 +1,94 @@
import sinon from 'sinon';
import { expect, aTimeout } from '@open-wc/testing';
import { IsPhoneNumber } from '../src/validators.js';
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
import {
mockPhoneUtilManager,
restorePhoneUtilManager,
} from '../test-helpers/mockPhoneUtilManager.js';
/**
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
*/
// For enum output, see: https://www.npmjs.com/package/awesome-phonenumber
describe('IsPhoneNumber validation', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
it('is invalid when no input is provided', () => {
const validator = new IsPhoneNumber();
expect(validator.execute('', 'NL')).to.equal('unknown');
});
it('is invalid when non digits are entered, returns "unknown"', () => {
const validator = new IsPhoneNumber();
expect(validator.execute('foo', 'NL')).to.equal('unknown');
});
it('is invalid when wrong country code is entered, returns "invalid-country-code"', () => {
const validator = new IsPhoneNumber();
// 32 is BE region code
expect(validator.execute('+32612345678', 'NL')).to.equal('invalid-country-code');
});
// TODO: find out why awesome-phonenumber does not detect too-short/too-long
it.skip('is invalid when number is too short, returns "too-short"', () => {
const validator = new IsPhoneNumber();
expect(validator.execute('+3161234567', 'NL')).to.equal('too-short');
});
// TODO: find out why awesome-phonenumber does not detect too-short/too-long
it.skip('is invalid when number is too long, returns "too-long"', () => {
const validator = new IsPhoneNumber();
expect(validator.execute('+316123456789', 'NL')).to.equal('too-long');
});
it('is valid when a phone number is entered', () => {
const validator = new IsPhoneNumber();
expect(validator.execute('+31612345678', 'NL')).to.be.false;
});
it('handles validation via awesome-phonenumber', () => {
const validator = new IsPhoneNumber();
const spy = sinon.spy(PhoneUtilManager, 'PhoneNumber');
validator.execute('0123456789', 'NL');
expect(spy).to.have.been.calledOnce;
expect(spy.lastCall.args[1]).to.equal('NL');
validator.execute('0123456789', 'DE');
expect(spy.lastCall.args[1]).to.equal('DE');
spy.restore();
});
describe('Lazy loading PhoneUtilManager', () => {
/** @type {(value:any) => void} */
let resolveLoaded;
beforeEach(() => {
({ resolveLoaded } = mockPhoneUtilManager());
});
afterEach(() => {
restorePhoneUtilManager();
});
it('behaves asynchronously when lib is still loading', () => {
expect(IsPhoneNumber.async).to.be.true;
resolveLoaded(undefined);
expect(IsPhoneNumber.async).to.be.false;
});
it('waits for the lib to be loaded before execution completes when still in async mode', async () => {
const validator = new IsPhoneNumber();
const spy = sinon.spy(PhoneUtilManager, 'PhoneNumber');
const validationResult = validator.execute('061234', 'NL');
expect(validationResult).to.be.instanceOf(Promise);
expect(spy).to.not.have.been.called;
resolveLoaded(undefined);
await aTimeout(0);
expect(spy).to.have.been.calledOnce;
spy.restore();
});
});
});

View file

@ -0,0 +1,5 @@
import bg from './bg.js';
export default {
...bg,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Избор на държава',
phoneNumber: 'Телефонен номер',
};

View file

@ -0,0 +1,5 @@
import cs from './cs.js';
export default {
...cs,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Vybrat zemi',
phoneNumber: 'Telefonní číslo',
};

View file

@ -0,0 +1,5 @@
import de from './de.js';
export default {
...de,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Land auswählen',
phoneNumber: 'Telefonnummer',
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Select country',
phoneNumber: 'Phone number',
};

View file

@ -0,0 +1,5 @@
import es from './es.js';
export default {
...es,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Seleccione país',
phoneNumber: 'Número de teléfono',
};

View file

@ -0,0 +1,5 @@
import fr from './fr.js';
export default {
...fr,
};

View file

@ -0,0 +1,5 @@
import fr from './fr.js';
export default {
...fr,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Sélectionnez un pays',
phoneNumber: 'Numéro de téléphone',
};

View file

@ -0,0 +1,5 @@
import hu from './hu.js';
export default {
...hu,
};

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