Merge pull request #1593 from ing-bank/feat/input-tel
feat(input-tel): new component LionInputTel
This commit is contained in:
commit
dde778ec9d
123 changed files with 22217 additions and 342 deletions
5
.changeset/big-geese-run.md
Normal file
5
.changeset/big-geese-run.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/input-tel-dropdown': minor
|
||||
---
|
||||
|
||||
New component LionInpuTelDropdown
|
||||
5
.changeset/fifty-comics-attend.md
Normal file
5
.changeset/fifty-comics-attend.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/validate-messages': patch
|
||||
---
|
||||
|
||||
fix(validate-messages): typo IsData message
|
||||
5
.changeset/four-avocados-confess.md
Normal file
5
.changeset/four-avocados-confess.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/form-core': patch
|
||||
---
|
||||
|
||||
FormControl: allow a label-sr-only flag to provide visually hidden labels
|
||||
5
.changeset/green-ads-fold.md
Normal file
5
.changeset/green-ads-fold.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/form-core': patch
|
||||
---
|
||||
|
||||
form-core: expose 'mimicUserInput' test-helper
|
||||
5
.changeset/proud-geese-suffer.md
Normal file
5
.changeset/proud-geese-suffer.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/form-core': minor
|
||||
---
|
||||
|
||||
Validation: allow enums as outcome of a Validator
|
||||
5
.changeset/rare-panthers-crash.md
Normal file
5
.changeset/rare-panthers-crash.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/input-tel': minor
|
||||
---
|
||||
|
||||
New component "LionInputTel"
|
||||
|
|
@ -3,3 +3,4 @@ CHANGELOG.md
|
|||
bundlesize/
|
||||
_site
|
||||
_site-dev
|
||||
.history
|
||||
|
|
|
|||
28
docs/components/inputs/input-tel-dropdown/examples.md
Normal file
28
docs/components/inputs/input-tel-dropdown/examples.md
Normal 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>
|
||||
`;
|
||||
```
|
||||
72
docs/components/inputs/input-tel-dropdown/features.md
Normal file
72
docs/components/inputs/input-tel-dropdown/features.md
Normal 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>
|
||||
`;
|
||||
```
|
||||
3
docs/components/inputs/input-tel-dropdown/index.md
Normal file
3
docs/components/inputs/input-tel-dropdown/index.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Inputs >> Input Tel Dropdown ||20
|
||||
|
||||
-> go to Overview
|
||||
36
docs/components/inputs/input-tel-dropdown/overview.md
Normal file
36
docs/components/inputs/input-tel-dropdown/overview.md
Normal 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';
|
||||
```
|
||||
|
|
@ -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);
|
||||
254
docs/components/inputs/input-tel/features.md
Normal file
254
docs/components/inputs/input-tel/features.md
Normal 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>
|
||||
`;
|
||||
```
|
||||
3
docs/components/inputs/input-tel/index.md
Normal file
3
docs/components/inputs/input-tel/index.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Inputs >> Input Tel ||20
|
||||
|
||||
-> go to Overview
|
||||
59
docs/components/inputs/input-tel/overview.md
Normal file
59
docs/components/inputs/input-tel/overview.md
Normal 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';
|
||||
```
|
||||
107
docs/components/inputs/input-tel/src/h-region-code-table.js
Normal file
107
docs/components/inputs/input-tel/src/h-region-code-table.js
Normal 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);
|
||||
31
docs/components/inputs/select-rich/examples.md
Normal file
31
docs/components/inputs/select-rich/examples.md
Normal 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>
|
||||
`;
|
||||
```
|
||||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
1250
docs/components/inputs/select-rich/src/flagStyles.js
Normal file
1250
docs/components/inputs/select-rich/src/flagStyles.js
Normal file
File diff suppressed because it is too large
Load diff
BIN
docs/components/inputs/select-rich/src/flags.png
Normal file
BIN
docs/components/inputs/select-rich/src/flags.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/components/inputs/select-rich/src/flags@2x.png
Normal file
BIN
docs/components/inputs/select-rich/src/flags@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
193
docs/components/inputs/select-rich/src/intl-select-rich.js
Normal file
193
docs/components/inputs/select-rich/src/intl-select-rich.js
Normal 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);
|
||||
1717
docs/components/inputs/select-rich/src/regionMetaList.js
Normal file
1717
docs/components/inputs/select-rich/src/regionMetaList.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*********************/
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
60
packages/form-core/src/validate/types/index.d.ts
vendored
Normal file
60
packages/form-core/src/validate/types/index.d.ts
vendored
Normal 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;
|
||||
};
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './ExampleValidators.js';
|
||||
export * from './getFormControlMembers.js';
|
||||
export * from './mimicUserInput.js';
|
||||
|
|
|
|||
16
packages/form-core/test-helpers/mimicUserInput.js
Normal file
16
packages/form-core/test-helpers/mimicUserInput.js
Normal 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 }));
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export type ModelValueEventDetails = {
|
|||
|
||||
declare interface HTMLElementWithValue extends HTMLElement {
|
||||
value: string;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
}
|
||||
|
||||
export declare class FormControlHost {
|
||||
|
|
|
|||
25
packages/form-core/types/FormatMixinTypes.d.ts
vendored
25
packages/form-core/types/FormatMixinTypes.d.ts
vendored
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ describe(`Submitting/Resetting/Clearing Form`, async () => {
|
|||
},
|
||||
range: '',
|
||||
rsvp: '',
|
||||
tel: '',
|
||||
terms: [],
|
||||
comments: '',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
3
packages/input-tel-dropdown/README.md
Normal file
3
packages/input-tel-dropdown/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Lion Input Tel
|
||||
|
||||
[=> See Source <=](../../docs/components/inputs/input-tel/overview.md)
|
||||
1
packages/input-tel-dropdown/define.js
Normal file
1
packages/input-tel-dropdown/define.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import './lion-input-tel-dropdown.js';
|
||||
3
packages/input-tel-dropdown/docs/features.md
Normal file
3
packages/input-tel-dropdown/docs/features.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Lion Input Tel Dropdown Features
|
||||
|
||||
[=> See Source <=](../../../docs/components/inputs/input-tel-dropdown/features.md)
|
||||
3
packages/input-tel-dropdown/docs/overview.md
Normal file
3
packages/input-tel-dropdown/docs/overview.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Lion Input Tel Dropdown Overview
|
||||
|
||||
[=> See Source <=](../../../docs/components/inputs/input-tel-dropdown/overview.md)
|
||||
1
packages/input-tel-dropdown/index.js
Normal file
1
packages/input-tel-dropdown/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { LionInputTelDropdown } from './src/LionInputTelDropdown.js';
|
||||
3
packages/input-tel-dropdown/lion-input-tel-dropdown.js
Normal file
3
packages/input-tel-dropdown/lion-input-tel-dropdown.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { LionInputTelDropdown } from './src/LionInputTelDropdown.js';
|
||||
|
||||
customElements.define('lion-input-tel-dropdown', LionInputTelDropdown);
|
||||
57
packages/input-tel-dropdown/package.json
Normal file
57
packages/input-tel-dropdown/package.json
Normal 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/*"
|
||||
}
|
||||
}
|
||||
375
packages/input-tel-dropdown/src/LionInputTelDropdown.js
Normal file
375
packages/input-tel-dropdown/src/LionInputTelDropdown.js
Normal 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}) ${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),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
44
packages/input-tel-dropdown/types/index.d.ts
vendored
Normal file
44
packages/input-tel-dropdown/types/index.d.ts
vendored
Normal 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[];
|
||||
};
|
||||
};
|
||||
3
packages/input-tel/README.md
Normal file
3
packages/input-tel/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Lion Input Tel
|
||||
|
||||
[=> See Source <=](../../docs/components/inputs/input-tel/overview.md)
|
||||
1
packages/input-tel/define.js
Normal file
1
packages/input-tel/define.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import './lion-input-tel.js';
|
||||
3
packages/input-tel/docs/features.md
Normal file
3
packages/input-tel/docs/features.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Lion Input Tel Features
|
||||
|
||||
[=> See Source <=](../../../docs/components/inputs/input-tel/features.md)
|
||||
3
packages/input-tel/docs/overview.md
Normal file
3
packages/input-tel/docs/overview.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Lion Input Tel Overview
|
||||
|
||||
[=> See Source <=](../../../docs/components/inputs/input-tel/overview.md)
|
||||
3
packages/input-tel/index.js
Normal file
3
packages/input-tel/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { LionInputTel } from './src/LionInputTel.js';
|
||||
export { PhoneUtilManager } from './src/PhoneUtilManager.js';
|
||||
export { liveFormatPhoneNumber } from './src/preprocessors.js';
|
||||
14999
packages/input-tel/lib/awesome-phonenumber-esm.js
Normal file
14999
packages/input-tel/lib/awesome-phonenumber-esm.js
Normal file
File diff suppressed because it is too large
Load diff
3
packages/input-tel/lion-input-tel.js
Normal file
3
packages/input-tel/lion-input-tel.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { LionInputTel } from './src/LionInputTel.js';
|
||||
|
||||
customElements.define('lion-input-tel', LionInputTel);
|
||||
62
packages/input-tel/package.json
Normal file
62
packages/input-tel/package.json
Normal 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/*"
|
||||
}
|
||||
}
|
||||
317
packages/input-tel/src/LionInputTel.js
Normal file
317
packages/input-tel/src/LionInputTel.js
Normal 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);
|
||||
}
|
||||
}
|
||||
38
packages/input-tel/src/PhoneUtilManager.js
Normal file
38
packages/input-tel/src/PhoneUtilManager.js
Normal 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();
|
||||
57
packages/input-tel/src/formatters.js
Normal file
57
packages/input-tel/src/formatters.js
Normal 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;
|
||||
}
|
||||
33
packages/input-tel/src/parsers.js
Normal file
33
packages/input-tel/src/parsers.js
Normal 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;
|
||||
}
|
||||
51
packages/input-tel/src/preprocessors.js
Normal file
51
packages/input-tel/src/preprocessors.js
Normal 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;
|
||||
}
|
||||
70
packages/input-tel/src/validators.js
Normal file
70
packages/input-tel/src/validators.js
Normal 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';
|
||||
}
|
||||
}
|
||||
1
packages/input-tel/test-helpers/index.js
Normal file
1
packages/input-tel/test-helpers/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { mockPhoneUtilManager, restorePhoneUtilManager } from './mockPhoneUtilManager.js';
|
||||
25
packages/input-tel/test-helpers/mockPhoneUtilManager.js
Normal file
25
packages/input-tel/test-helpers/mockPhoneUtilManager.js
Normal 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 });
|
||||
}
|
||||
384
packages/input-tel/test-suites/LionInputTel.suite.js
Normal file
384
packages/input-tel/test-suites/LionInputTel.suite.js
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
1
packages/input-tel/test-suites/index.js
Normal file
1
packages/input-tel/test-suites/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { runInputTelSuite } from './LionInputTel.suite.js';
|
||||
3
packages/input-tel/test/LionInputTel.test.js
Normal file
3
packages/input-tel/test/LionInputTel.test.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { runInputTelSuite } from '../test-suites/LionInputTel.suite.js';
|
||||
|
||||
runInputTelSuite();
|
||||
28
packages/input-tel/test/formatters.test.js
Normal file
28
packages/input-tel/test/formatters.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
16
packages/input-tel/test/parsers.test.js
Normal file
16
packages/input-tel/test/parsers.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
32
packages/input-tel/test/preprocessors.test.js
Normal file
32
packages/input-tel/test/preprocessors.test.js
Normal 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' });
|
||||
});
|
||||
});
|
||||
94
packages/input-tel/test/validators.test.js
Normal file
94
packages/input-tel/test/validators.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
5
packages/input-tel/translations/bg-BG.js
Normal file
5
packages/input-tel/translations/bg-BG.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import bg from './bg.js';
|
||||
|
||||
export default {
|
||||
...bg,
|
||||
};
|
||||
4
packages/input-tel/translations/bg.js
Normal file
4
packages/input-tel/translations/bg.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
selectCountry: 'Избор на държава',
|
||||
phoneNumber: 'Телефонен номер',
|
||||
};
|
||||
5
packages/input-tel/translations/cs-CZ.js
Normal file
5
packages/input-tel/translations/cs-CZ.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import cs from './cs.js';
|
||||
|
||||
export default {
|
||||
...cs,
|
||||
};
|
||||
4
packages/input-tel/translations/cs.js
Normal file
4
packages/input-tel/translations/cs.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
selectCountry: 'Vybrat zemi',
|
||||
phoneNumber: 'Telefonní číslo',
|
||||
};
|
||||
5
packages/input-tel/translations/de-DE.js
Normal file
5
packages/input-tel/translations/de-DE.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import de from './de.js';
|
||||
|
||||
export default {
|
||||
...de,
|
||||
};
|
||||
4
packages/input-tel/translations/de.js
Normal file
4
packages/input-tel/translations/de.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
selectCountry: 'Land auswählen',
|
||||
phoneNumber: 'Telefonnummer',
|
||||
};
|
||||
5
packages/input-tel/translations/en-AU.js
Normal file
5
packages/input-tel/translations/en-AU.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import en from './en.js';
|
||||
|
||||
export default {
|
||||
...en,
|
||||
};
|
||||
5
packages/input-tel/translations/en-GB.js
Normal file
5
packages/input-tel/translations/en-GB.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import en from './en.js';
|
||||
|
||||
export default {
|
||||
...en,
|
||||
};
|
||||
5
packages/input-tel/translations/en-US.js
Normal file
5
packages/input-tel/translations/en-US.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import en from './en.js';
|
||||
|
||||
export default {
|
||||
...en,
|
||||
};
|
||||
4
packages/input-tel/translations/en.js
Normal file
4
packages/input-tel/translations/en.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
selectCountry: 'Select country',
|
||||
phoneNumber: 'Phone number',
|
||||
};
|
||||
5
packages/input-tel/translations/es-ES.js
Normal file
5
packages/input-tel/translations/es-ES.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import es from './es.js';
|
||||
|
||||
export default {
|
||||
...es,
|
||||
};
|
||||
4
packages/input-tel/translations/es.js
Normal file
4
packages/input-tel/translations/es.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
selectCountry: 'Seleccione país',
|
||||
phoneNumber: 'Número de teléfono',
|
||||
};
|
||||
5
packages/input-tel/translations/fr-BE.js
Normal file
5
packages/input-tel/translations/fr-BE.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import fr from './fr.js';
|
||||
|
||||
export default {
|
||||
...fr,
|
||||
};
|
||||
5
packages/input-tel/translations/fr-FR.js
Normal file
5
packages/input-tel/translations/fr-FR.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import fr from './fr.js';
|
||||
|
||||
export default {
|
||||
...fr,
|
||||
};
|
||||
4
packages/input-tel/translations/fr.js
Normal file
4
packages/input-tel/translations/fr.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
selectCountry: 'Sélectionnez un pays',
|
||||
phoneNumber: 'Numéro de téléphone',
|
||||
};
|
||||
5
packages/input-tel/translations/hu-HU.js
Normal file
5
packages/input-tel/translations/hu-HU.js
Normal 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
Loading…
Reference in a new issue