feat: release inital public lion version

Co-authored-by: Mikhail Bashkirov <mikhail.bashkirov@ing.com>
Co-authored-by: Thijs Louisse <thijs.louisse@ing.com>
Co-authored-by: Joren Broekema <joren.broekema@ing.com>
Co-authored-by: Gerjan van Geest <gerjan.van.geest@ing.com>
Co-authored-by: Erik Kroes <erik.kroes@ing.com>
Co-authored-by: Lars den Bakker <lars.den.bakker@ing.com>
This commit is contained in:
Thomas Allmer 2019-04-25 14:02:06 +02:00 committed by Thomas Allmer
parent 7c563b76bf
commit ec8da8f12c
415 changed files with 41839 additions and 3 deletions

14
.editorconfig Normal file
View file

@ -0,0 +1,14 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{html,js}]
block_comment_start = /**
block_comment = *
block_comment_end = */

1
.eslintignore Normal file
View file

@ -0,0 +1 @@
coverage/

3
.eslintrc.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
extends: ['@open-wc/eslint-config', 'eslint-config-prettier'].map(require.resolve),
};

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
## editors
/.idea
/.vscode
/*.code-workspace
/.history
## system files
.DS_Store
## npm
/node_modules/
/npm-debug.log
# we do prefer yarn.lock so we do not want npms version of it
/package-lock.json
## build artifacts
/lib/
/build/
## temp folders
/.tmp/
/coverage/

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
coverage/

14
.storybook/.babelrc Executable file
View file

@ -0,0 +1,14 @@
{
"plugins": [
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-proposal-object-rest-spread"
],
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "entry"
},
]
]
}

7
.storybook/addons.js Executable file
View file

@ -0,0 +1,7 @@
import '@storybook/addon-storysource/register';
import '@storybook/addon-actions/register';
import '@storybook/addon-backgrounds/register';
import '@storybook/addon-notes/register';
import '@storybook/addon-links/register';
import '@storybook/addon-viewport/register';
import '@storybook/addon-options/register';

13
.storybook/config.js Executable file
View file

@ -0,0 +1,13 @@
import { configure } from '@storybook/polymer';
import { setOptions } from '@storybook/addon-options';
setOptions({
hierarchyRootSeparator: /\|/,
});
const req = require.context('../stories', true, /\.stories\.js$/);
function loadStories() {
req.keys().forEach(filename => req(filename));
}
configure(loadStories, module);

View file

@ -0,0 +1,2 @@
<meta name="viewport"
content="width=device-width, minimum-scale=1, initial-scale=1, user-scalable=yes">

41
.storybook/webpack.config.js Executable file
View file

@ -0,0 +1,41 @@
const path = require('path');
module.exports = (storybookBaseConfig, configType, defaultConfig) => {
defaultConfig.module.rules.push({
test: [/\.stories\.js$/, /index\.js$/],
loaders: [require.resolve('@storybook/addon-storysource/loader')],
enforce: 'pre',
});
const transpilePackages = ['lit-html', 'lit-element', '@open-wc', 'autosize'];
// this is a separate config for only those packages
// the main storybook will use the .babelrc which is needed so storybook itself works in IE
defaultConfig.module.rules.push({
test: new RegExp(`node_modules(\\\/|\\\\)(${transpilePackages.join('|')})(.*)\\.js$`),
use: {
loader: 'babel-loader',
options: {
plugins: [
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-proposal-object-rest-spread',
],
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'entry',
},
],
],
babelrc: false,
},
},
});
defaultConfig.devServer = {
headers: { 'X-UA-Compatible': 'IE=Edge' },
};
return defaultConfig;
};

48
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,48 @@
# Contributing
Check out ways to contribute to Lion Web Components:
## Existing components: we love pull requests ♥
Help out the whole lion community by sending your merge requests and issues.
Check out how to set it up:
Setup:
```bash
# Clone the repo:
git clone https://github.com/ing-bank/lion.git
cd lion
# Install dependencies
yarn install
# Create a branch for your changes
git checkout -b fix/buttonSize
```
Make sure everything works as expected:
```bash
# Linting
npm run lint
# Tests
npm run test
# Storybook Demo
npm run storybook
```
Create a Pull Request:
- At https://github.com/ing-bank/lion click on fork (at the right top)
```bash
# add fork to your remotes
git remote add fork git@github.com:<your-user>/lion.git
# push new branch to your fork
git push -u fork fix/buttonSize
```
- Go to your fork and create a Pull Request :)
Some things that will increase the chance that your merge request is accepted:
* Write tests.
* Write a [good commit message](https://www.conventionalcommits.org/).

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 ING Bank NV Amsterdam
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

130
README.md
View file

@ -1,5 +1,129 @@
# Lion
> ## 🛠 Status: Pilot Phase
> Lion Web Components are still in an early alpha stage; they should not be considered production ready yet.
>
> The goal of our pilot phase is to gather feedback from a private group of users.
> Therefore, during this phase, we kindly ask you to:
> - not publicly promote or link us yet: (no tweets, blog posts or other forms of communication about Lion Web Components)
> - not publicly promote or link products derived from/based on Lion Web Components
>
> As soon as Pilot Phase ends we will let you know (feel free to subscribe to this issue https://github.com/ing-bank/lion/issues/1)
I'm afraid a little more patience is needed.
# Lion Web Components
See you soon :)
Lion web components is a set of highly performant, accessible and flexible Web Components.
They provide an unopinionated, white label layer that can be extended to your own layer of components.
## How to install
```bash
npm i @lion/<package-name>
```
## Content
| Package | Version | Description |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
| **-- [Forms](./docs/forms.md) --** | | |
| [checkbox](./packages/checkbox) | [![checkbox](https://img.shields.io/npm/v/@lion/checkbox.svg)](https://www.npmjs.com/package/@lion/checkbox) | Checkbox form element |
| [checkbox-group](./packages/checkbox-group) | [![checkbox-group](https://img.shields.io/npm/v/@lion/checkbox-group.svg)](https://www.npmjs.com/package/@lion/checkbox-group) | Group of checkboxes |
| [field](./packages/field) | [![field](https://img.shields.io/npm/v/@lion/field.svg)](https://www.npmjs.com/package/@lion/field) | Base Class for all inputs |
| [form](./packages/form) | [![form](https://img.shields.io/npm/v/@lion/form.svg)](https://www.npmjs.com/package/@lion/form) | Wrapper for multiple form elements |
| [input](./packages/input) | [![input](https://img.shields.io/npm/v/@lion/input.svg)](https://www.npmjs.com/package/@lion/input) | Input element for strings |
| [input-amount](./packages/input-amount) | [![input-amount](https://img.shields.io/npm/v/@lion/input-amount.svg)](https://www.npmjs.com/package/@lion/input-amount) | Input element for amounts |
| [input-date](./packages/input-date) | [![input-date](https://img.shields.io/npm/v/@lion/input-date.svg)](https://www.npmjs.com/package/@lion/input-date) | Input element for dates |
| [input-email](./packages/input-email) | [![input-email](https://img.shields.io/npm/v/@lion/input-email.svg)](https://www.npmjs.com/package/@lion/input-email) | Input element for e-mails |
| [input-iban](./packages/input-iban) | [![input-iban](https://img.shields.io/npm/v/@lion/input-iban.svg)](https://www.npmjs.com/package/@lion/input-iban) | Input element for IBANs |
| [radio](./packages/radio) | [![radio](https://img.shields.io/npm/v/@lion/radio.svg)](https://www.npmjs.com/package/@lion/radio) | Radio from element |
| [radio-group](./packages/radio-group) | [![radio-group](https://img.shields.io/npm/v/@lion/radio-group.svg)](https://www.npmjs.com/package/@lion/radio-group) | Group of radios |
| [select](./packages/select) | [![select](https://img.shields.io/npm/v/@lion/select.svg)](https://www.npmjs.com/package/@lion/select) | Simple native dropdown element |
| [textarea](./packages/textarea) | [![textarea](https://img.shields.io/npm/v/@lion/textarea.svg)](https://www.npmjs.com/package/@lion/textarea) | Multiline text input |
| [validate](./packages/validate) | [![validate](https://img.shields.io/npm/v/@lion/validate.svg)](https://www.npmjs.com/package/@lion/validate) | Validation for form components |
| **-- [Icons](./packages/icon) --** | | |
| [icon](./packages/icon) | [![icon](https://img.shields.io/npm/v/@lion/icon.svg)](https://www.npmjs.com/package/@lion/icon) | Display our svg icons |
| **-- [Localize](./packages/localize) --** | | |
| [localize](./packages/localize) | [![localize](https://img.shields.io/npm/v/@lion/localize.svg)](https://www.npmjs.com/package/@lion/localize) | Localize and translate your application/components |
| **-- [Overlays](./docs/overlays.md) --** | | |
| [overlays](./packages/overlays) | [![overlays](https://img.shields.io/npm/v/@lion/overlays.svg)](https://www.npmjs.com/package/@lion/overlays) | Overlays System using lit-html for rendering |
| [popup](./packages/popup) | [![popup](https://img.shields.io/npm/v/@lion/popup.svg)](https://www.npmjs.com/package/@lion/popup) | Popup element |
| [tooltip](./packages/tooltip) | [![tooltip](https://img.shields.io/npm/v/@lion/tooltip.svg)](https://www.npmjs.com/package/@lion/tooltip) | Popup element |
| **-- [Steps](./packages/steps) --** | | |
| [steps](./packages/steps) | [![steps](https://img.shields.io/npm/v/@lion/steps.svg)](https://www.npmjs.com/package/@lion/steps) | Multi Step System |
| **-- Individual Packages --** | | |
| [ajax](./packages/ajax) | [![ajax](https://img.shields.io/npm/v/@lion/ajax.svg)](https://www.npmjs.com/package/@lion/ajax) | Fetching data via ajax request |
| [button](./packages/button) | [![button](https://img.shields.io/npm/v/@lion/button.svg)](https://www.npmjs.com/package/@lion/button) | Button |
## How to use
### Use a Web Component
```html
<script type="module">
import '@lion/input/lion-input.js';
</script>
<lion-input name="firstName"></lion-input>
```
### Use a JavaScript system
```html
<script type="module">
import { ajax } from '@lion/ajax';
ajax.get('data.json').then(response => {
// most likely you will use response.data
});
</script>
```
### Extend a Web Component
```js
import { LionInput } from '@lion/input';
class MyInput extends LionInput {}
customElements.define('my-input', MyInput);
```
## Key Features
- High Performance - Focused on great performance in all relevant browsers with a minimal number of dependencies
- Accessibility - Aimed at compliance with the WCAG 2.0 AA standard to create components that are accessible for everybody
- Flexibility - Provides solutions through Web Components and JavaScript classes which can be used, adopted and extended to fit all needs
## Technologies
Lion Web Components aims to be future proof and use well-supported proven technology. The stack we have chosen should reflect this.
- [lit-html](https://lit-html.polymer-project.org) and [lit-element](https://lit-element.polymer-project.org)
- [npm](http://npmjs.com)
- [yarn](https://yarnpkg.com)
- [open-wc](https://open-wc.org)
- [webpack](https://webpack.js.org)
- [Karma](https://karma-runner.github.io)
- [Mocha](https://mochajs.org)
- [Chai](https://www.chaijs.com)
- [ESLint](https://eslint.org)
- [prettier](https://prettier.io)
- [Storybook](https://storybook.js.org)
- [ES modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import)
- Lots and lots of tests
## Rationale
We know from experience that making high quality, accessible UI components is hard and time consuming:
it takes many iterations, a lot of development time and a lot of testing to get a generic component that works in every
context, supports many edge cases and is accessible in all relevant screen readers.
Lion aims to do the heavy lifting for you.
This means you only have to apply your own Design System: by delivering styles, configuring components and adding a minimal set of custom logic on top.
## How to contribute
Lion Web Components are only as good as its contributions.
Read our [contribution guide](./CONTRIBUTING.md) and feel free to enhance/improve our product .
## Support and issues
As stated above "support and issues time" is currently rather limited: feel free to open a discussion.
However, we can not guarantee any response times.

View file

@ -0,0 +1,5 @@
{
"file-name": "max.json",
"name": "Max",
"age": "30"
}

View file

@ -0,0 +1,5 @@
{
"file-name": "peter.json",
"name": "Peter",
"age": "20"
}

View file

@ -0,0 +1 @@
export default '<svg id="Layer_1" style="enable-background:new 0 0 100 100;" version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g><g><path d="M36.1,38.4c-0.2,0-4.9-0.9-7.6-4.6c-1.5-2-4.1-3.5-6.1-3.5c0,0,0,0,0,0c-0.8,0-1.4,0.3-1.8,0.8L19,30 c0.7-1,1.9-1.6,3.3-1.6c0,0,0,0,0.1,0c2.6,0,5.7,1.8,7.6,4.3c2.3,3.1,6.4,3.9,6.4,3.9L36.1,38.4z"/></g><g><path d="M19.8,63.9V62c5.2,0,6.8-2.9,8.4-9.4c1.7-6.6,9.7-7.3,10.1-7.3l0.1,1.9l-0.1-0.9l0.1,0.9c-0.1,0-7.1,0.6-8.4,5.9 C28.6,59,26.8,63.9,19.8,63.9z"/></g><g><path d="M26.4,92.5c-0.4,0-0.9,0-1.3-0.1l0.2-1.9c2.1,0.2,3.7-0.2,4.8-1.3c2.9-2.9,1.8-10.4,1.1-15.3c-0.2-1.7-0.4-3.2-0.5-4.3 c-0.1-5.1,3.8-6.8,5.9-7l0.2,1.9c-0.4,0-4.3,0.5-4.2,5.1c0,1,0.2,2.4,0.5,4.1c0.8,5.6,1.8,13.3-1.7,16.9 C30.2,91.9,28.5,92.5,26.4,92.5z"/></g><g><path d="M50,80.1c-6.5,0-10.8-3.4-13-10.2c-1.8-5.6-1.8-12.5-1.8-18v-0.5c0-2.2,0.8-4.1,2.3-5.6c4.1-4,12.1-3.9,12.4-3.9 c0.3,0,8.4-0.1,12.4,3.9c1.5,1.5,2.3,3.4,2.3,5.6v0.5c0,5.5,0,12.4-1.8,18C60.8,76.6,56.5,80.1,50,80.1z M50,43.7 c-0.6,0-7.7,0.1-11.1,3.4c-1.2,1.2-1.7,2.5-1.7,4.3v0.5c0,5.4,0,12.1,1.7,17.4c1.9,6,5.5,8.9,11.2,8.9c5.6,0,9.3-2.9,11.2-8.9 c1.7-5.3,1.7-12,1.7-17.4v-0.5c0-1.7-0.6-3.1-1.7-4.3C57.6,43.6,50.1,43.7,50,43.7C50,43.7,50,43.7,50,43.7z"/></g><g><path d="M63.9,38.4l-0.3-1.9l0.2,0.9l-0.2-0.9c0,0,4.1-0.8,6.4-3.9c1.9-2.6,5.1-4.3,7.7-4.3c1.4,0,2.6,0.6,3.3,1.6l-1.5,1.1 c-0.4-0.5-1-0.8-1.8-0.8c-2,0-4.6,1.5-6.2,3.5C68.7,37.6,64.1,38.4,63.9,38.4z"/></g><g><path d="M80.2,63.9c-7,0-8.8-4.9-10.3-10.8c-1.3-5.3-8.3-5.9-8.4-5.9l0.1-1.9c0.3,0,8.4,0.7,10.1,7.3c1.6,6.5,3.2,9.4,8.4,9.4 V63.9z"/></g><g><path d="M73.6,92.5c-2.1,0-3.8-0.6-5-1.9c-3.5-3.6-2.4-11.3-1.7-16.9c0.2-1.7,0.4-3.1,0.5-4.1c0.1-4.5-3.7-5-4.2-5.1l0.2-1.9 c2.1,0.2,6,1.9,5.9,7c0,1.1-0.2,2.6-0.5,4.3c-0.7,4.9-1.7,12.4,1.1,15.3c1.1,1.1,2.6,1.5,4.8,1.3l0.2,1.9 C74.4,92.5,74,92.5,73.6,92.5z"/></g><g><path d="M59.1,25.1c-2.4,0,2.6-3,0.7-6h-3.3c0-8.3-3.8-10.7-3.8-10.7v10.7H50h-2.6V8.5c0,0-3.8,2.3-3.8,10.7h-3.4 c-1.9,3,3.1,6,0.7,6c-2.4,0-4.7,3.3-4.7,11.2c0,7.8,7.9,7.1,8,7.1c3-0.7,5.8-0.7,5.8-0.7c0,0,2.8,0,5.8,0.7c0.1,0,8,0.7,8-7.1 C63.8,28.5,61.5,25.1,59.1,25.1z"/></g><g><path d="M56.3,44.4c-0.4,0-0.6,0-0.6,0c0,0-0.1,0-0.1,0c-2.9-0.7-5.5-0.7-5.6-0.7c-0.1,0-2.7,0-5.6,0.7c0,0-0.1,0-0.1,0 c-0.2,0-4,0.3-6.7-2.1c-1.5-1.4-2.3-3.4-2.3-6c0-9.8,3.3-11.7,5-12c0,0-0.1-0.1-0.1-0.1c-0.8-1.2-2.1-3.3-0.8-5.5 c0.2-0.3,0.5-0.4,0.8-0.4h2.4c0.3-8,4.1-10.4,4.3-10.5c0.3-0.2,0.7-0.2,1,0c0.3,0.2,0.5,0.5,0.5,0.8v9.7h3.3V8.5 c0-0.3,0.2-0.7,0.5-0.8c0.3-0.2,0.7-0.2,1,0c0.2,0.1,4,2.5,4.3,10.5h2.4c0.3,0,0.6,0.2,0.8,0.4c1.4,2.2,0,4.3-0.8,5.5 c0,0,0,0.1-0.1,0.1c1.7,0.3,5,2.2,5,12c0,2.6-0.8,4.6-2.3,6C60.4,44.1,57.6,44.4,56.3,44.4z M55.9,42.5c0.4,0,3.3,0.1,5.2-1.6 c1.1-1,1.7-2.6,1.7-4.6c0-7.1-1.9-10.2-3.7-10.2c-0.7,0-1.2-0.2-1.5-0.7c-0.4-0.7,0.1-1.4,0.6-2.3c0.7-1.1,1.3-2.1,1-3.1h-2.8 c-0.5,0-0.9-0.4-0.9-0.9c0-4.2-1-6.8-2-8.2v8.2c0,0.5-0.4,0.9-0.9,0.9h-5.2c-0.5,0-0.9-0.4-0.9-0.9v-8.2c-0.9,1.5-2,4-2,8.2 c0,0.5-0.4,0.9-0.9,0.9h-2.8c-0.3,0.9,0.2,1.9,1,3.1c0.5,0.8,1,1.5,0.6,2.3c-0.3,0.5-0.7,0.7-1.5,0.7c-1.9,0-3.7,3.2-3.7,10.2 c0,2,0.6,3.5,1.7,4.6c1.9,1.7,4.8,1.6,5.2,1.6c3.1-0.7,5.8-0.7,6-0.7C50.1,41.8,52.9,41.7,55.9,42.5z"/></g><g><g><path d="M49.8,42.7H50C50,42.7,49.9,42.7,49.8,42.7z"/></g><g><path d="M50.2,42.7c-0.1,0-0.1,0-0.2,0H50.2z"/></g><g><polygon points="50,79.1 50,79.1 50,79.1 "/></g><g><path d="M63.8,51.3c0-7.4-9.7-8.4-12.8-8.6v36.3C64,78.3,63.8,61.9,63.8,51.3z"/></g><g><path d="M49.1,42.7c-3.1,0.1-12.8,1.2-12.8,8.6c0,10.6-0.2,27,12.8,27.8V42.7z"/></g></g></g></svg>';

File diff suppressed because one or more lines are too long

3
commitlint.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
};

28
docs/forms.md Normal file
View file

@ -0,0 +1,28 @@
# Forms
Forms allows you to create complex forms with various validation in an easy way.
## Features
- built in [validate](../packages/validate) for error/warning/info/success
- formatting of values
- accessible
- ...
## Packages
| Package | Description |
| ------------------------------------------------------ | -------------------------------------------- |
| [checkbox](../packages/checkbox) | Checkbox form element |
| [checkbox-group](../packages/checkbox-group) | Group of checkboxes |
| [field](../packages/field) | Base class for all inputs |
| [form](../packages/form) | Wrapper for multiple form elements |
| [input](../packages/input) | Input element for strings |
| [input-amount](../packages/input-amount) | Input element for amounts |
| [input-date](../packages/input-date) | Input element for dates |
| [input-email](../packages/input-email) | Input element for e-mails |
| [input-iban](../packages/input-iban) | Input element for IBANs |
| [radio](../packages/radio) | Radio form element |
| [radio-group](../packages/radio-group) | Group of radios |
| [select](../packages/select) | Simple native dropdown element |
| [textarea](../packages/textarea) | Multiline text input |
| [validate](../packages/validate) | Validation for our form components |

6
husky.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
hooks: {
'pre-commit': 'lint-staged',
'commit-msg': 'commitlint -E HUSKY_GIT_PARAMS',
},
};

16
karma.bs.config.js Normal file
View file

@ -0,0 +1,16 @@
/* eslint-disable import/no-extraneous-dependencies */
const merge = require('webpack-merge');
const bsSettings = require('@open-wc/testing-karma-bs/bs-settings.js');
const createBaseConfig = require('./karma.conf.js');
module.exports = config => {
config.set(
merge(bsSettings(config), createBaseConfig(config), {
browserStack: {
project: 'lion',
},
}),
);
return config;
};

31
karma.conf.js Normal file
View file

@ -0,0 +1,31 @@
/* eslint-disable import/no-extraneous-dependencies */
const createDefaultConfig = require('@open-wc/testing-karma/default-config');
const merge = require('webpack-merge');
module.exports = config => {
config.set(
merge(createDefaultConfig(config), {
files: [
// runs all files ending with .test in the test folder,
// can be overwritten by passing a --grep flag. examples:
//
// npm run test -- --grep test/foo/bar.test.js
// npm run test -- --grep test/bar/*
config.grep ? config.grep : 'packages/*/test/*.test.js',
],
// TODO: improve coverage
coverageIstanbulReporter: {
thresholds: {
global: {
statements: 80,
branches: 70,
functions: 70,
lines: 80,
},
},
},
}),
);
return config;
};

13
lerna.json Normal file
View file

@ -0,0 +1,13 @@
{
"packages": [
"packages/*"
],
"version": "independent",
"npmClient": "yarn",
"useWorkspaces": true,
"command": {
"publish": {
"conventionalCommits": true
}
}
}

63
package.json Normal file
View file

@ -0,0 +1,63 @@
{
"name": "@lion/root",
"private": true,
"license": "MIT",
"workspaces": [
"packages/*"
],
"devDependencies": {
"@commitlint/cli": "^7.0.0",
"@commitlint/config-conventional": "^7.0.0",
"@open-wc/eslint-config": "^0.4.0",
"@open-wc/prettier-config": "^0.1.0",
"@open-wc/storybook": "^0.1.5",
"@open-wc/testing": "^0.11.1",
"@open-wc/testing-karma": "^1.0.0",
"@open-wc/testing-karma-bs": "^1.0.0",
"@webcomponents/webcomponentsjs": "^2.2.5",
"babel-eslint": "^8.2.6",
"babel-polyfill": "^6.26.0",
"eclint": "^2.8.1",
"eslint": "^5.14.1",
"eslint-config-prettier": "^4.0.0",
"eslint-plugin-html": "^5.0.3",
"eslint-plugin-import": "^2.16.0",
"husky": "^1.0.0",
"lerna": "3.4.3",
"lint-staged": "^8.0.0",
"npm-run-all": "^4.1.5",
"sinon": "^7.2.2",
"webpack-merge": "^4.1.5",
"whatwg-fetch": "^3.0.0"
},
"scripts": {
"start": "npm run storybook",
"storybook": "start-storybook -p 9001 -s ./assets",
"storybook:build": "build-storybook -s ./assets",
"test": "karma start --coverage",
"test:watch": "karma start --auto-watch=true --single-run=false",
"test:legacy": "karma start --legacy --coverage",
"test:legacy:watch": "karma start --legacy --auto-watch=true --single-run=false",
"test:update-snapshots": "karma start --update-snapshots",
"test:prune-snapshots": "karma start --prune-snapshots",
"test:bs": "karma start karma.bs.config.js --legacy --coverage",
"lint": "run-p lint:*",
"lint:eclint": "eclint check $(find . \\( -name '*.html' -o -name '*.js' -o -name '*.css' \\) -type f -not -path '*/\\.*' -not -path '*node_modules/*' -not -path '*assets/*' -not -path '*coverage/*')",
"lint:eslint": "eslint --ext .js,.html .",
"lint:prettier": "prettier '**/*.js' --list-different || (echo '↑↑ these files are not prettier formatted ↑↑' && exit 1)",
"format": "npm run format:eslint && npm run format:prettier",
"format:eslint": "eslint --ext .js,.html . --fix",
"format:prettier": "prettier '**/*.js' --write"
},
"lint-staged": {
"*": [
"eclint fix",
"git add"
],
"*.js": [
"eslint --fix",
"prettier --write",
"git add"
]
}
}

81
packages/ajax/README.md Normal file
View file

@ -0,0 +1,81 @@
# Ajax
[//]: # (AUTO INSERT HEADER PREPUBLISH)
`ajax` is the global manager for handling all ajax requests.
It is a promise based system for fetching data, based on [axios](https://github.com/axios/axios)
## Features
- only JS functions, no (unnecessarily expensive) web components
- supports GET, POST, PUT, DELETE, REQUEST, PATCH and HEAD methods
- can be used with or without XSRF token
## How to use
### Installation
```sh
npm i --save @lion/ajax
```
### Example
```js
import { ajax } from '@lion/ajax';
ajax.get('data.json')
.then((response) => {
console.log(response);
})
.catch((error) => {
console.log(error);
});
```
### Create own instances for custom options
#### Cancel
```js
import { AjaxClass } from '@lion/ajax';
const myAjax = AjaxClass.getNewInstance({ cancelable: true });
myAjax.get('data.json')
.then((response) => {
document.querySelector('#canceled').innerHTML = JSON.stringify(response.data);
})
.catch((error) => {
document.querySelector('#canceled').innerHTML = `I got cancelled: ${error.message}`;
});
setTimeout(() => {
myAjax.cancel('too slow');
}, 1);
```
#### Cancel previous on new request
```js
import { AjaxClass } from '@lion/ajax'
const myAjax = AjaxClass.getNewInstance({ cancelPreviousOnNewRequest: true });
myAjax.get('data.json')
.then((response) => {
document.querySelector('#request1').innerHTML = 'Request 1: ' + JSON.stringify(response.data);
})
.catch((error) => {
document.querySelector('#request1').innerHTML = `Request 1: I got cancelled: ${error.message}`;
});
myAjax.get('data2.json')
.then((response) => {
document.querySelector('#request2').innerHTML = 'Request 2: ' + JSON.stringify(response.data);
})
.catch((error) => {
document.querySelector('#request2').innerHTML = `Request 2: I got cancelled: ${error.message}`;
});
```
## Considerations
> Due to a [bug in axios](https://github.com/axios/axios/issues/385) options may leak in to other instances. So please avoid setting global options in axios. Interceptors have no issues.
## Future plans
- Endplan is to remove axios and replace it with fetch
- This wrapper exist so that this switch should not mean any breaking changes for our users

11
packages/ajax/index.js Normal file
View file

@ -0,0 +1,11 @@
export { ajax } from './src/ajax.js';
export { AjaxClass } from './src/AjaxClass.js';
export {
cancelInterceptorFactory,
cancelPreviousOnNewRequestInterceptorFactory,
addAcceptLanguageHeaderInterceptorFactory,
} from './src/interceptors.js';
export { jsonPrefixTransformerFactory } from './src/transformers.js';

View file

@ -0,0 +1,40 @@
{
"name": "@lion/ajax",
"version": "0.0.0",
"description": "Thin wrapper around axios to allow for custom interceptors",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/ajax"
},
"scripts": {
"prepublishOnly": "../../scripts/insert-header.js"
},
"keywords": [
"lion",
"web-components",
"ajax"
],
"main": "index.js",
"module": "index.js",
"files": [
"stories",
"test",
"*.js"
],
"dependencies": {
"@lion/core": "0.0.0",
"@bundled-es-modules/axios": "0.18.0"
},
"devDependencies": {
"@open-wc/testing": "^0.11.1",
"@open-wc/storybook": "^0.1.5",
"sinon": "^7.2.2"
}
}

View file

@ -0,0 +1,225 @@
/* eslint-disable no-underscore-dangle */
import { axios } from '@bundled-es-modules/axios';
import { LionSingleton } from '@lion/core';
import {
cancelInterceptorFactory,
cancelPreviousOnNewRequestInterceptorFactory,
addAcceptLanguageHeaderInterceptorFactory,
} from './interceptors.js';
import { jsonPrefixTransformerFactory } from './transformers.js';
/**
* `AjaxClass` creates the singleton instance {@link:ajax}. It is a promise based system for
* fetching data, based on [axios](https://github.com/axios/axios).
*/
export class AjaxClass extends LionSingleton {
/**
* @property {Object} proxy the axios instance that is bound to the AjaxClass instance
*/
/**
* @param {Object} config configuration for the AjaxClass instance
* @param {string} config.jsonPrefix prefixing the JSON string in this manner is used to help
* prevent JSON Hijacking. The prefix renders the string syntactically invalid as a script so
* that it cannot be hijacked. This prefix should be stripped before parsing the string as JSON.
* @param {string} config.lang language
* @param {string} config.languageHeader the Accept-Language request HTTP header advertises
* which languages the client is able to understand, and which locale variant is preferred.
* @param {string} config.cancelable if request can be canceled
* @param {string} config.cancelPreviousOnNewRequest prevents concurrent requests
*/
constructor(config) {
super();
this.__config = {
lang: document.documentElement.getAttribute('lang'),
languageHeader: true,
cancelable: false,
cancelPreviousOnNewRequest: false,
...config,
};
this.proxy = axios.create(this.__config);
this.__setupInterceptors();
this.requestInterceptors = [];
this.requestErrorInterceptors = [];
this.requestDataTransformers = [];
this.requestDataErrorTransformers = [];
this.responseDataTransformers = [];
this.responseDataErrorTransformers = [];
this.responseInterceptors = [];
this.responseErrorInterceptors = [];
this.__isInterceptorsSetup = false;
if (this.__config.languageHeader) {
this.requestInterceptors.push(addAcceptLanguageHeaderInterceptorFactory(this.__config.lang));
}
if (this.__config.cancelable) {
this.requestInterceptors.push(cancelInterceptorFactory(this));
}
if (this.__config.cancelPreviousOnNewRequest) {
this.requestInterceptors.push(cancelPreviousOnNewRequestInterceptorFactory());
}
if (this.__config.jsonPrefix) {
const transformer = jsonPrefixTransformerFactory(this.__config.jsonPrefix);
this.responseDataTransformers.push(transformer);
}
}
/**
* Sets the config for the instance
* TODO: rename to 'config', because of conflict with options() request method on axios
*/
set options(config) {
this.__config = config;
}
get options() {
return this.__config;
}
/**
* Dispatches a request
* @see https://github.com/axios/axios
* @param {AxiosRequestConfig} config the config specific for this request
* @returns {AxiosResponseSchema}
*/
request(url, config) {
return this.proxy.request.apply(this, [url, { ...this.__config, ...config }]);
}
/**
* Dispatches a {@link AxiosRequestConfig} with method 'get' predefined
* @param {string} url the endpoint location
* @param {AxiosRequestConfig} config the config specific for this request
* @returns {AxiosResponseSchema}
*/
get(url, config) {
return this.proxy.get.apply(this, [url, { ...this.__config, ...config }]);
}
/**
* Dispatches a {@link AxiosRequestConfig} with method 'delete' predefined
* @param {string} url the endpoint location
* @param {AxiosRequestConfig} config the config specific for this request
* @returns {AxiosResponseSchema}
*/
delete(url, config) {
return this.proxy.delete.apply(this, [url, { ...this.__config, ...config }]);
}
/**
* Dispatches a {@link AxiosRequestConfig} with method 'head' predefined
* @param {string} url the endpoint location
* @param {AxiosRequestConfig} config the config specific for this request
* @returns {AxiosResponseSchema}
*/
head(url, config) {
return this.proxy.head.apply(this, [url, { ...this.__config, ...config }]);
}
/**
* Dispatches a {@link AxiosRequestConfig} with method 'options' predefined
* @param {string} url the endpoint location
* @param {AxiosRequestConfig} config the config specific for this request
* @returns {AxiosResponseSchema}
* TODO: consider reenable after rename of options to config
*/
// options(url, config) {
// return this.proxy.options.apply(this, [url, { ...this.__config, ...config }]);
// }
/**
* Dispatches a {@link AxiosRequestConfig} with method 'post' predefined
* @param {string} url the endpoint location
* @param {Object} data the data to be sent to the endpoint
* @param {AxiosRequestConfig} config the config specific for this request
* @returns {AxiosResponseSchema}
*/
post(url, data, config) {
return this.proxy.post.apply(this, [url, data, { ...this.__config, ...config }]);
}
/**
* Dispatches a {@link AxiosRequestConfig} with method 'put' predefined
* @param {string} url the endpoint location
* @param {Object} data the data to be sent to the endpoint
* @param {AxiosRequestConfig} config the config specific for this request
* @returns {AxiosResponseSchema}
*/
put(url, data, config) {
return this.proxy.put.apply(this, [url, data, { ...this.__config, ...config }]);
}
/**
* Dispatches a {@link AxiosRequestConfig} with method 'patch' predefined
* @see https://github.com/axios/axios (Request Config)
* @param {string} url the endpoint location
* @param {Object} data the data to be sent to the endpoint
* @param {Object} config the config specific for this request.
* @returns {AxiosResponseSchema}
*/
patch(url, data, config) {
return this.proxy.patch.apply(this, [url, data, { ...this.__config, ...config }]);
}
__setupInterceptors() {
this.proxy.interceptors.request.use(
config => {
const configWithTransformers = this.__setupTransformers(config);
return this.requestInterceptors.reduce((c, i) => i(c), configWithTransformers);
},
error => {
this.requestErrorInterceptors.forEach(i => i(error));
return Promise.reject(error);
},
);
this.proxy.interceptors.response.use(
response => this.responseInterceptors.reduce((r, i) => i(r), response),
error => {
this.responseErrorInterceptors.forEach(i => i(error));
return Promise.reject(error);
},
);
}
__setupTransformers(config) {
const axiosTransformRequest = config.transformRequest[0];
const axiosTransformResponse = config.transformResponse[0];
return {
...config,
transformRequest: (data, headers) => {
try {
const ourData = this.requestDataTransformers.reduce((d, t) => t(d, headers), data);
// axios does a lot of smart things with the request that people rely on
// and must be the last request data transformer to do this job
return axiosTransformRequest(ourData, headers);
} catch (error) {
this.requestDataErrorTransformers.forEach(t => t(error));
throw error;
}
},
transformResponse: data => {
try {
// axios does a lot of smart things with the response that people rely on
// and must be the first response data transformer to do this job
const axiosData = axiosTransformResponse(data);
return this.responseDataTransformers.reduce((d, t) => t(d), axiosData);
} catch (error) {
this.responseDataErrorTransformers.forEach(t => t(error));
throw error;
}
},
};
}
}

17
packages/ajax/src/ajax.js Normal file
View file

@ -0,0 +1,17 @@
import { AjaxClass } from './AjaxClass.js';
/**
* @typedef {ajax} ajax the global instance for handling all ajax requests
*/
export let ajax = AjaxClass.getInstance(); // eslint-disable-line import/no-mutable-exports
/**
* setAjax allows the Application Developer to override the globally used instance of {@link:ajax}.
* All interactions with {@link:ajax} after the call to setAjax will use this new instance
* (so make sure to call this method before dependant code using {@link:ajax} is ran and this
* method is not called by any of your (indirect) dependencies.)
* @param {AjaxClass} newAjax the globally used instance of {@link:ajax}.
*/
export function setAjax(newAjax) {
ajax = newAjax;
}

View file

@ -0,0 +1,43 @@
/* eslint-disable no-underscore-dangle */
import { axios } from '@bundled-es-modules/axios';
// FIXME: lang must be dynamic, fallback to html tag lang attribute or use the user-provided one
export function addAcceptLanguageHeaderInterceptorFactory(lang) {
return config => {
const result = config;
if (typeof lang === 'string' && lang !== '') {
if (typeof result.headers !== 'object') {
result.headers = {};
}
const withLang = { headers: { 'Accept-Language': lang, ...result.headers } };
return { ...result, ...withLang };
}
return result;
};
}
export function cancelInterceptorFactory(ajaxInstance) {
const cancelSources = [];
return config => {
const source = axios.CancelToken.source();
cancelSources.push(source);
/* eslint-disable-next-line no-param-reassign */
ajaxInstance.cancel = (message = 'Operation canceled by the user.') => {
cancelSources.forEach(s => s.cancel(message));
};
return { ...config, cancelToken: source.token };
};
}
export function cancelPreviousOnNewRequestInterceptorFactory() {
let prevCancelSource;
return config => {
if (prevCancelSource) {
prevCancelSource.cancel('Concurrent requests not allowed.');
}
const source = axios.CancelToken.source();
prevCancelSource = source;
return { ...config, cancelToken: source.token };
};
}

View file

@ -0,0 +1,16 @@
export function jsonPrefixTransformerFactory(prefix) {
return data => {
let result = data;
if (typeof result === 'string') {
if (prefix.length > 0 && result.indexOf(prefix) === 0) {
result = result.substring(prefix.length);
}
try {
result = JSON.parse(result);
} catch (e) {
/* ignore to allow non-JSON responses */
}
}
return result;
};
}

View file

@ -0,0 +1,80 @@
import { storiesOf, html, action } from '@open-wc/storybook';
import { ajax } from '../src/ajax.js';
import { AjaxClass } from '../src/AjaxClass.js';
/* eslint-disable indent */
storiesOf('Ajax system|ajax', module)
.addParameters({ options: { selectedPanel: 'storybook/actions/actions-panel' } })
.add(
'Get',
() => html`
<button
@click=${() => {
ajax
.get('./dummy-jsons/peter.json')
.then(response => {
action('request-response')(response.data);
})
.catch(error => {
action('request-error')(error);
});
}}
>
Log Get Request to Action Logger
</button>
`,
)
.add(
'Cancelable',
() => html`
<button
@click=${() => {
const myAjax = AjaxClass.getNewInstance({ cancelable: true });
requestAnimationFrame(() => {
myAjax.cancel('too slow');
});
myAjax
.get('./dummy-jsons/peter.json')
.then(response => {
action('request-response')(response.data);
})
.catch(error => {
action('request-error')(error);
});
}}
>
Execute Request to Action Logger
</button>
`,
)
.add(
'CancelPreviousOnNewRequest',
() => html`
<button
@click=${() => {
const myAjax = AjaxClass.getNewInstance({ cancelPreviousOnNewRequest: true });
myAjax
.get('./dummy-jsons/peter.json')
.then(response => {
action('Request 1:')(response.data);
})
.catch(error => {
action('Request 1: I got cancelled:')(error.message);
});
myAjax
.get('./dummy-jsons/max.json')
.then(response => {
action('Request 2:')(response.data);
})
.catch(error => {
action('Request 2: I got cancelled:')(error.message);
});
}}
>
Execute 2 Request to Action Logger
</button>
`,
);

View file

@ -0,0 +1,122 @@
/* eslint-env mocha */
import { expect } from '@open-wc/testing';
import sinon from 'sinon';
import { AjaxClass } from '../src/AjaxClass.js';
describe('AjaxClass interceptors', () => {
let server;
beforeEach(() => {
server = sinon.fakeServer.create({ autoRespond: true });
});
afterEach(() => {
server.restore();
});
describe('use cases', () => {
it('can be added on a class for all instances', () => {
['requestInterceptors', 'responseInterceptors'].forEach(type => {
const myInterceptor = () => {};
class MyApi extends AjaxClass {
constructor() {
super();
this[type] = [...this[type], myInterceptor];
}
}
const ajaxWithout = AjaxClass.getNewInstance();
const ajaxWith = MyApi.getNewInstance();
expect(ajaxWithout[type]).to.not.include(myInterceptor);
expect(ajaxWith[type]).to.include(myInterceptor);
});
});
it('can be added per instance without changing the class', () => {
['requestInterceptors', 'responseInterceptors'].forEach(type => {
const myInterceptor = () => {};
const ajaxWithout = AjaxClass.getNewInstance();
const ajaxWith = AjaxClass.getNewInstance();
ajaxWith[type].push(myInterceptor);
expect(ajaxWithout[type]).to.not.include(myInterceptor);
expect(ajaxWith[type]).to.include(myInterceptor);
});
});
it('can be removed after request', async () => {
await Promise.all(
['requestInterceptors', 'responseInterceptors'].map(async type => {
server.respondWith('GET', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{}',
]);
const myInterceptor = sinon.spy(foo => foo);
const ajax = AjaxClass.getNewInstance();
ajax[type].push(myInterceptor);
await ajax.get('data.json');
ajax[type] = ajax[type].filter(item => item !== myInterceptor);
await ajax.get('data.json');
expect(myInterceptor.callCount).to.eql(1);
}),
);
});
it('has access to provided instance config(options) on requestInterceptors', async () => {
server.respondWith('GET', 'data.json', [200, { 'Content-Type': 'application/json' }, '{}']);
const ajax = AjaxClass.getNewInstance();
ajax.options.myCustomValue = 'foo';
let customValueAccess = false;
const myInterceptor = config => {
customValueAccess = config.myCustomValue === 'foo';
return config;
};
ajax.requestInterceptors.push(myInterceptor);
await ajax.get('data.json');
expect(customValueAccess).to.eql(true);
});
});
describe('requestInterceptors', () => {
it('allow to intercept request to change config', async () => {
server.respondWith('POST', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{ "method": "post" }',
]);
server.respondWith('PUT', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{ "method": "put" }',
]);
const enforcePutInterceptor = config => ({ ...config, method: 'PUT' });
const myAjax = AjaxClass.getNewInstance();
myAjax.requestInterceptors.push(enforcePutInterceptor);
const response = await myAjax.post('data.json');
expect(response.data).to.deep.equal({ method: 'put' });
});
});
describe('responseInterceptors', () => {
it('allow to intercept response to change data', async () => {
server.respondWith('GET', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{ "method": "get" }',
]);
const addDataInterceptor = response => ({
...response,
data: { ...response.data, foo: 'bar' },
});
const myAjax = AjaxClass.getNewInstance();
myAjax.responseInterceptors.push(addDataInterceptor);
const response = await myAjax.get('data.json');
expect(response.data).to.deep.equal({ method: 'get', foo: 'bar' });
});
});
});

View file

@ -0,0 +1,78 @@
/* eslint-env mocha */
import { expect, aTimeout } from '@open-wc/testing';
import sinon from 'sinon';
import { AjaxClass } from '../src/AjaxClass.js';
describe('AjaxClass languages', () => {
let fakeXhr;
let requests;
beforeEach(() => {
fakeXhr = sinon.useFakeXMLHttpRequest();
requests = [];
fakeXhr.onCreate = xhr => {
requests.push(xhr);
};
});
afterEach(() => {
fakeXhr.restore();
document.documentElement.lang = 'en-GB';
});
it('sets "Accept-Language" header to "en-GB" for one request if <html lang="en-GB">', async () => {
document.documentElement.lang = 'en-GB';
const req = new AjaxClass();
req.get('data.json');
await aTimeout();
expect(requests.length).to.equal(1);
expect(requests[0].requestHeaders['Accept-Language']).to.equal('en-GB');
});
it('sets "Accept-Language" header to "en-GB" for multiple subsequent requests if <html lang="en-GB">', async () => {
document.documentElement.lang = 'en-GB';
const req = new AjaxClass();
req.get('data1.json');
req.post('data2.json');
req.put('data3.json');
req.delete('data4.json');
await aTimeout();
expect(requests.length).to.equal(4);
requests.forEach(request => {
expect(request.requestHeaders['Accept-Language']).to.equal('en-GB');
});
});
it('sets "Accept-Language" header to "nl-NL" for one request if <html lang="nl-NL">', async () => {
document.documentElement.lang = 'nl-NL';
const req = new AjaxClass();
req.get('data.json');
await aTimeout();
expect(requests.length).to.equal(1);
expect(requests[0].requestHeaders['Accept-Language']).to.equal('nl-NL');
});
it('sets "Accept-Language" header to "nl-NL" for multiple subsequent requests if <html lang="nl-NL">', async () => {
document.documentElement.lang = 'nl-NL';
const req = new AjaxClass();
req.get('data1.json');
req.post('data2.json');
req.put('data3.json');
req.delete('data4.json');
await aTimeout();
expect(requests.length).to.equal(4);
requests.forEach(request => {
expect(request.requestHeaders['Accept-Language']).to.equal('nl-NL');
});
});
it('does not set "Accept-Language" header if <html lang="">', async () => {
document.documentElement.lang = '';
const req = new AjaxClass();
req.get('data.json');
await aTimeout();
expect(requests.length).to.equal(1);
expect(requests[0].requestHeaders['Accept-Language']).to.equal(undefined);
});
});

View file

@ -0,0 +1,297 @@
/* eslint-env mocha */
import { expect } from '@open-wc/testing';
import sinon from 'sinon';
import { AjaxClass } from '../src/AjaxClass.js';
import { ajax } from '../src/ajax.js';
describe('AjaxClass', () => {
let server;
beforeEach(() => {
server = sinon.fakeServer.create({ autoRespond: true });
});
afterEach(() => {
server.restore();
});
it('sets content type json if passed an object', async () => {
const myAjax = AjaxClass.getNewInstance();
server.respondWith('POST', /\/api\/foo/, [200, { 'Content-Type': 'application/json' }, '']);
await myAjax.post('/api/foo', { a: 1, b: 2 });
expect(server.requests[0].requestHeaders['Content-Type']).to.include('application/json');
});
describe('AjaxClass.getNewInstance({ jsonPrefix: "%prefix%" })', () => {
it('adds new transformer to responseDataTransformers', () => {
const myAjaxWithout = AjaxClass.getNewInstance({ jsonPrefix: '' });
const myAjaxWith = AjaxClass.getNewInstance({ jsonPrefix: 'prefix' });
const lengthWithout = myAjaxWithout.responseDataTransformers.length;
const lengthWith = myAjaxWith.responseDataTransformers.length;
expect(lengthWith - lengthWithout).to.eql(1);
});
it('allows to customize anti-XSSI prefix', async () => {
server.respondWith('GET', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'for(;;);{"success":true}',
]);
const myAjax = AjaxClass.getNewInstance({ jsonPrefix: 'for(;;);' });
const response = await myAjax.get('data.json');
expect(response.status).to.equal(200);
expect(response.data.success).to.equal(true);
});
it('works with non-JSON responses', async () => {
server.respondWith('GET', 'data.txt', [200, { 'Content-Type': 'text/plain' }, 'some text']);
const myAjax = AjaxClass.getNewInstance({ jsonPrefix: 'for(;;);' });
const response = await myAjax.get('data.txt');
expect(response.status).to.equal(200);
expect(response.data).to.equal('some text');
});
});
describe('AjaxClass.getNewInstance({ cancelable: true })', () => {
it('adds new interceptor to requestInterceptors', () => {
const myAjaxWithout = AjaxClass.getNewInstance();
const myAjaxWith = AjaxClass.getNewInstance({ cancelable: true });
const lengthWithout = myAjaxWithout.requestInterceptors.length;
const lengthWith = myAjaxWith.requestInterceptors.length;
expect(lengthWith - lengthWithout).to.eql(1);
});
it('allows to cancel single running requests', async () => {
const myAjax = AjaxClass.getNewInstance({ cancelable: true });
setTimeout(() => {
myAjax.cancel('is cancelled');
});
try {
await myAjax.get('data.json');
throw new Error('is not cancelled');
} catch (error) {
expect(error.message).to.equal('is cancelled');
}
});
it('allows to cancel multiple running requests', async () => {
const myAjax = AjaxClass.getNewInstance({ cancelable: true });
let cancelCount = 0;
setTimeout(() => {
myAjax.cancel('is cancelled');
});
const makeRequest = async () => {
try {
await myAjax.get('data.json');
throw new Error('is not cancelled');
} catch (error) {
expect(error.message).to.equal('is cancelled');
cancelCount += 1;
}
};
await Promise.all([makeRequest(), makeRequest(), makeRequest()]);
expect(cancelCount).to.equal(3);
});
it('does not cancel resolved requests', async () => {
const myAjax = AjaxClass.getNewInstance({ cancelable: true });
server.respondWith('GET', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{ "method": "get" }',
]);
try {
const response = await myAjax.get('data.json');
expect(response.data).to.deep.equal({ method: 'get' });
myAjax.cancel('is cancelled');
} catch (error) {
throw new Error('is cancelled');
}
});
});
describe('AjaxClass.getNewInstance({ cancelPreviousOnNewRequest: true })', () => {
it('adds new interceptor to requestInterceptors', () => {
const myAjaxWithout = AjaxClass.getNewInstance();
const myAjaxWith = AjaxClass.getNewInstance({ cancelPreviousOnNewRequest: true });
const lengthWithout = myAjaxWithout.requestInterceptors.length;
const lengthWith = myAjaxWith.requestInterceptors.length;
expect(lengthWith - lengthWithout).to.eql(1);
});
it('automatically cancels previous running request', async () => {
const myAjax = AjaxClass.getNewInstance({ cancelPreviousOnNewRequest: true });
server.respondWith('GET', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{ "method": "get" }',
]);
await Promise.all([
(async () => {
try {
await myAjax.get('data.json');
throw new Error('is resolved');
} catch (error) {
expect(error.message).to.equal('Concurrent requests not allowed.');
}
})(),
(async () => {
try {
const response = await myAjax.get('data.json');
expect(response.data).to.deep.equal({ method: 'get' });
} catch (error) {
throw new Error('is not resolved');
}
})(),
]);
});
it('automatically cancels multiple previous requests to the same endpoint', async () => {
const myAjax = AjaxClass.getNewInstance({ cancelPreviousOnNewRequest: true });
server.respondWith('GET', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{ "method": "get" }',
]);
const makeRequest = async () => {
try {
await myAjax.get('data.json');
throw new Error('is resolved');
} catch (error) {
expect(error.message).to.equal('Concurrent requests not allowed.');
}
};
await Promise.all([
makeRequest(),
makeRequest(),
makeRequest(),
(async () => {
try {
const response = await myAjax.get('data.json');
expect(response.data).to.deep.equal({ method: 'get' });
} catch (error) {
throw new Error('is not resolved');
}
})(),
]);
});
it('automatically cancels multiple previous requests to different endpoints', async () => {
const myAjax = AjaxClass.getNewInstance({ cancelPreviousOnNewRequest: true });
server.respondWith('GET', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{ "method": "get" }',
]);
const makeRequest = async url => {
try {
await myAjax.get(url);
throw new Error('is resolved');
} catch (error) {
expect(error.message).to.equal('Concurrent requests not allowed.');
}
};
await Promise.all([
makeRequest('data1.json'),
makeRequest('data2.json'),
makeRequest('data3.json'),
(async () => {
try {
const response = await myAjax.get('data.json');
expect(response.data).to.deep.equal({ method: 'get' });
} catch (error) {
throw new Error('is not resolved');
}
})(),
]);
});
it('does not automatically cancel requests made via generic ajax', async () => {
const myAjax = AjaxClass.getNewInstance({ cancelPreviousOnNewRequest: true });
server.respondWith('GET', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{ "method": "get" }',
]);
await Promise.all([
(async () => {
try {
await myAjax.get('data.json');
throw new Error('is resolved');
} catch (error) {
expect(error.message).to.equal('Concurrent requests not allowed.');
}
})(),
(async () => {
try {
const response = await myAjax.get('data.json');
expect(response.data).to.deep.equal({ method: 'get' });
} catch (error) {
throw new Error('is not resolved');
}
})(),
(async () => {
try {
const response = await ajax.get('data.json');
expect(response.data).to.deep.equal({ method: 'get' });
} catch (error) {
throw new Error('is not resolved');
}
})(),
]);
});
it('does not automatically cancel requests made via other instances', async () => {
const myAjax1 = AjaxClass.getNewInstance({ cancelPreviousOnNewRequest: true });
const myAjax2 = AjaxClass.getNewInstance({ cancelPreviousOnNewRequest: true });
server.respondWith('GET', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{ "method": "get" }',
]);
await Promise.all([
(async () => {
try {
await myAjax1.get('data.json');
throw new Error('is resolved');
} catch (error) {
expect(error.message).to.equal('Concurrent requests not allowed.');
}
})(),
(async () => {
try {
const response = await myAjax2.get('data.json');
expect(response.data).to.deep.equal({ method: 'get' });
} catch (error) {
throw new Error('is not resolved');
}
})(),
(async () => {
try {
const response = await myAjax1.get('data.json');
expect(response.data).to.deep.equal({ method: 'get' });
} catch (error) {
throw new Error('is not resolved');
}
})(),
]);
});
});
});

View file

@ -0,0 +1,103 @@
/* eslint-env mocha */
import { expect } from '@open-wc/testing';
import sinon from 'sinon';
import { AjaxClass } from '../src/AjaxClass.js';
describe('AjaxClass transformers', () => {
let server;
beforeEach(() => {
server = sinon.fakeServer.create({ autoRespond: true });
});
afterEach(() => {
server.restore();
});
describe('use cases', () => {
it('can be added on a class for all instances', () => {
['requestDataTransformers', 'responseDataTransformers'].forEach(type => {
const myInterceptor = () => {};
class MyApi extends AjaxClass {
constructor() {
super();
this[type] = [...this[type], myInterceptor];
}
}
const ajaxWithout = AjaxClass.getNewInstance();
const ajaxWith = MyApi.getNewInstance();
expect(ajaxWithout[type]).to.not.include(myInterceptor);
expect(ajaxWith[type]).to.include(myInterceptor);
});
});
it('can be added per instance withour changing the class', () => {
['requestDataTransformers', 'responseDataTransformers'].forEach(type => {
const myInterceptor = () => {};
const ajaxWithout = AjaxClass.getNewInstance();
const ajaxWith = AjaxClass.getNewInstance();
ajaxWith[type].push(myInterceptor);
expect(ajaxWithout[type]).to.not.include(myInterceptor);
expect(ajaxWith[type]).to.include(myInterceptor);
});
});
it('can be removed after request', async () => {
await Promise.all(
['requestDataTransformers', 'responseDataTransformers'].map(async type => {
server.respondWith('GET', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{}',
]);
const myTransformer = sinon.spy(foo => foo);
const ajax = AjaxClass.getNewInstance();
ajax[type].push(myTransformer);
await ajax.get('data.json');
ajax[type] = ajax[type].filter(item => item !== myTransformer);
await ajax.get('data.json');
expect(myTransformer.callCount).to.eql(1);
}),
);
});
});
describe('requestDataTransformers', () => {
it('allow to transform request data', async () => {
server.respondWith('POST', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{ "method": "post" }',
]);
const addBarTransformer = data => ({ ...data, bar: 'bar' });
const myAjax = AjaxClass.getNewInstance();
myAjax.requestDataTransformers.push(addBarTransformer);
const response = await myAjax.post('data.json', { foo: 'foo' });
expect(JSON.parse(response.config.data)).to.deep.equal({
foo: 'foo',
bar: 'bar',
});
});
});
describe('responseDataTransformers', () => {
it('allow to transform response data', async () => {
server.respondWith('GET', 'data.json', [
200,
{ 'Content-Type': 'application/json' },
'{ "method": "get" }',
]);
const addBarTransformer = data => ({ ...data, bar: 'bar' });
const myAjax = AjaxClass.getNewInstance();
myAjax.responseDataTransformers.push(addBarTransformer);
const response = await myAjax.get('data.json');
expect(response.data).to.deep.equal({ method: 'get', bar: 'bar' });
});
});
});

View file

@ -0,0 +1,125 @@
/* eslint-env mocha */
import { expect } from '@open-wc/testing';
import sinon from 'sinon';
import { ajax } from '../src/ajax.js';
describe('ajax', () => {
let server;
beforeEach(() => {
server = sinon.fakeServer.create({ autoRespond: true });
});
afterEach(() => {
server.restore();
});
it('interprets Content-Type of the response by default', async () => {
server.respondWith('GET', '/path/to/data/', [
200,
{ 'Content-Type': 'application/json' },
'{ "json": "yes" }',
]);
const response = await ajax.get('/path/to/data/');
expect(response.status).to.equal(200);
expect(response.data).to.deep.equal({ json: 'yes' });
});
it('supports signature (url[, config]) for get(), request(), delete(), head()', async () => {
server.respondWith('data.json', [
200,
{ 'Content-Type': 'application/json' },
'{"success": true}',
]);
const makeRequest = async method => {
const response = await ajax[method]('data.json', { foo: 'bar' });
expect(response.status).to.equal(200);
expect(response.data).to.deep.equal({ success: true });
};
await Promise.all(['get', 'request', 'delete', 'head'].map(m => makeRequest(m)));
});
it('supports signature (url[, data[, config]]) for post(), put(), patch()', async () => {
server.respondWith('data.json', [
200,
{ 'Content-Type': 'application/json' },
'{"success": true}',
]);
const makeRequest = async method => {
const response = await ajax[method]('data.json', { data: 'foobar' }, { foo: 'bar' });
expect(response.status).to.equal(200);
expect(response.data).to.deep.equal({ success: true });
};
await Promise.all(['post', 'put', 'patch'].map(m => makeRequest(m)));
});
it('supports GET, POST, PUT, DELETE, REQUEST, PATCH and HEAD methods with XSRF token', async () => {
document.cookie = 'XSRF-TOKEN=test; ';
server.respondWith('data.json', [
200,
{ 'Content-Type': 'application/json' },
'{"success": true}',
]);
const makeRequest = async method => {
const response = await ajax[method]('data.json');
expect(response.config.headers['X-XSRF-TOKEN']).to.equal('test');
expect(response.status).to.equal(200);
expect(response.data).to.deep.equal({ success: true });
};
await Promise.all(
['get', 'post', 'put', 'delete', 'request', 'patch', 'head'].map(m => makeRequest(m)),
);
});
it('supports GET, POST, PUT, DELETE, REQUEST, PATCH and HEAD methods without XSRF token', async () => {
document.cookie = 'XSRF-TOKEN=; ';
server.respondWith('data.json', [
200,
{ 'Content-Type': 'application/json' },
'{"success": true}',
]);
const makeRequest = async method => {
const response = await ajax[method]('data.json');
expect(response.config.headers['X-XSRF-TOKEN']).to.equal(undefined);
expect(response.status).to.equal(200);
expect(response.data).to.deep.equal({ success: true });
};
await Promise.all(
['get', 'post', 'put', 'delete', 'request', 'patch', 'head'].map(m => makeRequest(m)),
);
});
it('supports empty responses', async () => {
server.respondWith('GET', 'data.json', [200, { 'Content-Type': 'application/json' }, '']);
const response = await ajax.get('data.json');
expect(response.status).to.equal(200);
expect(response.data).to.equal('');
});
it('supports error responses', async () => {
server.respondWith('GET', 'data.json', [500, { 'Content-Type': 'application/json' }, '']);
try {
await ajax.get('data.json');
throw new Error('error is not handled');
} catch (error) {
expect(error).to.be.an.instanceof(Error);
expect(error.response.status).to.equal(500);
}
});
it('supports non-JSON responses', async () => {
server.respondWith('GET', 'data.txt', [200, { 'Content-Type': 'text/plain' }, 'some text']);
const response = await ajax.get('data.txt');
expect(response.status).to.equal(200);
expect(response.data).to.equal('some text');
});
});

40
packages/button/README.md Normal file
View file

@ -0,0 +1,40 @@
# Button
[//]: # (AUTO INSERT HEADER PREPUBLISH)
`lion-button` provides a component that is easily stylable and is accessible in all contexts.
## Features
### Disabled
You can also set a button as disabled with the `disabled` property.
## How to use
### Installation
```
npm i --save @lion/button
```
```js
import '@lion/button/lion-button.js';
```
### Example
```html
<lion-button>Button Text</lion-button>
```
- Don't use a button when you want a user to navigate. Use a link instead.
- Not all color and font size combinations are available because some do not meet accessibility contrast requirements
## Considerations
### Why a webcomponent?
There are multiple reasons why we used a web component as opposed to a CSS component.
- **Target size**: The minimum target size is 40 pixels, which makes even the small buttons easy to activate. A container element was needed to make this size possible.
- **Accessibility**: Our button is accessible because it uses the native button element. Having this native button element available in the light dom, preserves all platform accessibility features, like having it recognized by a native form.
- **Advanced styling**: There are advanced styling options regarding icons in buttons, where it is a lot more maintainable to handle icons in our button using slots. An example is that a sticky icon-only buttons may looks different from buttons which have both icons and text.

1
packages/button/index.js Normal file
View file

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

View file

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

View file

@ -0,0 +1,43 @@
{
"name": "@lion/button",
"version": "0.0.0",
"description": "A button that is easily stylable and accessible in all contexts",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/button"
},
"scripts": {
"prepublishOnly": "../../scripts/insert-header.js"
},
"keywords": [
"lion",
"web-components",
"button"
],
"main": "index.js",
"module": "index.js",
"files": [
"stories",
"test",
"*.js"
],
"dependencies": {
"@lion/core": "0.0.0"
},
"devDependencies": {
"@lion/icon": "0.0.0",
"@lion/form": "0.0.0",
"@lion/input": "0.0.0",
"@open-wc/testing": "^0.11.1",
"@open-wc/storybook": "^0.1.5",
"@polymer/iron-test-helpers": "^3.0.1",
"sinon": "^7.2.2"
}
}

View file

@ -0,0 +1,174 @@
/* eslint-disable no-underscore-dangle */
import { css, html, DelegateMixin, SlotMixin } from '@lion/core';
import { LionLitElement } from '@lion/core/src/LionLitElement.js';
// eslint-disable-next-line no-unused-vars
export class LionButton extends DelegateMixin(SlotMixin(LionLitElement)) {
static get properties() {
return {
disabled: {
type: Boolean,
reflect: true,
},
};
}
render() {
return html`
<div class="btn">
<slot></slot>
<slot name="_button"></slot>
<div class="click-area" @click="${this.__clickDelegationHandler}"></div>
</div>
`;
}
static get styles() {
return [
css`
:host {
display: inline-block;
padding-top: 2px;
padding-bottom: 2px;
height: 40px; /* src = https://www.smashingmagazine.com/2012/02/finger-friendly-design-ideal-mobile-touchscreen-target-sizes/ */
outline: 0;
background-color: transparent;
box-sizing: border-box;
}
.btn {
height: 24px;
display: flex;
align-items: center;
position: relative;
border: 1px solid black;
border-radius: 8px;
background: whitesmoke;
color: black;
padding: 7px 15px;
}
:host .btn ::slotted(button) {
position: absolute;
visibility: hidden;
}
.click-area {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: -3px -1px;
padding: 0;
}
:host(:focus) {
outline: none;
}
:host(:focus) .btn {
border-color: lightblue;
box-shadow: 0 0 8px lightblue, 0 0 0 1px lightblue;
}
:host(:hover) .btn {
background: black;
color: whitesmoke;
}
:host(:hover) .btn ::slotted(lion-icon) {
fill: whitesmoke;
}
:host([disabled]) {
pointer-events: none;
}
:host([disabled]) .btn {
background: lightgray;
color: gray;
fill: gray;
border-color: gray;
}
`,
];
}
update(changedProperties) {
super.update(changedProperties);
if (changedProperties.has('disabled')) {
this.__onDisabledChanged();
}
}
get delegations() {
return {
...super.delegations,
target: () => this.$$slot('_button'),
attributes: ['type'],
};
}
get slots() {
return {
...super.slots,
_button: () => {
if (!this.constructor._button) {
this.constructor._button = document.createElement('button');
this.constructor._button.setAttribute('slot', '_button');
this.constructor._button.setAttribute('tabindex', '-1');
}
return this.constructor._button.cloneNode();
},
};
}
constructor() {
super();
this.disabled = false;
this.__keydownDelegationHandler = this.__keydownDelegationHandler.bind(this);
}
connectedCallback() {
super.connectedCallback();
this.__setupA11y();
this.__setupKeydownDelegation();
}
disconnectedCallback() {
super.disconnectedCallback();
this.__teardownKeydownDelegation();
}
__clickDelegationHandler(e) {
e.stopPropagation(); // prevent click on the fake element and cause click on the native button
this.$$slot('_button').click();
}
__setupA11y() {
this.setAttribute('role', 'button');
this.setAttribute('tabindex', this.disabled ? -1 : 0);
}
__setupKeydownDelegation() {
this.addEventListener('keydown', this.__keydownDelegationHandler);
}
__teardownKeydownDelegation() {
this.removeEventListener('keydown', this.__keydownDelegationHandler);
}
__keydownDelegationHandler(e) {
// Makes the real button the trigger in forms (will submit form, as opposed to paper-button)
// and make click handlers on button work on space and enter
if (e.keyCode === 32 /* space */ || e.keyCode === 13 /* enter */) {
e.preventDefault();
this.$$slot('_button').click();
}
}
__onDisabledChanged() {
this.setAttribute('tabindex', this.disabled ? -1 : 0);
}
}

View file

@ -0,0 +1,49 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { storiesOf, html, action } from '@open-wc/storybook';
import { bug12 } from '@lion/icon/stories/icons/bugs-collection';
import '@lion/icon/lion-icon.js';
import '@lion/form/lion-form.js';
import '@lion/input/lion-input.js';
import '../lion-button.js';
storiesOf('Buttons|<lion-button>', module)
.add(
'Used on its own',
() => html`
<style>
.demo-box {
display: flex;
padding: 8px;
}
lion-button {
margin: 8px;
}
</style>
<div class="demo-box">
<lion-button>Default</lion-button>
<lion-button><lion-icon .svg="${bug12}"></lion-icon>Debug</lion-button>
<lion-button type="submit">Submit</lion-button>
<lion-button aria-label="Debug"><lion-icon .svg="${bug12}"></lion-icon></lion-button>
<lion-button onclick="alert('clicked/spaced/entered')">click/space/enter me</lion-button>
<lion-button disabled>Disabled</lion-button>
</div>
`,
)
.add(
'Within a form',
() => html`
<lion-form id="form"
><form>
<lion-input name="foo" label="Foo" .modelValue=${'bar'}></lion-input>
<lion-button
type="submit"
@click=${() =>
action('serializeGroup')(document.querySelector('#form').serializeGroup())}
>Submit</lion-button
>
</form></lion-form
>
`,
);

View file

@ -0,0 +1,95 @@
/* eslint-env mocha */
/* eslint-disable no-unused-expressions */
import { expect, fixture, html } from '@open-wc/testing';
import sinon from 'sinon';
import { pressEnter, pressSpace } from '@polymer/iron-test-helpers/mock-interactions.js';
import '../lion-button.js';
describe('lion-button', () => {
it('behaves like native `button` in terms of a11y', async () => {
const lionButton = await fixture(`<lion-button>foo</lion-button>`);
expect(lionButton.getAttribute('role')).to.equal('button');
expect(lionButton.getAttribute('tabindex')).to.equal('0');
});
it('has no type by default on the native button', async () => {
const lionButton = await fixture(`<lion-button>foo</lion-button>`);
const nativeButton = lionButton.$$slot('_button');
expect(nativeButton.getAttribute('type')).to.be.null;
});
it('has type="submit" on the native button when set', async () => {
const lionButton = await fixture(`<lion-button type="submit">foo</lion-button>`);
const nativeButton = lionButton.$$slot('_button');
expect(nativeButton.getAttribute('type')).to.equal('submit');
});
it('hides the native button in the UI', async () => {
const lionButton = await fixture(`<lion-button>foo</lion-button>`);
const nativeButton = lionButton.$$slot('_button');
expect(nativeButton.getAttribute('tabindex')).to.equal('-1');
expect(window.getComputedStyle(nativeButton).visibility).to.equal('hidden');
});
it('can be disabled imperatively', async () => {
const lionButton = await fixture(`<lion-button disabled>foo</lion-button>`);
expect(lionButton.getAttribute('tabindex')).to.equal('-1');
lionButton.disabled = false;
await lionButton.updateComplete;
expect(lionButton.getAttribute('tabindex')).to.equal('0');
expect(lionButton.hasAttribute('disabled')).to.equal(false);
lionButton.disabled = true;
await lionButton.updateComplete;
expect(lionButton.getAttribute('tabindex')).to.equal('-1');
expect(lionButton.hasAttribute('disabled')).to.equal(true);
});
describe('form integration', () => {
it('behaves like native `button` when clicked', async () => {
const formSubmitSpy = sinon.spy(e => e.preventDefault());
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<lion-button type="submit">foo</lion-button>
</form>
`);
const button = form.querySelector('lion-button');
const { left, top } = button.getBoundingClientRect();
// to support elementFromPoint() in polyfilled browsers we have to use document
const crossBrowserRoot = button.shadowRoot.elementFromPoint ? button.shadowRoot : document;
const shadowClickAreaElement = crossBrowserRoot.elementFromPoint(left, top);
shadowClickAreaElement.click();
expect(formSubmitSpy.called).to.be.true;
});
it('behaves like native `button` when interected with keyboard space', async () => {
const formSubmitSpy = sinon.spy(e => e.preventDefault());
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<lion-button type="submit">foo</lion-button>
</form>
`);
pressSpace(form.querySelector('lion-button'));
expect(formSubmitSpy.called).to.be.true;
});
it('behaves like native `button` when interected with keyboard enter', async () => {
const formSubmitSpy = sinon.spy(e => e.preventDefault());
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<lion-button type="submit">foo</lion-button>
</form>
`);
pressEnter(form.querySelector('lion-button'));
expect(formSubmitSpy.called).to.be.true;
});
});
});

View file

@ -0,0 +1,40 @@
# Checkbox Group
[//]: # (AUTO INSERT HEADER PREPUBLISH)
`lion-checkbox-group` component is webcomponent that enhances the functionality of the native `<input type="checkbox">` element. Its purpose is to provide a way for users to check **multiple** options amongst a set of choices, or to function as a single toggle.
You should use [lion-checkbox](../checkbox/)'s inside this element.
## Features
Since it extends from [lion-fieldset](../fieldset/), it has all the features a fieldset has.
## How to use
### Installation
```
npm i --save @lion/checkbox @lion/checkbox-group
```
```js
import '@lion/checkbox/lion-checkbox.js';
import '@lion/checkbox-group/lion-checkbox-group.js';
```
### Example
```html
<lion-form><form>
<lion-checkbox-group
name="scientistsGroup"
label="Who are your favorite scientists?"
.errorValidators=${[['required']]}
>
<lion-checkbox name="scientists[]" label="Archimedes" .choiceValue=${'Archimedes'}></lion-checkbox>
<lion-checkbox name="scientists[]" label="Francis Bacon" .choiceValue=${'Francis Bacon'}></lion-checkbox>
<lion-checkbox name="scientists[]" label="Marie Curie" .choiceValue=${'Marie Curie'}></lion-checkbox>
</lion-checkbox-group>
</form></lion-form>
```
- Make sure that it has a name attribute, this is necessary for the [lion-form](../form/)'s serialization result.

View file

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

View file

@ -0,0 +1,3 @@
import { LionCheckboxGroup } from './src/LionCheckboxGroup.js';
customElements.define('lion-checkbox-group', LionCheckboxGroup);

View file

@ -0,0 +1,43 @@
{
"name": "@lion/checkbox-group",
"version": "0.0.0",
"description": "A container for multiple checkboxes",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/checkbox-group"
},
"scripts": {
"prepublishOnly": "../../scripts/insert-header.js"
},
"keywords": [
"lion",
"web-components",
"checkbox-group"
],
"main": "index.js",
"module": "index.js",
"files": [
"stories",
"test",
"*.js"
],
"dependencies": {
"@lion/core": "0.0.0",
"@lion/fieldset": "0.0.0"
},
"devDependencies": {
"@lion/checkbox": "0.0.0",
"@lion/form": "0.0.0",
"@lion/localize": "0.0.0",
"@open-wc/testing": "^0.11.1",
"@open-wc/storybook": "^0.1.5",
"sinon": "^7.2.2"
}
}

View file

@ -0,0 +1,90 @@
import { LionFieldset } from '@lion/fieldset';
/* eslint-disable no-underscore-dangle */
export class LionCheckboxGroup extends LionFieldset {
constructor() {
super();
this._checkboxGroupTouched = false;
this._setTouchedAndPrefilled = this._setTouchedAndPrefilled.bind(this);
this._checkForOutsideClick = this._checkForOutsideClick.bind(this);
this._checkForChildrenClick = this._checkForChildrenClick.bind(this);
}
connectedCallback() {
super.connectedCallback();
// We listen for focusin(instead of foxus), because it bubbles and gives the right event order
window.addEventListener('focusin', this._setTouchedAndPrefilled);
document.addEventListener('click', this._checkForOutsideClick);
this.addEventListener('click', this._checkForChildrenClick);
// checks for any of the children to be prefilled
this._checkboxGroupPrefilled = super.prefilled;
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('focusin', this._setTouchedAndPrefilled);
document.removeEventListener('click', this._checkForOutsideClick);
this.removeEventListener('click', this._checkForChildrenClick);
}
get touched() {
return this._checkboxGroupTouched;
}
/**
* Leave event will be fired when previous document.activeElement
* is inside group and current document.activeElement is outside.
*/
_setTouchedAndPrefilled() {
const groupHasFocus = this.focused;
if (this.__groupHadFocus && !groupHasFocus) {
this._checkboxGroupTouched = true;
this._checkboxGroupPrefilled = super.prefilled; // right time to reconsider prefilled
this.__checkboxGroupPrefilledHasBeenSet = true;
}
this.__groupHadFocus = groupHasFocus;
}
_checkForOutsideClick(event) {
const outsideGroupClicked = !this.contains(event.target);
if (outsideGroupClicked) {
this._setTouchedAndPrefilled();
}
}
// Whenever a user clicks a checkbox, error messages should become visible
_checkForChildrenClick(event) {
const childClicked = this._childArray.some(c => c === event.target || c.contains(event.target));
if (childClicked) {
this._checkboxGroupTouched = true;
}
}
get _childArray() {
// We assume here that the fieldset has one set of checkboxes/radios that are grouped via attr
// name="groupName[]"
const arrayKey = Object.keys(this.formElements).filter(k => k.substr(-2) === '[]')[0];
return this.formElements[arrayKey] || [];
}
// eslint-disable-next-line class-methods-use-this
__isRequired(modelValues) {
const keys = Object.keys(modelValues);
for (let i = 0; i < keys.length; i += 1) {
const modelValue = modelValues[keys[i]];
if (Array.isArray(modelValue)) {
// grouped via myName[]
return {
required: modelValue.some(node => node.checked),
};
}
return {
required: modelValue.checked,
};
}
return { required: false };
}
}

View file

@ -0,0 +1,126 @@
import { storiesOf, html, action } from '@open-wc/storybook';
import '../lion-checkbox-group.js';
import '@lion/checkbox/lion-checkbox.js';
import '@lion/form/lion-form.js';
storiesOf('Forms|<lion-checkbox-group>', module)
.add(
'Default',
() => html`
<lion-form>
<form>
<lion-checkbox-group name="scientistsGroup" label="Who are your favorite scientists?">
<lion-checkbox
name="scientists[]"
label="Archimedes"
.choiceValue=${'Archimedes'}
></lion-checkbox>
<lion-checkbox
name="scientists[]"
label="Francis Bacon"
.choiceValue=${'Francis Bacon'}
></lion-checkbox>
<lion-checkbox
name="scientists[]"
label="Marie Curie"
.choiceValue=${'Marie Curie'}
></lion-checkbox>
</lion-checkbox-group>
</form>
</lion-form>
`,
)
.add(
'Pre Select',
() => html`
<lion-form>
<form>
<lion-checkbox-group name="scientistsGroup" label="Who are your favorite scientists?">
<lion-checkbox
name="scientists[]"
label="Archimedes"
.choiceValue=${'Archimedes'}
></lion-checkbox>
<lion-checkbox
name="scientists[]"
label="Francis Bacon"
.choiceValue=${'Francis Bacon'}
.choiceChecked=${true}
></lion-checkbox>
<lion-checkbox
name="scientists[]"
label="Marie Curie"
.modelValue=${{ value: 'Marie Curie', checked: true }}
></lion-checkbox>
</lion-checkbox-group>
</form>
</lion-form>
`,
)
.add(
'Disabled',
() => html`
<lion-form>
<form>
<lion-checkbox-group
name="scientistsGroup"
label="Who are your favorite scientists?"
disabled
>
<lion-checkbox
name="scientists[]"
label="Archimedes"
.choiceValue=${'Archimedes'}
></lion-checkbox>
<lion-checkbox
name="scientists[]"
label="Francis Bacon"
.choiceValue=${'Francis Bacon'}
></lion-checkbox>
<lion-checkbox
name="scientists[]"
label="Marie Curie"
.modelValue=${{ value: 'Marie Curie', checked: true }}
></lion-checkbox>
</lion-checkbox-group>
</form>
</lion-form>
`,
)
.add('Validation', () => {
const submit = () => {
const form = document.querySelector('#form');
if (form.errorState === false) {
action('serializeGroup')(form.serializeGroup());
}
};
return html`
<lion-form id="form" @submit="${submit}"
><form>
<lion-checkbox-group
name="scientistsGroup"
label="Who are your favorite scientists?"
.errorValidators=${[['required']]}
>
<lion-checkbox
name="scientists[]"
label="Archimedes"
.choiceValue=${'Archimedes'}
></lion-checkbox>
<lion-checkbox
name="scientists[]"
label="Francis Bacon"
.choiceValue=${'Francis Bacon'}
></lion-checkbox>
<lion-checkbox
name="scientists[]"
label="Marie Curie"
.choiceValue=${'Marie Curie'}
></lion-checkbox>
</lion-checkbox-group>
<button type="submit">Submit</button>
</form></lion-form
>
`;
});

View file

@ -0,0 +1,110 @@
/* eslint-env mocha */
/* eslint-disable no-underscore-dangle, no-unused-expressions */
import { expect, html, fixture, triggerFocusFor, nextFrame } from '@open-wc/testing';
import sinon from 'sinon';
import { localizeTearDown } from '@lion/localize/test-helpers.js';
import '@lion/checkbox/lion-checkbox.js';
import '../lion-checkbox-group.js';
beforeEach(() => {
localizeTearDown();
});
describe('<lion-checkbox-group>', () => {
// Note: these requirements seem to hold for checkbox-group only, not for radio-group (since we
// cannot tab through all input elements).
it(`becomes "touched" once the last element of a group becomes blurred by keyboard
interaction (e.g. tabbing through the checkbox-group)`, async () => {
const el = await fixture(`
<lion-checkbox-group>
<label slot="label">My group</label>
<lion-checkbox name="myGroup[]" label="Option 1" value="1"></lion-checkbox>
<lion-checkbox name="myGroup[]" label="Option 2" value="2"></lion-checkbox>
</lion-checkbox-group>
`);
await nextFrame();
const button = await fixture(`<button>Blur</button>`);
el.children[1].focus();
expect(el.touched).to.equal(false, 'initially, touched state is false');
el.children[2].focus();
expect(el.touched).to.equal(false, 'focus is on second checkbox');
button.focus();
expect(el.touched).to.equal(
true,
`focus is on element behind second checkbox
(group has blurred)`,
);
});
it(`becomes "touched" once the group as a whole becomes blurred via mouse interaction after
keyboard interaction (e.g. focus is moved inside the group and user clicks somewhere outside
the group)`, async () => {
const groupWrapper = await fixture(`
<div tabindex="0">
<lion-checkbox-group>
<label slot="label">My group</label>
<lion-checkbox name="myGroup[]" label="Option 1" value="1"></lion-checkbox>
<lion-checkbox name="myGroup[]" label="Option 2" vallue="2"></lion-checkbox>
</lion-checkbox-group>
</div>
`);
await nextFrame();
const el = groupWrapper.children[0];
await el.children[1].updateComplete;
el.children[1].focus();
expect(el.touched).to.equal(false, 'initially, touched state is false');
el.children[2].focus(); // simulate tab
expect(el.touched).to.equal(false, 'focus is on second checkbox');
// simulate click outside
sinon.spy(el, '_setTouchedAndPrefilled');
groupWrapper.click(); // blur the group via a click
expect(el._setTouchedAndPrefilled.callCount).to.equal(1);
// For some reason, document.activeElement is not updated after groupWrapper.click() (this
// happens on user clicks, not on imperative clicks). So we check if the private callbacks
// for outside clicks are called (they trigger _setTouchedAndPrefilled call).
// To make sure focus is moved, we 'help' the test here to mimic browser behavior.
// groupWrapper.focus();
await triggerFocusFor(groupWrapper);
expect(el.touched).to.equal(true, 'focus is on element outside checkbox group');
});
it(`becomes "touched" once a single element of the group becomes "touched" via mouse interaction
(e.g. user clicks on checkbox)`, async () => {
const el = await fixture(`
<lion-checkbox-group>
<lion-checkbox name="myGroup[]"></lion-checkbox>
<lion-checkbox name="myGroup[]"></lion-checkbox>
</lion-checkbox-group>
`);
await nextFrame();
el.children[1].focus();
expect(el.touched).to.equal(false, 'initially, touched state is false');
el.children[1].click();
expect(el.touched).to.equal(
true,
`focus is initiated via a mouse event, thus
fieldset/checkbox-group as a whole is considered touched`,
);
});
it('can be required', async () => {
const el = await fixture(html`
<lion-checkbox-group .errorValidators=${[['required']]}>
<lion-checkbox name="sports[]" .choiceValue=${'running'}></lion-checkbox>
<lion-checkbox name="sports[]" .choiceValue=${'swimming'}></lion-checkbox>
</lion-checkbox-group>
`);
await nextFrame();
expect(el.error.required).to.be.true;
el.formElements['sports[]'][0].choiceChecked = true;
expect(el.error.required).to.be.undefined;
});
});

View file

@ -0,0 +1,32 @@
# Checkbox
[//]: # (AUTO INSERT HEADER PREPUBLISH)
`lion-checkbox` component is a sub-element to be used in [lion-checkbox-group](../checkbox-group/) elements. Its purpose is to provide a way for users to check **multiple** options amongst a set of choices, or to function as a single toggle.
## Features
- Get or set the checked state (boolean) - `choiceChecked()`
- Get or set the value of the choice - `choiceValue()`
- Pre-select an option by setting the `checked` boolean attribute
## How to use
### Installation
```
npm i --save @lion/checkbox;
```
```js
import '@lion/checkbox/lion-checkbox.js';
```
### Example
```html
<lion-checkbox name="scientists[]" label="Archimedes" .choiceValue=${'Archimedes'}></lion-checkbox>
<lion-checkbox name="scientists[]" label="Francis Bacon" .choiceValue=${'Francis Bacon'}></lion-checkbox>
<lion-checkbox name="scientists[]" label="Marie Curie" .choiceValue=${'Marie Curie'}></lion-checkbox>
```
- Use this component inside a [lion-checkbox-group](../checkbox-group/)
- Make sure that it has a name attribute with appended `[]` for multiple choices.

View file

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

View file

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

View file

@ -0,0 +1,41 @@
{
"name": "@lion/checkbox",
"version": "0.0.0",
"description": "A single styleable and accessible checkbox",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/checkbox"
},
"scripts": {
"prepublishOnly": "../../scripts/insert-header.js"
},
"keywords": [
"lion",
"web-components",
"checkbox"
],
"main": "index.js",
"module": "index.js",
"files": [
"stories",
"test",
"*.js"
],
"dependencies": {
"@lion/core": "0.0.0",
"@lion/choice-input": "0.0.0",
"@lion/input": "0.0.0"
},
"devDependencies": {
"@open-wc/testing": "^0.11.1",
"@open-wc/storybook": "^0.1.5"
}
}

View file

@ -0,0 +1,9 @@
import { LionInput } from '@lion/input';
import { ChoiceInputMixin } from '@lion/choice-input';
export class LionCheckbox extends ChoiceInputMixin(LionInput) {
connectedCallback() {
if (super.connectedCallback) super.connectedCallback();
this.type = 'checkbox';
}
}

View file

@ -0,0 +1,5 @@
# Choice Input
[//]: # (AUTO INSERT HEADER PREPUBLISH)
We still need help writing better documentation - care to help?

View file

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

View file

@ -0,0 +1,40 @@
{
"name": "@lion/choice-input",
"version": "0.0.0",
"description": "Base for all choise inputs like checkbox/radio",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/choice-input"
},
"scripts": {
"prepublishOnly": "../../scripts/insert-header.js"
},
"keywords": [
"lion",
"web-components",
"choice-input"
],
"main": "index.js",
"module": "index.js",
"files": [
"stories",
"test",
"*.js"
],
"dependencies": {
"@lion/core": "0.0.0",
"@lion/field": "0.0.0"
},
"devDependencies": {
"@lion/input": "0.0.0",
"@open-wc/testing": "^0.11.1",
"@open-wc/storybook": "^0.1.5"
}
}

View file

@ -0,0 +1,181 @@
import { html, css, nothing } from '@lion/core';
import { ObserverMixin } from '@lion/core/src/ObserverMixin.js';
import { FormatMixin } from '@lion/field';
/* eslint-disable no-underscore-dangle, class-methods-use-this */
export const ChoiceInputMixin = superclass =>
// eslint-disable-next-line
class ChoiceInputMixin extends FormatMixin(ObserverMixin(superclass)) {
get delegations() {
return {
...super.delegations,
target: () => this.inputElement,
properties: [...super.delegations.properties, 'checked'],
attributes: [...super.delegations.attributes, 'checked'],
};
}
get events() {
return {
...super.events,
_toggleChecked: [() => this, 'user-input-changed'],
};
}
static get syncObservers() {
return {
...super.syncObservers,
_syncModelValueToChecked: ['modelValue'],
};
}
static get asyncObservers() {
return {
...super.asyncObservers,
_reflectCheckedToCssClass: ['modelValue'],
};
}
get choiceChecked() {
return this.modelValue.checked;
}
set choiceChecked(checked) {
if (this.modelValue.checked !== checked) {
this.modelValue = { value: this.modelValue.value, checked };
}
}
get choiceValue() {
return this.modelValue.value;
}
set choiceValue(value) {
if (this.modelValue.value !== value) {
this.modelValue = { value, checked: this.modelValue.checked };
}
}
constructor() {
super();
this.modelValue = { value: '', checked: false };
}
/**
* @override
* Override InteractionStateMixin
* 'prefilled' should be false when modelValue is { checked: false }, which would return
* true in original method (since non-empty objects are considered prefilled by default).
*/
static _isPrefilled(modelValue) {
return modelValue.checked;
}
static get styles() {
return [
css`
:host {
display: flex;
}
.choice-field__graphic-container {
display: none;
}
`,
];
}
render() {
return html`
<slot name="input"></slot>
<div class="choice-field__graphic-container">
${this.choiceGraphicTemplate()}
</div>
<div class="choice-field__label">
<slot name="label"></slot>
</div>
`;
}
choiceGraphicTemplate() {
return nothing;
}
connectedCallback() {
if (super.connectedCallback) super.connectedCallback();
this._reflectCheckedToCssClass();
}
_toggleChecked() {
this.choiceChecked = !this.choiceChecked;
}
_syncModelValueToChecked({ modelValue }) {
this.checked = !!modelValue.checked;
}
/**
* @override
* This method is overridden from FormatMixin. It originally fired the normalizing
* 'user-input-changed' event after listening to the native 'input' event.
* However on Chrome on Mac whenever you use the keyboard
* it fires the input AND change event. Other Browsers only fires the change event.
* Therefore we disable the input event here.
*/
_proxyInputEvent() {}
/**
* @override
* Override FormatMixin default dispatching of model-value-changed as it only does a simple
* comparision which is not enough in js because
* { value: 'foo', checked: true } !== { value: 'foo', checked: true }
* We do our own "deep" comparision.
*
* @param {object} modelValue
* @param {object} modelValue the old one
*/
// TODO: consider making a generic option inside FormatMixin for deep object comparisons when
// modelValue is an object
_dispatchModelValueChangedEvent({ modelValue }, { modelValue: old }) {
let changed = true;
if (old) {
changed = modelValue.value !== old.value || modelValue.checked !== old.checked;
}
if (changed) {
this.dispatchEvent(
new CustomEvent('model-value-changed', { bubbles: true, composed: true }),
);
}
}
_reflectCheckedToCssClass() {
this.classList[this.choiceChecked ? 'add' : 'remove']('state-checked');
}
/**
* @override
* Overridden from FormatMixin, since a different modelValue is used for choice inputs.
* Sets modelValue based on checked state (instead of value), so that changes will be detected.
*/
parser() {
return this.modelValue;
}
/**
* @override
* Overridden from FormatMixin, since a different modelValue is used for choice inputs.
*/
formatter(modelValue) {
return modelValue && modelValue.value !== undefined ? modelValue.value : modelValue;
}
/**
* @override
* Overridden from ValidateMixin, since a different modelValue is used for choice inputs.
*/
__isRequired(modelValue) {
return {
required: !!modelValue.checked,
};
}
};

View file

@ -0,0 +1,215 @@
/* eslint-disable no-unused-expressions */
import { expect, fixture } from '@open-wc/testing';
import { html } from '@lion/core';
import { LionInput } from '@lion/input';
import { ChoiceInputMixin } from '../src/ChoiceInputMixin.js';
describe('ChoiceInputMixin', () => {
before(() => {
class ChoiceInput extends ChoiceInputMixin(LionInput) {
connectedCallback() {
if (super.connectedCallback) super.connectedCallback();
this.type = 'checkbox'; // could also be 'radio', should be tested in integration test
}
}
customElements.define('choice-input', ChoiceInput);
});
it('has choiceValue', async () => {
const el = await fixture(html`
<choice-input .choiceValue=${'foo'}></choice-input>
`);
expect(el.choiceValue).to.equal('foo');
expect(el.modelValue).to.deep.equal({
value: 'foo',
checked: false,
});
});
it('can handle complex data via choiceValue', async () => {
const date = new Date(2018, 11, 24, 10, 33, 30, 0);
const el = await fixture(html`
<choice-input .choiceValue=${date}></choice-input>
`);
expect(el.choiceValue).to.equal(date);
expect(el.modelValue.value).to.equal(date);
});
it('fires one "model-value-changed" event if choiceValue or choiceChecked or modelValue changed', async () => {
let counter = 0;
const el = await fixture(html`
<choice-input
@model-value-changed=${() => {
counter += 1;
}}
.choiceValue=${'foo'}
></choice-input>
`);
expect(counter).to.equal(1); // undefined to set value
el.choiceChecked = true;
expect(counter).to.equal(2);
// no change means no event
el.choiceChecked = true;
el.choiceValue = 'foo';
el.modelValue = { value: 'foo', checked: true };
expect(counter).to.equal(2);
el.modelValue = { value: 'foo', checked: false };
expect(counter).to.equal(3);
});
it('fires one "user-input-changed" event after user interaction', async () => {
let counter = 0;
const el = await fixture(html`
<choice-input
@user-input-changed=${() => {
counter += 1;
}}
></choice-input>
`);
expect(counter).to.equal(0);
// Here we try to mimic user interaction by firing browser events
const nativeInput = el.inputElement;
nativeInput.dispatchEvent(new CustomEvent('input', { bubbles: true })); // fired by (at least) Chrome
expect(counter).to.equal(0);
nativeInput.dispatchEvent(new CustomEvent('change', { bubbles: true }));
expect(counter).to.equal(1);
});
it('can be required', async () => {
const el = await fixture(html`
<choice-input .choiceValue=${'foo'} .errorValidators=${[['required']]}></choice-input>
`);
expect(el.error.required).to.be.true;
el.choiceChecked = true;
expect(el.error.required).to.be.undefined;
});
describe('Checked state synchronization', () => {
it('synchronizes checked state initially (via attribute or property)', async () => {
const el = await fixture(`<choice-input></choice-input>`);
expect(el.choiceChecked).to.equal(false, 'initially unchecked');
const precheckedElementAttr = await fixture(html`
<choice-input .choiceChecked=${true}></choice-input>
`);
expect(precheckedElementAttr.choiceChecked).to.equal(true, 'initially checked via attribute');
});
it('can be checked and unchecked programmatically', async () => {
const el = await fixture(`<choice-input></choice-input>`);
expect(el.choiceChecked).to.be.false;
el.choiceChecked = true;
expect(el.choiceChecked).to.be.true;
});
it('can be checked and unchecked via user interaction', async () => {
const el = await fixture(`<choice-input></choice-input>`);
el.inputElement.click();
expect(el.choiceChecked).to.be.true;
el.inputElement.click();
expect(el.choiceChecked).to.be.false;
});
it('synchronizes modelValue to checked state and vice versa', async () => {
const el = await fixture(html`
<choice-input .choiceValue=${'foo'}></choice-input>
`);
expect(el.choiceChecked).to.be.false;
expect(el.modelValue).to.deep.equal({
checked: false,
value: 'foo',
});
el.choiceChecked = true;
expect(el.choiceChecked).to.be.true;
expect(el.modelValue).to.deep.equal({
checked: true,
value: 'foo',
});
});
it('synchronizes checked state to class "state-checked" for styling purposes', async () => {
const hasClass = el => [].slice.call(el.classList).indexOf('state-checked') > -1;
const el = await fixture(`<choice-input></choice-input>`);
const elChecked = await fixture(html`
<choice-input .choiceChecked=${true}></choice-input>
`);
// Initial values
expect(hasClass(el)).to.equal(false, 'inital unchecked element');
expect(hasClass(elChecked)).to.equal(true, 'inital checked element');
// Programmatically via checked
el.choiceChecked = true;
elChecked.choiceChecked = false;
await el.updateComplete;
expect(hasClass(el)).to.equal(true, 'programmatically checked');
expect(hasClass(elChecked)).to.equal(false, 'programmatically unchecked');
// reset
el.choiceChecked = false;
elChecked.choiceChecked = true;
// Via user interaction
el.inputElement.click();
elChecked.inputElement.click();
await el.updateComplete;
expect(hasClass(el)).to.equal(true, 'user click checked');
expect(hasClass(elChecked)).to.equal(false, 'user click unchecked');
// reset
el.choiceChecked = false;
elChecked.choiceChecked = true;
// Programmatically via modelValue
el.modelValue = { value: '', checked: true };
elChecked.modelValue = { value: '', checked: false };
await el.updateComplete;
expect(hasClass(el)).to.equal(true, 'modelValue checked');
expect(hasClass(elChecked)).to.equal(false, 'modelValue unchecked');
});
});
describe('Format/parse/serialize loop', () => {
it('creates a modelValue object like { checked: true, value: foo } on init', async () => {
const el = await fixture(html`
<choice-input .choiceValue=${'foo'}></choice-input>
`);
expect(el.modelValue).deep.equal({ value: 'foo', checked: false });
const elChecked = await fixture(html`
<choice-input .choiceValue=${'foo'} .choiceChecked=${true}></choice-input>
`);
expect(elChecked.modelValue).deep.equal({ value: 'foo', checked: true });
});
it('creates a formattedValue based on modelValue.value', async () => {
const el = await fixture(`<choice-input></choice-input>`);
expect(el.formattedValue).to.equal('');
const elementWithValue = await fixture(html`
<choice-input .choiceValue=${'foo'}></choice-input>
`);
expect(elementWithValue.formattedValue).to.equal('foo');
});
});
describe('Interaction states', () => {
it('is considered prefilled when checked and not considered prefilled when unchecked', async () => {
const el = await fixture(html`
<choice-input .choiceChecked=${true}></choice-input>
`);
expect(el.prefilled).equal(true, 'checked element not considered prefilled');
const elUnchecked = await fixture(`<choice-input></choice-input>`);
expect(elUnchecked.prefilled).equal(false, 'unchecked element not considered prefilled');
});
});
});

57
packages/core/README.md Normal file
View file

@ -0,0 +1,57 @@
# Core
[//]: # (AUTO INSERT HEADER PREPUBLISH)
## Deprecations
The following files/features are deprecated
- CssClassMixin
- DomHelpersMixin (only $$id, $$slot is deprecated)
- ElementMixin
- EventMixin
- ObserverMixin
- lit-html.js
## Deduping of mixins
### Why is deduping of mixins necessary?
Imagine you are developing web components and creating ES classes for Custom Elements. You have two generic mixins (let's say `M1` and `M2`) which require independently the same even more generic mixin (`BaseMixin`). `M1` and `M2` can be used independently, that means they have to inherit from `BaseMixin` also independently. But they can be also used in combination. Sometimes `M1` and `M2` are used in the same component and can mess up the inheritance chain if `BaseMixin` is applied twice.
In other words, this may happen to the protoype chain `... -> M2 -> BaseMixin -> M1 -> BaseMixin -> ...`.
An example of this may be a `LocalizeMixin` used across different components and mixins. Some mixins may need it and many components need it too and can not rely on other mixins to have it by default, so must inherit from it independently.
The more generic the mixin is, the higher the chance of being appliend more than once. As a mixin author you can't control how it is used, and can't always predict it. So as a safety measure it is always recommended to create deduping mixins.
### Usage of dedupeMixin()
This is an example of how to make a conventional ES mixin deduping.
```javascript
const BaseMixin = dedupeMixin((superClass) => {
return class extends superClass { ... };
});
// inherits from BaseMixin
const M1 = dedupeMixin((superClass) => {
return class extends BaseMixin(superClass) { ... };
});
// inherits from BaseMixin
const M2 = dedupeMixin((superClass) => {
return class extends BaseMixin(superClass) { ... };
});
// component inherits from M1
// MyCustomElement -> M1 -> BaseMixin -> BaseCustomElement;
class MyCustomElement extends M1(BaseCustomElement) { ... }
// component inherits from M2
// MyCustomElement -> M2 -> BaseMixin -> BaseCustomElement;
class MyCustomElement extends M2(BaseCustomElement) { ... }
// component inherits from both M1 and M2
// MyCustomElement -> M2 -> M1 -> BaseMixin -> BaseCustomElement;
class MyCustomElement extends M2(M1(BaseCustomElement)) { ... }
```

21
packages/core/index.js Normal file
View file

@ -0,0 +1,21 @@
// lit-html
export { html, render, nothing, isDirective } from 'lit-html';
export { render as renderShady } from 'lit-html/lib/shady-render.js';
export { asyncAppend } from 'lit-html/directives/async-append.js';
export { asyncReplace } from 'lit-html/directives/async-replace.js';
export { cache } from 'lit-html/directives/cache.js';
export { classMap } from 'lit-html/directives/class-map.js';
export { guard } from 'lit-html/directives/guard.js';
export { ifDefined } from 'lit-html/directives/if-defined.js';
export { repeat } from 'lit-html/directives/repeat.js';
export { styleMap } from 'lit-html/directives/style-map.js';
export { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
export { until } from 'lit-html/directives/until.js';
// lit-element
export { css, LitElement, UpdatingElement } from 'lit-element';
// ours
export { dedupeMixin } from './src/dedupeMixin.js';
export { DelegateMixin } from './src/DelegateMixin.js';
export { DomHelpersMixin } from './src/DomHelpersMixin.js';
export { LionSingleton } from './src/LionSingleton.js';
export { SlotMixin } from './src/SlotMixin.js';

View file

@ -0,0 +1,39 @@
{
"name": "@lion/core",
"version": "0.0.0",
"description": "Core functionality that is shared across all Lion Web Components",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/core"
},
"scripts": {
"prepublishOnly": "../../scripts/insert-header.js"
},
"keywords": [
"lion",
"web-components"
],
"main": "index.js",
"module": "index.js",
"files": [
"stories",
"test",
"*.js"
],
"dependencies": {
"lit-element": "2.1.0",
"lit-html": "1.0.0"
},
"devDependencies": {
"@open-wc/testing": "^0.11.1",
"@open-wc/storybook": "^0.1.5",
"sinon": "^7.2.2"
}
}

View file

@ -0,0 +1,60 @@
import { dedupeMixin } from './dedupeMixin.js';
/* eslint-disable no-underscore-dangle */
/**
* # CssClassMixin
* `CssClassMixin` is a base mixin for the use of css in lion-components.
*
* **Deprecated**: A custom element should not modify it's own classes
*
* @deprecated
* @type {function()}
* @polymerMixin
* @mixinFunction
*/
export const CssClassMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line
class CssClassMixin extends superclass {
update(changedProps) {
super.update(changedProps);
this._updateCssClasses(changedProps);
}
/**
* This function will check for 'empty': it returns true when an array or object has
* no keys or when a value is falsy.
*
* @param {Object} value
* @returns {boolean}
* @private
*/
static _isEmpty(value) {
if (typeof value === 'object') {
return Object.keys(value).length === 0;
}
return !value;
}
/**
* This function updates css classes
*
* @param {Object} newValues
* @private
*/
_updateCssClasses(changedProps) {
Array.from(changedProps.keys()).forEach(property => {
const klass = this.constructor.properties[property].nonEmptyToClass;
if (klass) {
if (this.constructor._isEmpty(this[property])) {
this.classList.remove(klass);
} else {
this.classList.add(klass);
}
}
});
}
},
);

View file

@ -0,0 +1,202 @@
/* eslint-disable no-underscore-dangle, class-methods-use-this */
import { dedupeMixin } from './dedupeMixin.js';
/**
* # DelegateMixin
* Forwards defined events, methods, properties and attributes to the defined target.
*
* @example
* get delegations() {
* return {
* ...super.delegations,
* target: () => this.$id('button1'),
* events: ['click'],
* methods: ['click'],
* properties: ['disabled'],
* attributes: ['disabled'],
* };
* }
* render() {
* return html`
* <button id="button1">with delegation</button>
* `;
* }
*
* @type {function()}
* @polymerMixin
* @mixinFunction
*/
export const DelegateMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line
class DelegateMixin extends superclass {
constructor() {
super();
this.__eventsQueue = [];
this.__propertiesQueue = {};
this.__setupPropertyDelegation();
}
/**
* @returns {{target: null, events: Array, methods: Array, properties: Array, attributes: Array}}
*/
get delegations() {
return {
target: null,
events: [],
methods: [],
properties: [],
attributes: [],
};
}
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
this._connectDelegateMixin();
}
updated(...args) {
super.updated(...args);
this._connectDelegateMixin();
}
/**
* @param {string} type
* @param {Object} args
*/
addEventListener(type, ...args) {
const delegatedEvents = this.delegations.events;
if (delegatedEvents.indexOf(type) > -1) {
if (this.delegationTarget) {
this.delegationTarget.addEventListener(type, ...args);
} else {
this.__eventsQueue.push({ type, args });
}
} else {
super.addEventListener(type, ...args);
}
}
/**
* @param {string} name
* @param {string} value
*/
setAttribute(name, value) {
const attributeNames = this.delegations.attributes;
if (attributeNames.indexOf(name) > -1) {
if (this.delegationTarget) {
this.delegationTarget.setAttribute(name, value);
}
super.removeAttribute(name);
} else {
super.setAttribute(name, value);
}
}
/**
* @param {string} name
*/
removeAttribute(name) {
const attributeNames = this.delegations.attributes;
if (attributeNames.indexOf(name) > -1) {
if (this.delegationTarget) {
this.delegationTarget.removeAttribute(name);
}
}
super.removeAttribute(name);
}
/**
* @protected
*/
_connectDelegateMixin() {
if (this.__connectedDelegateMixin) return;
if (!this.delegationTarget) {
this.delegationTarget = this.delegations.target();
}
if (this.delegationTarget) {
this.__emptyEventListenerQueue();
this.__emptyPropertiesQueue();
this.__initialAttributeDelegation();
this.__connectedDelegateMixin = true;
}
}
/**
* @private
*/
__setupPropertyDelegation() {
const propertyNames = this.delegations.properties.concat(this.delegations.methods);
propertyNames.forEach(propertyName => {
Object.defineProperty(this, propertyName, {
get() {
const target = this.delegationTarget;
if (target) {
if (typeof target[propertyName] === 'function') {
return target[propertyName].bind(target);
}
return target[propertyName];
}
if (this.__propertiesQueue[propertyName]) {
return this.__propertiesQueue[propertyName];
}
// This is the moment the attribute is not delegated (and thus removed) yet.
// and the property is not set, but the attribute is (it serves as a fallback for
// __propertiesQueue).
return this.getAttribute(propertyName);
},
set(newValue) {
if (this.delegationTarget) {
const oldValue = this.delegationTarget[propertyName];
this.delegationTarget[propertyName] = newValue;
// connect with observer system if available
if (typeof this.triggerObserversFor === 'function') {
this.triggerObserversFor(propertyName, newValue, oldValue);
}
} else {
this.__propertiesQueue[propertyName] = newValue;
}
},
});
});
}
/**
* @private
*/
__initialAttributeDelegation() {
const attributeNames = this.delegations.attributes;
attributeNames.forEach(attributeName => {
const attributeValue = this.getAttribute(attributeName);
if (typeof attributeValue === 'string') {
this.delegationTarget.setAttribute(attributeName, attributeValue);
super.removeAttribute(attributeName);
}
});
}
/**
* @private
*/
__emptyEventListenerQueue() {
this.__eventsQueue.forEach(ev => {
this.delegationTarget.addEventListener(ev.type, ...ev.args);
});
}
/**
* @private
*/
__emptyPropertiesQueue() {
Object.keys(this.__propertiesQueue).forEach(propName => {
this.delegationTarget[propName] = this.__propertiesQueue[propName];
});
}
},
);

View file

@ -0,0 +1,127 @@
/* eslint-disable no-underscore-dangle */
import { dedupeMixin } from './dedupeMixin.js';
/**
*
* @returns {{$id: {}, $name: {}, $$id: {}, $$slot: {}}}
*/
function generateEmptyCache() {
return {
$id: {},
$name: {},
$$id: {},
$$slot: {},
};
}
/**
* # DomHelpersMixin
* `DomHelpersMixin` provides access to element in shadow and light DOM with "id" attribute,
* it provides access to element in shadow DOM with "name" attribute and
* provides access to element in Light DOM with "slot" attribute.
* It memorizes element reference in cache and can be removed from cache
* (individually or completely) via _clearDomCache().
*
* @example
* this.$id('foo') to access the element with the id 'foo' in shadow DOM
* this.$name('foo') to access the element with name 'foo' in shadow DOM
* this.$$id('foo') to access the element with the id 'foo' when not in shadow DOM
* this.$$slot('foo') to access the element with the slot 'foo' when in light DOM
*
* @type {function()}
* @polymerMixin
* @mixinFunction
*/
export const DomHelpersMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line
class DomHelpersMixin extends superclass {
constructor() {
super();
this.__domHelpersCache = generateEmptyCache();
}
/**
* To access an element with the id 'foo' in shadow DOM
*
* @param {number} id
* @returns {*|undefined}
*/
$id(id) {
let element = this.__domHelpersCache.$id[id];
if (!element) {
element = this.shadowRoot.getElementById(id);
this.__domHelpersCache.$id[id] = element;
}
return element || undefined;
}
/**
* Provides access to the named slot node in shadow DOM for this name
*
* @param {string} name
* @returns {*|undefined}
*/
$name(name) {
let element = this.__domHelpersCache.$name[name];
if (!element) {
element = this.shadowRoot.querySelector(`[name="${name}"]`);
this.__domHelpersCache.$name[name] = element;
}
return element || undefined;
}
/**
* To access an element with the id 'foo' in light DOM
*
* **Deprecated**: LightDom may change underneath you - you should not cache it
*
* @deprecated
* @param {number} id
* @returns {*|undefined}
*/
$$id(id) {
let element = this.__domHelpersCache.$$id[id];
if (!element) {
element = this.querySelector(`#${id}`);
this.__domHelpersCache.$$id[id] = element;
}
return element || undefined;
}
/**
* To access the element with the slot 'foo' when in light DOM
*
* **Deprecated**: LightDom may change underneath you - you should not cache it
*
* @deprecated
* @param {string} slot
* @returns {*|undefined}
*/
$$slot(slot) {
let element = this.__domHelpersCache.$$slot[slot];
if (!element) {
element = Array.from(this.children).find(child => child.slot === slot);
this.__domHelpersCache.$$slot[slot] = element;
}
return element || undefined;
}
/**
* Remove from cache (individually or completely) via _clearDomCache()
*
* @param {string} type
* @param {number} id
* @private
*/
_clearDomCache(type, id) {
if (type) {
this.__domHelpersCache[type][id] = undefined;
} else {
this.__domHelpersCache = generateEmptyCache();
}
}
},
);

View file

@ -0,0 +1,64 @@
/* eslint-disable no-underscore-dangle, class-methods-use-this */
/* global ShadyCSS */
import { dedupeMixin } from './dedupeMixin.js';
import { DomHelpersMixin } from './DomHelpersMixin.js';
/**
* @deprecated please apply DomHelpersMixin and UpdateStylesMixin if needed yourself
*/
export const ElementMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow
class ElementMixin extends DomHelpersMixin(superclass) {
/**
* @example
* <my-element>
* <style>
* :host {
* color: var(--foo);
* }
* </style>
* </my-element>
*
* $0.updateStyles({'background': 'orange', '--foo': '#fff'})
* Chrome, Firefox: <my-element style="background: orange; --foo: bar;">
* IE: <my-element>
* => to head: <style>color: #fff</style>
*
* @param {Object} updateStyles
*/
updateStyles(updateStyles) {
const styleString = this.getAttribute('style') || this.getAttribute('data-style') || '';
const currentStyles = styleString.split(';').reduce((acc, stylePair) => {
const parts = stylePair.split(':');
if (parts.length === 2) {
/* eslint-disable-next-line prefer-destructuring */
acc[parts[0]] = parts[1];
}
return acc;
}, {});
const newStyles = { ...currentStyles, ...updateStyles };
let newStylesString = '';
if (typeof ShadyCSS === 'object' && !ShadyCSS.nativeShadow) {
// No ShadowDOM => IE, Edge
const newCssVariablesObj = {};
Object.keys(newStyles).forEach(key => {
if (key.indexOf('--') === -1) {
newStylesString += `${key}:${newStyles[key]};`;
} else {
newCssVariablesObj[key] = newStyles[key];
}
});
this.setAttribute('style', newStylesString);
ShadyCSS.styleSubtree(this, newCssVariablesObj);
} else {
// has shadowdom => Chrome, Firefox, Safari
Object.keys(newStyles).forEach(key => {
newStylesString += `${key}: ${newStyles[key]};`;
});
this.setAttribute('style', newStylesString);
}
}
},
);

View file

@ -0,0 +1,129 @@
/* eslint-disable no-underscore-dangle, class-methods-use-this */
import { dedupeMixin } from './dedupeMixin.js';
/**
* # EventMixin
* `EventMixin` provides a declarative way for registering event handlers,
* keeping performance and ease of use in mind
*
* **Deprecated**: Please use add/removeEventListener in connected/disconnectedCallback
*
* @deprecated
* @example
* get events() {
* return {
* ...super.events,
* '_onButton1Click': [() => this.$id('button1'), 'click'],
* '_onButton2Focus': [() => this.$id('button2'), 'focus'],
* '_onButton2Blur': [() => this.$id('button2'), 'blur'],
* };
* }
* render() {
* return html`
* <button id="button1">with click event</button>
* <button id="button2">with focus + blur event</button>
* `;
* }
*
* @polymerMixin
* @mixinFunction
*/
export const EventMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line
class EventMixin extends superclass {
/**
* @returns {{}}
*/
get events() {
return {};
}
constructor() {
super();
this.__eventsCache = [];
this.__boundEvents = {};
Object.keys(this.events).forEach(eventFunctionName => {
this.__boundEvents[eventFunctionName] = this[eventFunctionName].bind(this);
});
}
updated() {
if (super.updated) super.updated();
this.__registerEvents();
}
connectedCallback() {
if (super.connectedCallback) super.connectedCallback();
this.__registerEvents();
}
disconnectedCallback() {
if (super.disconnectedCallback) super.disconnectedCallback();
this.__unregisterEvents();
}
/**
* @private
*/
__registerEvents() {
Object.keys(this.events).forEach(eventFunctionName => {
const [targetFunction, eventNames] = this.events[eventFunctionName];
const target = targetFunction();
if (target) {
const eventFunction = this.__boundEvents[eventFunctionName];
const eventNamesToProcess = typeof eventNames === 'string' ? [eventNames] : eventNames;
eventNamesToProcess.forEach(eventName => {
if (!this.constructor._isProcessed(target, eventName, eventFunction)) {
target.addEventListener(eventName, eventFunction);
this.__eventsCache.push([target, eventName, eventFunctionName]);
this.constructor._markProcessed(target, eventName, eventFunction);
}
});
}
});
}
/**
* @param {Object} target
* @param {string} eventName
* @param {function()} eventFunction
* @returns {*|Boolean|boolean}
* @private
*/
static _isProcessed(target, eventName, eventFunction) {
const mixinData = target.__eventMixinProcessed;
return mixinData && mixinData[eventName] && mixinData[eventName].has(eventFunction);
}
/**
* @param {Object} target
* @param {string} eventName
* @param {function()} eventFunction
* @private
*/
static _markProcessed(target, eventName, eventFunction) {
let mixinData = target.__eventMixinProcessed;
mixinData = mixinData || {};
mixinData[eventName] = mixinData[eventName] || new Set();
mixinData[eventName].add(eventFunction);
target.__eventMixinProcessed = mixinData; // eslint-disable-line no-param-reassign
}
/**
* @private
*/
__unregisterEvents() {
let data = this.__eventsCache.pop();
while (data) {
const [target, eventName, eventFunctionName] = data;
const eventFunction = this.__boundEvents[eventFunctionName];
target.removeEventListener(eventName, eventFunction);
delete target.__eventMixinProcessed;
data = this.__eventsCache.pop();
}
}
},
);

View file

@ -0,0 +1,10 @@
import { LitElement } from 'lit-element';
import { ElementMixin } from './ElementMixin.js';
export { css } from 'lit-element';
export { html } from './lit-html.js';
/**
* @deprecated
*/
export class LionLitElement extends ElementMixin(LitElement) {}

View file

@ -0,0 +1,50 @@
/* eslint-disable no-underscore-dangle */
/**
* 'LionSingleton' provides an instance of the given class via .getInstance(foo, bar) and will
* return the same instance if already created. It can reset its instance so a new one will be
* created via .resetInstance() and can at any time add mixins via .addInstanceMixin().
* It can provide new instances (with applied Mixins) via .getNewInstance().
*/
export class LionSingleton {
/**
* @param {function()} mixin
*/
static addInstanceMixin(mixin) {
if (!this.__instanceMixins) {
this.__instanceMixins = [];
}
this.__instanceMixins.push(mixin);
}
/**
* @param {...*} args
* @returns {LionSingleton}
*/
static getNewInstance(...args) {
let Klass = this;
if (Array.isArray(this.__instanceMixins)) {
this.__instanceMixins.forEach(mixin => {
Klass = mixin(Klass);
});
}
return new Klass(...args);
}
/**
* @param {...*} args
* @returns {*}
*/
static getInstance(...args) {
if (this.__instance) {
return this.__instance;
}
this.__instance = this.getNewInstance(...args);
return this.__instance;
}
static resetInstance() {
this.__instance = undefined;
}
}

View file

@ -0,0 +1,200 @@
/* eslint-disable no-underscore-dangle */
import { dedupeMixin } from './dedupeMixin.js';
/**
*
* @type {Symbol}
*/
const undefinedSymbol = Symbol('this value should actually be undefined when passing on');
/**
* # ObserverMixin
* `ObserverMixin` warns the developer if something unexpected happens and provides
* triggerObserversFor() which can be used within a setter to hook into the observer system.
* It has syncObservers, which call observers immediately when the observed property
* is changed (newValue !== oldValue) and asyncObservers, which makes only one call
* to observer even if multiple observed attributes changed.
*
* **Deprecated**: Please use LitElement update/updated instead.
*
* @deprecated
* @type {function()}
* @polymerMixin
* @mixinFunction
*/
export const ObserverMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line
class ObserverMixin extends superclass {
/**
* @returns {{}}
*/
static get syncObservers() {
return {};
}
/**
* @returns {{}}
*/
static get asyncObservers() {
return {};
}
constructor() {
super();
this.__initializeObservers('sync');
this.__initializeObservers('async');
this.__asyncObserversQueue = {};
this.__asyncObserversNewValues = {};
this.__asyncObserversOldValues = {};
}
/**
* @param {string} property
* @param {*} newValue
* @param {*} oldValue
*/
triggerObserversFor(property, newValue, oldValue) {
this.__executeSyncObserversFor(property, newValue, oldValue);
this.__addToAsyncObserversQueue(property, newValue, oldValue);
this.updateComplete.then(() => {
this.__emptyAsyncObserversQueue();
});
}
/**
* Sync hooks into UpdatingElement mixin
*
* @param {string} property
* @param {any} oldValue
* @private
*/
_requestUpdate(property, oldValue) {
super._requestUpdate(property, oldValue);
this.__executeSyncObserversFor(property, this[property], oldValue);
}
/**
* Async hook into Updating Element
*
* @param {Map} changedProperties
*/
update(changedProperties) {
super.update(changedProperties);
this.__addMultipleToAsyncObserversQueue(changedProperties);
this.__emptyAsyncObserversQueue();
}
/**
* @param {string} type
* @private
*/
__initializeObservers(type) {
this[`__${type}ObserversForProperty`] = {};
Object.keys(this.constructor[`${type}Observers`]).forEach(observerFunctionName => {
const propertiesToObserve = this.constructor[`${type}Observers`][observerFunctionName];
if (typeof this[observerFunctionName] === 'function') {
propertiesToObserve.forEach(property => {
if (!this[`__${type}ObserversForProperty`][property]) {
this[`__${type}ObserversForProperty`][property] = [];
}
this[`__${type}ObserversForProperty`][property].push(observerFunctionName);
});
} else {
throw new Error(
`${this.localName} does not have a function called ${observerFunctionName}`,
);
}
});
}
/**
* @param {string} observedProperty
* @param {*} newValue
* @param {*} oldValue
* @private
*/
__executeSyncObserversFor(observedProperty, newValue, oldValue) {
if (newValue === oldValue) return;
const functionsToCall = {};
if (this.__syncObserversForProperty[observedProperty]) {
this.__syncObserversForProperty[observedProperty].forEach(observerFunctionName => {
functionsToCall[observerFunctionName] = true;
});
}
Object.keys(functionsToCall).forEach(functionName => {
const newValues = {};
const oldValues = {};
this.constructor.syncObservers[functionName].forEach(property => {
newValues[property] = observedProperty === property ? newValue : this[property];
oldValues[property] = observedProperty === property ? oldValue : this[property];
});
this[functionName](newValues, oldValues);
});
}
/**
* @param {string} property
* @param {*} newValue
* @param {*} oldValue
* @private
*/
__addToAsyncObserversQueue(property, newValue, oldValue) {
this.__asyncObserversNewValues[property] = newValue;
if (this.__asyncObserversOldValues[property] === undefined) {
// only get old value once
this.__asyncObserversOldValues[property] = oldValue;
}
if (oldValue === undefined) {
// special case for undefined
this.__asyncObserversOldValues[property] = undefinedSymbol;
}
if (this.__asyncObserversForProperty[property]) {
this.__asyncObserversForProperty[property].forEach(observerFunctionName => {
this.__asyncObserversQueue[observerFunctionName] = true;
});
}
}
/**
* @param {Map} oldValues
* @private
*/
__addMultipleToAsyncObserversQueue(oldValues) {
if (!oldValues) return;
oldValues.forEach((oldValue, property) => {
this.__addToAsyncObserversQueue(property, this[property], oldValue);
});
}
/**
* @private
*/
__emptyAsyncObserversQueue() {
Object.keys(this.__asyncObserversQueue).forEach(functionName => {
this[functionName](
this.__asyncObserversNewValues,
this.__getOldValuesWithRealUndefined(),
);
});
this.__asyncObserversNewValues = {};
this.__asyncObserversOldValues = {};
this.__asyncObserversQueue = {};
}
/**
* @returns {{}}
* @private
*/
__getOldValuesWithRealUndefined() {
const result = {};
Object.keys(this.__asyncObserversOldValues).forEach(key => {
const value = this.__asyncObserversOldValues[key];
result[key] = value === undefinedSymbol ? undefined : value;
});
return result;
}
},
);

View file

@ -0,0 +1,80 @@
import { dedupeMixin } from './dedupeMixin.js';
import { DomHelpersMixin } from './DomHelpersMixin.js';
/* eslint-disable class-methods-use-this, no-underscore-dangle */
/**
* # SlotMixin
* `SlotMixin`, when attached to the DOM it creates content for defined slots in the Light DOM.
* The content element is created using a factory function and is assigned a slot name from the key.
* Existing slot content is not overridden.
*
* The purpose is to have the default content in the Light DOM rather than hidden in Shadow DOM
* like default slot content works natively.
*
* @example
* get slots() {
* return {
* ...super.slots,
* // appends <div slot="foo"></div> to the Light DOM of this element
* foo: () => document.createElement('div'),
* };
* }
*
* @type {function()}
* @polymerMixin
* @mixinFunction
*/
export const SlotMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-unused-vars, no-shadow
class SlotMixin extends DomHelpersMixin(superclass) {
/**
* @returns {{}}
*/
get slots() {
return {};
}
constructor() {
super();
this.__privateSlots = new Set(null);
}
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
this._connectSlotMixin();
}
/**
* @protected
*/
_connectSlotMixin() {
if (!this.__isConnectedSlotMixin) {
Object.keys(this.slots).forEach(slotName => {
if (!this.$$slot(slotName)) {
const slotFactory = this.slots[slotName];
const slotContent = slotFactory();
if (slotContent instanceof Element) {
slotContent.setAttribute('slot', slotName);
this.appendChild(slotContent);
this.__privateSlots.add(slotName);
} // ignore non-elements to enable conditional slots
}
});
this.__isConnectedSlotMixin = true;
}
}
/**
* @protected
* @param {string} slotName Name of the slot
* @return {boolean} true if given slot name been created by SlotMixin
*/
_isPrivateSlot(slotName) {
return this.__privateSlots.has(slotName);
}
},
);

View file

@ -0,0 +1,87 @@
const appliedClassMixins = new WeakMap();
/**
* @param {Object} obj
* @returns {Array}
*/
function getPrototypeChain(obj) {
const chain = [];
let proto = obj;
while (proto) {
chain.push(proto);
proto = Object.getPrototypeOf(proto);
}
return chain;
}
/**
* @param {function()} mixin
* @param {function()} superClass
* @returns {boolean}
*/
function wasApplied(mixin, superClass) {
const classes = getPrototypeChain(superClass);
return classes.reduce((res, klass) => res || appliedClassMixins.get(klass) === mixin, false);
}
/**
* # dedupeMixin
*
* Imagine you are developing web components and creating ES classes for
* Custom Elements. You have two generic mixins (let's say `M1` and `M2`) which
* require independently the same even more generic mixin (`BaseMixin`).
* `M1` and `M2` can be used independently, that means they have to inherit
* from `BaseMixin` also independently. But they can be also used in combination.
* Sometimes `M1` and `M2` are used in the same component and can mess up the
* inheritance chain if `BaseMixin` is applied twice. In other words, this may happen
* to the protoype chain `... -> M2 -> BaseMixin -> M1 -> BaseMixin -> ...`.
*
* An example of this may be a `LocalizeMixin` used across different components and mixins.
* Some mixins may need it and many components need it too and can not rely on other mixins
* to have it by default, so must inherit from it independently.
*
* The more generic the mixin is, the higher the chance of being applied more than once.
* As a mixin author you can't control how it is used, and can't always predict it.
* So as a safety measure it is always recommended to create deduping mixins.
*
* @param {function()} mixin
* @returns {function()}
*
* @example
* // makes a conventional ES mixin deduping
* const BaseMixin = dedupeMixin((superClass) => {
* return class extends superClass { ... };
* });
*
* // inherits from BaseMixin
* const M1 = dedupeMixin((superClass) => {
* return class extends BaseMixin(superClass) { ... };
* });
*
* // inherits from BaseMixin
* const M2 = dedupeMixin((superClass) => {
* return class extends BaseMixin(superClass) { ... };
* });
*
* // component inherits from M1
* // MyCustomElement -> M1 -> BaseMixin -> BaseCustomElement;
* class MyCustomElement extends M1(BaseCustomElement) { ... }
*
* // component inherits from M2
* // MyCustomElement -> M2 -> BaseMixin -> BaseCustomElement;
* class MyCustomElement extends M2(BaseCustomElement) { ... }
*
* // component inherits from both M1 and M2
* // MyCustomElement -> M2 -> M1 -> BaseMixin -> BaseCustomElement;
* class MyCustomElement extends M2(M1(BaseCustomElement)) { ... }
*/
export function dedupeMixin(mixin) {
return superClass => {
if (wasApplied(mixin, superClass)) {
return superClass;
}
const mixedClass = mixin(superClass);
appliedClassMixins.set(mixedClass, mixin);
return mixedClass;
};
}

View file

@ -0,0 +1,3 @@
// deprecated!
export { html, render } from 'lit-html';
export { render as renderShady } from 'lit-html/lib/shady-render.js';

View file

@ -0,0 +1,50 @@
/* eslint-env mocha */
/* eslint-disable no-underscore-dangle, no-unused-expressions */
import { expect, fixture } from '@open-wc/testing';
import { LionLitElement } from '../src/LionLitElement.js';
import { CssClassMixin } from '../src/CssClassMixin.js';
describe('CssClassMixin', () => {
it('reflects non empty values to given class name', async () => {
let toClassInstance;
async function checkProp(newValue, bool) {
toClassInstance.foo = newValue;
await toClassInstance.updateComplete;
expect(toClassInstance.classList.contains('state-foo')).to.equal(bool);
}
class ToClassElement extends CssClassMixin(LionLitElement) {
static get properties() {
return {
foo: {
nonEmptyToClass: 'state-foo',
},
};
}
}
customElements.define('to-class-element', ToClassElement);
toClassInstance = await fixture(`<to-class-element><to-class-element/>`);
// Init
expect(toClassInstance.classList.contains('state-foo')).to.equal(false);
// Boolean
await checkProp(true, true);
await checkProp(false, false);
// Falsy
await checkProp('foo', true);
await checkProp('', false);
// Non empty object
await checkProp({ foo: 'bar' }, true);
await checkProp({}, false);
// Non empty array
await checkProp(['foo'], true);
await checkProp([], false);
});
});

View file

@ -0,0 +1,508 @@
/* eslint-env mocha */
/* eslint-disable no-unused-expressions, class-methods-use-this */
import { expect, fixture, defineCE, unsafeStatic, html } from '@open-wc/testing';
import sinon from 'sinon';
import { LionLitElement } from '../src/LionLitElement.js';
import { ObserverMixin } from '../src/ObserverMixin';
import { DelegateMixin } from '../src/DelegateMixin';
describe('DelegateMixin', () => {
afterEach(() => {
sinon.restore();
});
it('delegates events', async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.$id('button1'),
events: ['click'],
};
}
render() {
return html`
<button id="button1">with delegation</button>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
const cb = sinon.spy();
element.addEventListener('click', cb);
element.$id('button1').click();
expect(cb.callCount).to.equal(1);
});
it('delegates events before delegation target is attached to DOM', async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.$id('button1'),
events: ['click'],
};
}
render() {
return html`
<button id="button1">with delegation</button>
`;
}
},
);
const element = document.createElement(tag);
const cb = sinon.spy();
element.addEventListener('click', cb);
document.body.appendChild(element);
await element.updateComplete;
element.$id('button1').click();
expect(cb.callCount).to.equal(1);
// cleanup
document.body.removeChild(element);
});
it('delegates if light and shadow dom is used at the same time', async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.$$slot('button'),
events: ['click'],
methods: ['click'],
};
}
render() {
return html`
<span>Outside</span>
<slot name="button2"></slot>
`;
}
},
);
const element = await fixture(`
<${tag}><button slot="button">click me</button></${tag}>`);
const cb = sinon.spy();
element.addEventListener('click', cb);
element.$$slot('button').click();
expect(cb.callCount).to.equal(1);
});
it('will still support other events', async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.$id('button1'),
events: ['click'],
};
}
render() {
return html`
<button id="button1">with delegation</button>
`;
}
foo() {
this.dispatchEvent(new CustomEvent('foo-event', { bubbles: true, composed: true }));
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
const cb = sinon.spy();
element.addEventListener('foo-event', cb);
element.foo();
expect(cb.callCount).to.equal(1);
});
it('will call delegated methods', async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.$id('button1'),
methods: ['click'],
};
}
render() {
return html`
<button id="button1">with delegation</button>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
const cb = sinon.spy();
element.$id('button1').addEventListener('click', cb);
element.click();
expect(cb.callCount).to.equal(1);
});
it('supports arguments for delegated methods', async () => {
class DelegateArgumentSub extends LionLitElement {
constructor() {
super();
this.foo = { a: 'a', b: 'b' };
}
setFooAandB(a, b) {
this.foo.a = a;
this.foo.b = b;
}
}
customElements.define('delegate-argument-sub', DelegateArgumentSub);
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.$id('sub'),
methods: ['setFooAandB'],
};
}
render() {
return html`
<delegate-argument-sub id="sub"></delegate-argument-sub>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
element.disabled = true;
element.setFooAandB('newA', 'newB');
expect(element.$id('sub').foo.a).to.equal('newA');
expect(element.$id('sub').foo.b).to.equal('newB');
});
it('will set delegated properties', async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.$id('button1'),
properties: ['disabled'],
};
}
render() {
return html`
<button id="button1">with delegation</button>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
element.disabled = true;
await element.updateComplete;
expect(element.$id('button1').disabled).to.equal(true);
expect(element.$id('button1').hasAttribute('disabled')).to.equal(true);
});
it('delegates properties before delegation target is attached to DOM', async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.$id('button1'),
properties: ['disabled'],
};
}
render() {
return html`
<button id="button1">with delegation</button>
`;
}
},
);
const element = document.createElement(tag);
element.disabled = true;
document.body.appendChild(element);
await element.updateComplete;
expect(element.$id('button1').disabled).to.equal(true);
// cleanup
document.body.removeChild(element);
});
it('will delegate setAttribute', async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.$id('button1'),
attributes: ['disabled'],
};
}
render() {
return html`
<button id="button1">with delegation</button>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
element.setAttribute('disabled', '');
await element.updateComplete;
expect(element.hasAttribute('disabled')).to.equal(false);
expect(element.$id('button1').hasAttribute('disabled')).to.equal(true);
});
it('will read inital attributes', async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.$id('button1'),
attributes: ['disabled'],
};
}
render() {
return html`
<button id="button1">with delegation</button>
`;
}
},
);
const element = await fixture(`<${tag} disabled></${tag}>`);
expect(element.hasAttribute('disabled')).to.equal(false);
expect(element.$id('button1').hasAttribute('disabled')).to.equal(true);
});
it('will delegate removeAttribute', async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.$id('button1'),
attributes: ['disabled'],
};
}
render() {
return html`
<button id="button1">with delegation</button>
`;
}
},
);
const element = await fixture(`<${tag} disabled></${tag}>`);
element.removeAttribute('disabled', '');
await element.updateComplete;
expect(element.hasAttribute('disabled')).to.equal(false);
expect(element.$id('button1').hasAttribute('disabled')).to.equal(false);
});
it('respects user defined values for delegated attributes and properties', async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
// this just means itś config is set to the queue when called before connectedCallback
target: () => this.scheduledElement,
attributes: ['type'],
properties: ['type'],
};
}
get scheduledElement() {
return this.querySelector('input');
}
constructor() {
super();
this.type = 'email'; // 1. here we set the delegated prop and it should be scheduled
}
connectedCallback() {
// 2. this is where we add teh delegation target (so after 1)
this.appendChild(document.createElement('input'));
super.connectedCallback(); // let the DelegateMixin do its work
}
},
);
const tagName = unsafeStatic(tag);
// Here, the Application Developerd tries to set the type via attribute
const elementAttr = await fixture(`<${tag} type="radio"></${tag}>`);
expect(elementAttr.scheduledElement.type).to.equal('radio');
// Here, the Application Developer tries to set the type via property
const elementProp = await fixture(html`<${tagName} .type=${'radio'}></${tagName}>`);
expect(elementProp.scheduledElement.type).to.equal('radio');
});
it(`uses attribute value as a fallback for delegated property getter
when property not set by user and delegationTarget not ready`, async () => {
const tag = defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.delegatedEl,
properties: ['type'],
attributes: ['type'],
};
}
get delegatedEl() {
// returns null, so we can test that "cached" attr is used as fallback
return null;
}
},
);
const element = await fixture(`<${tag} type="radio"></${tag}>`);
expect(element.delegatedEl).to.equal(null);
expect(element.type).to.equal('radio'); // value retrieved from host instead of delegatedTarget
});
it('works with connectedCallback', async () => {
const tag = await defineCE(
class extends DelegateMixin(HTMLElement) {
get delegations() {
return {
...super.delegations,
target: () => this.querySelector('div'),
properties: ['foo'],
};
}
},
);
const element = await fixture(`<${tag}><div></div></${tag}>`);
element.foo = 'new';
expect(element.querySelector('div').foo).to.equal('new');
});
it('works with shadow dom', async () => {
const tag = await defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.shadowRoot.querySelector('div'),
properties: ['foo'],
};
}
render() {
return html`
<div></div>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
element.foo = 'new';
expect(element.shadowRoot.querySelector('div').foo).to.equal('new');
});
it('works with light dom', async () => {
const tag = await defineCE(
class extends DelegateMixin(LionLitElement) {
get delegations() {
return {
...super.delegations,
target: () => this.querySelector('div'),
properties: ['foo'],
};
}
createRenderRoot() {
return this;
}
render() {
return html`
<div></div>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
element.foo = 'new';
expect(element.querySelector('div').foo).to.equal('new');
});
it('integrates with the Observer System', async () => {
const tag = await defineCE(
class extends DelegateMixin(ObserverMixin(LionLitElement)) {
get delegations() {
return {
...super.delegations,
target: () => this.querySelector('div'),
properties: ['size'],
};
}
static get syncObservers() {
return { _onSyncSizeChanged: ['size'] };
}
static get asyncObservers() {
return { _onAsyncSizeChanged: ['size'] };
}
render() {
return html`
<div></div>
`;
}
createRenderRoot() {
return this;
}
_onSyncSizeChanged() {}
_onAsyncSizeChanged() {}
},
);
const el = await fixture(`<${tag}><div></div></${tag}>`);
const asyncSpy = sinon.spy(el, '_onAsyncSizeChanged');
const syncSpy = sinon.spy(el, '_onSyncSizeChanged');
el.size = 'tiny';
expect(syncSpy.callCount).to.equal(1);
expect(syncSpy.calledWith({ size: 'tiny' }, { size: undefined })).to.be.true;
el.size = 'big';
expect(syncSpy.callCount).to.equal(2);
expect(syncSpy.calledWith({ size: 'big' }, { size: 'tiny' })).to.be.true;
expect(asyncSpy.callCount).to.equal(0);
await el.updateComplete;
expect(syncSpy.callCount).to.equal(2);
expect(asyncSpy.callCount).to.equal(1);
expect(asyncSpy.calledWith({ size: 'big' }, { size: undefined })).to.be.true;
el.size = 'medium';
await el.updateComplete;
expect(syncSpy.callCount).to.equal(3);
expect(syncSpy.calledWith({ size: 'medium' }, { size: 'big' })).to.be.true;
expect(asyncSpy.callCount).to.equal(2);
expect(asyncSpy.calledWith({ size: 'medium' }, { size: 'big' })).to.be.true;
// we expect to NOT use an "internal" property
// TODO: double check if we test the right thing here
expect(el.constructor._classProperties.get('size')).to.be.undefined; // eslint-disable-line no-underscore-dangle
});
});

View file

@ -0,0 +1,287 @@
/* eslint-disable class-methods-use-this, no-underscore-dangle */
/* eslint-env mocha */
import { expect, fixture, defineCE } from '@open-wc/testing';
import { LionLitElement, html } from '../src/LionLitElement.js';
describe('DomHelpersMixin', () => {
describe('$id()', () => {
it('provides access to element in Shadom DOM with "id" attribute', async () => {
const tag = defineCE(
class extends LionLitElement {
render() {
return html`
<div id="myId">my element</div>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
expect(element.$id('myId').innerText).to.equal('my element');
});
it('memoizes element reference in cache', async () => {
const tag = defineCE(
class extends LionLitElement {
render() {
return html`
<div id="myId">my element</div>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
element.$id('myId');
element.shadowRoot.innerHTML = '';
expect(element.$id('myId').innerText).to.equal('my element');
});
it('can be removed from cache (individually or completely) via _clearDomCache()', async () => {
const tag = defineCE(
class extends LionLitElement {
render() {
return html`
<div id="myId">my element</div>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
element.$id('myId');
element.shadowRoot.innerHTML = '';
element._clearDomCache('$id', 'myId');
expect(element.$id('myId')).to.equal(undefined);
const element2 = await fixture(`<${tag}></${tag}>`);
element.$id('myId');
element2.shadowRoot.innerHTML = '';
element2._clearDomCache();
expect(element2.$id('myId')).to.equal(undefined);
});
});
describe('$name()', () => {
it('provides access to element in Shadom DOM with "name" attribute', async () => {
const tag = defineCE(
class extends LionLitElement {
render() {
return html`
<div name="myName">my element</div>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
expect(element.$name('myName').innerText).to.equal('my element');
});
it('memoizes element reference in cache', async () => {
const tag = defineCE(
class extends LionLitElement {
render() {
return html`
<div name="myName">my element</div>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
element.$name('myName');
element.shadowRoot.innerHTML = '';
expect(element.$name('myName').innerText).to.equal('my element');
});
it('can be removed from cache (individually or completely) via _clearDomCache()', async () => {
const tag = defineCE(
class extends LionLitElement {
render() {
return html`
<div name="myName">my element</div>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
element.$name('myName');
element.shadowRoot.innerHTML = '';
element._clearDomCache('$name', 'myName');
expect(element.$name('myName')).to.equal(undefined);
const element2 = await fixture(`<${tag}></${tag}>`);
element.$name('myName');
element2.shadowRoot.innerHTML = '';
element2._clearDomCache();
expect(element2.$name('myName')).to.equal(undefined);
});
});
describe('$$id()', () => {
it('provides access to element in Light DOM with "id" attribute', async () => {
const tag = defineCE(
class extends LionLitElement {
createRenderRoot() {
return this;
}
render() {
return html`
<div id="myId">my element</div>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
expect(element.$$id('myId').innerText).to.equal('my element');
});
it('memoizes element reference in cache', async () => {
const tag = defineCE(
class extends LionLitElement {
createRenderRoot() {
return this;
}
render() {
return html`
<div id="myId">my element</div>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
element.$$id('myId');
element.innerHTML = '';
expect(element.$$id('myId').innerText).to.equal('my element');
});
it('can be removed from cache (individually or completely) via _clearDomCache()', async () => {
const tag = defineCE(
class extends LionLitElement {
createRenderRoot() {
return this;
}
render() {
return html`
<div id="myId">my element</div>
`;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
element.$$id('myId');
element.innerHTML = '';
element._clearDomCache('$$id', 'myId');
expect(element.$$id('myId')).to.equal(undefined);
const element2 = await fixture(`<${tag}></${tag}>`);
element.$$id('myId');
element2.innerHTML = '';
element2._clearDomCache();
expect(element2.$$id('myId')).to.equal(undefined);
});
});
describe('$$slot()', () => {
it('provides access to element in Light DOM with "slot" attribute', async () => {
const tag = defineCE(
class extends LionLitElement {
render() {
return html`
<slot name="mySlot"></slot>
`;
}
},
);
const element = await fixture(`<${tag}>
<div slot="mySlot">my element</div>
</${tag}>`);
expect(element.$$slot('mySlot').innerText).to.equal('my element');
});
it('retrieves the first named slot that is a direct child of the element', async () => {
const tag = defineCE(
class extends LionLitElement {
render() {
return html`
<!-- here the nested slots will be plotted -->
<slot></slot>
<!-- here the slots belonging to this component are plotted -->
<slot name="slotA"></slot>
<slot name="slotB"></slot>
<slot name="slotC"></slot>
`;
}
},
);
const tagNested = tag; // reuse the same component for nested slots with same slot names
const fieldsetNested = await fixture(`
<${tag}>
<div slot="slotA">Get this slotA</div>
<${tagNested}>
<div slot="slotA">Don't get this slotA</div>
<div slot="slotB">Don't get this slotB</div>
<div slot="slotC">Don't get this slotC</div>
</${tagNested}>
<div slot="slotB">Get this slotB (2nd in dom, but belongs to 'upper tag')</div>
<div slot="slotB">
Don't get this slotB either
(it only should get the first slot that is a direct child)
</div>
</${tag}>`);
expect(fieldsetNested.$$slot('slotA').textContent).to.equal('Get this slotA');
expect(fieldsetNested.$$slot('slotB').textContent).to.equal(
"Get this slotB (2nd in dom, but belongs to 'upper tag')",
);
expect(fieldsetNested.$$slot('slotC')).to.equal(undefined);
});
it('memoizes element reference in cache', async () => {
const tag = defineCE(
class extends LionLitElement {
render() {
return html`
<slot name="mySlot"></slot>
`;
}
},
);
const element = await fixture(`<${tag}>
<div slot="mySlot">my element</div>
</${tag}>`);
element.$$slot('mySlot');
element.innerHTML = '';
expect(element.$$slot('mySlot').innerText).to.equal('my element');
});
it('can be removed from cache (individually or completely) via _clearDomCache()', async () => {
const tag = defineCE(
class extends LionLitElement {
render() {
return html`
<slot name="mySlot"></slot>
`;
}
},
);
const element = await fixture(`<${tag}>
<div slot="mySlot">my element</div>
</${tag}>`);
element.$$slot('mySlot');
element.innerHTML = '';
element._clearDomCache('$$slot', 'mySlot');
expect(element.$$slot('mySlot')).to.equal(undefined);
const element2 = await fixture(`<${tag}>
<div slot="mySlot">my element</div>
</${tag}>`);
element2.$$slot('mySlot');
element2.innerHTML = '';
element2._clearDomCache();
expect(element2.$$slot('mySlot')).to.equal(undefined);
});
});
});

View file

@ -0,0 +1,240 @@
/* eslint-env mocha */
/* eslint-disable no-unused-expressions, class-methods-use-this */
import { expect, fixture, html, defineCE } from '@open-wc/testing';
import { LionLitElement } from '../src/LionLitElement.js';
import { EventMixin } from '../src/EventMixin.js';
describe('EventMixin', () => {
before(() => {
class EventMixinSub extends LionLitElement {
static get properties() {
return {
value: {
type: 'String',
asAttribute: 'value',
},
};
}
_requestUpdate(propName) {
super._requestUpdate();
if (propName === 'value') {
this.dispatchEvent(new CustomEvent('value-changed', { bubbles: true, composed: true }));
}
}
}
customElements.define('event-mixin-sub', EventMixinSub);
/* global */
class EventMixinTag extends EventMixin(LionLitElement) {
get events() {
return {
...super.events,
_onSelfClick: [() => this, 'click'],
_onButton1Click: [() => this.$id('button1'), 'click'],
_onSub1Click: [() => this.$id('sub1'), 'click'],
_onSub1Input: [() => this.$id('sub1'), 'value-changed'],
};
}
// eslint-disable-next-line class-methods-use-this
render() {
return html`
<button id="button1">with click event</button>
<event-mixin-sub type="text" id="sub1"></event-mixin-sub>
<button id="button3">no event</button>
`;
}
constructor() {
super();
this.button1ClickCount = 0;
this.sub1ValueChangeCount = 0;
this.selfClick = 0;
}
_onButton1Click() {
this.button1ClickCount += 1;
}
_onSub1Click() {
this.$id('sub1').value = 'you clicked me';
}
_onSub1Input() {
this.sub1ValueChangeCount += 1;
}
_onSelfClick() {
this.selfClick += 1;
}
}
customElements.define('event-mixin', EventMixinTag);
});
it('can adding an event that gets triggered', async () => {
const element = await fixture(`<event-mixin></event-mixin>`);
element.$id('button1').click();
expect(element.button1ClickCount).to.equal(1);
});
it('can add events to itself', async () => {
const element = await fixture(`<event-mixin></event-mixin>`);
element.click();
expect(element.selfClick).to.equal(1);
});
it('can add events to window', async () => {
const tag = defineCE(
class extends EventMixin(HTMLElement) {
get events() {
return {
_onMyCustomEvent: [() => window, 'my-custom-event'],
};
}
_onMyCustomEvent() {
this.fired = true;
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
expect(element.fired).to.equal(undefined);
window.dispatchEvent(new Event('my-custom-event'));
await element.updateComplete;
expect(element.fired).to.equal(true);
// this will clear it's state on window
delete window.__eventMixinProcessed; // eslint-disable-line no-underscore-dangle
});
it('supports multiple different events for a single node', async () => {
const element = await fixture(`<event-mixin></event-mixin>`);
element.$id('sub1').click();
await element.updateComplete;
expect(element.$id('sub1').value).to.equal('you clicked me');
element.$id('sub1').value = 'new';
await element.updateComplete;
expect(element.$id('sub1').value).to.equal('new');
expect(element.sub1ValueChangeCount).to.equal(2);
});
it('supports multiple different events with a single function', async () => {
class MultiEvents extends EventMixin(HTMLElement) {
get events() {
return {
doIt: [() => this.lightDomInput, ['click', 'change']],
};
}
constructor() {
super();
this.doItCounter = 0;
}
doIt() {
this.doItCounter += 1;
}
connectedCallback() {
this.lightDomInput = this.querySelector('input');
this.__registerEvents(); // eslint-disable-line
}
}
customElements.define('multi-events', MultiEvents);
const element = await fixture(`<multi-events><input /></multi-events>`);
expect(element.doItCounter).to.equal(0);
element.lightDomInput.click();
element.lightDomInput.value = 'foo';
element.lightDomInput.dispatchEvent(new Event('change'));
await element.updateComplete;
expect(element.doItCounter).to.equal(2);
});
it('supports multiple functions per event for a single node', async () => {
class MultiFunctions extends EventMixin(HTMLElement) {
get events() {
return {
_onClick: [() => this.lightDomButton, 'click'],
_loggging: [() => this.lightDomButton, 'click'],
};
}
_onClick() {
this.clicked = true;
}
_loggging() {
this.logged = true;
}
connectedCallback() {
this.lightDomButton = this.querySelector('button');
this.__registerEvents(); // eslint-disable-line
}
}
customElements.define('multi-functions', MultiFunctions);
const element = await fixture(`<multi-functions><button>click</button></multi-functions>`);
expect(element.clicked).to.equal(undefined);
expect(element.logged).to.equal(undefined);
element.lightDomButton.click();
await element.updateComplete;
expect(element.clicked).to.equal(true);
expect(element.logged).to.equal(true);
});
it('will not add same event/function multiple times to a node', async () => {
const element = await fixture(`<event-mixin></event-mixin>`);
element.__registerEvents(); // eslint-disable-line
element.__registerEvents(); // eslint-disable-line
expect(element.__eventsCache.length).to.equal(4); // eslint-disable-line
});
it('will cleanup events', async () => {
// we can't test this properly as according to spec there is no way to get
// a list of attached event listeners
// in dev tools you can use getEventListeners but that is not available globally
// so we are at least testing our implementation
const element = await fixture(`<event-mixin></event-mixin>`);
element.__unregisterEvents(); // eslint-disable-line
expect(element.__eventsCache.length).to.equal(0); // eslint-disable-line
});
it('reregisters events if dom node is moved', async () => {
const tag = defineCE(
class extends EventMixin(HTMLElement) {
get events() {
return {
_onClick: [() => this.querySelector('button'), 'click'],
};
}
constructor() {
super();
this.myCallCount = 0;
}
_onClick() {
this.myCallCount += 1;
}
},
);
const el = await fixture(`<${tag}><button>click</button></${tag}>`);
el.querySelector('button').click();
expect(el.myCallCount).to.equal(1);
const wrapper = await fixture(`<div></div>`);
wrapper.appendChild(el);
el.querySelector('button').click();
expect(el.myCallCount).to.equal(2);
});
});

View file

@ -0,0 +1,90 @@
/* eslint-env mocha */
/* eslint-disable no-underscore-dangle, no-unused-expressions, class-methods-use-this */
import { expect, fixture, defineCE } from '@open-wc/testing';
import { LionLitElement, html, css } from '../src/LionLitElement.js';
describe('LionLitElement', () => {
describe('updateStyles()', () => {
it('handles css variables && direct e.g. host css properties correctly', async () => {
const elementName = defineCE(
class extends LionLitElement {
static get styles() {
return [
css`
:host {
text-align: right;
--color: rgb(128, 128, 128);
}
h1 {
color: var(--color);
}
`,
];
}
render() {
return html`
<h1 id="header">hey</h1>
`;
}
},
);
const shadowLion = await fixture(`<${elementName}></${elementName}>`);
expect(window.getComputedStyle(shadowLion.$id('header')).color).to.equal(
'rgb(128, 128, 128)',
);
expect(window.getComputedStyle(shadowLion).textAlign).to.equal('right');
shadowLion.updateStyles({
'--color': 'rgb(255, 0, 0)',
'text-align': 'center',
});
await elementName.updateComplete;
expect(window.getComputedStyle(shadowLion.$id('header')).color).to.equal('rgb(255, 0, 0)');
expect(window.getComputedStyle(shadowLion).textAlign).to.equal('center');
});
it('preserves existing styles', async () => {
const elementName = defineCE(
class extends LionLitElement {
static get styles() {
return [
css`
:host {
--color: rgb(128, 128, 128);
}
h1 {
color: var(--color);
}
`,
];
}
render() {
return html`
<h1 id="header">hey</h1>
`;
}
},
);
const shadowLion = await fixture(`<${elementName}></${elementName}>`);
expect(window.getComputedStyle(shadowLion.$id('header')).color).to.equal(
'rgb(128, 128, 128)',
);
shadowLion.updateStyles({ '--color': 'rgb(255, 0, 0)' });
await elementName.updateComplete;
expect(window.getComputedStyle(shadowLion.$id('header')).color).to.equal('rgb(255, 0, 0)');
shadowLion.updateStyles({ 'text-align': 'left' });
await elementName.updateComplete;
const styles = window.getComputedStyle(shadowLion.$id('header'));
expect(styles.color).to.equal('rgb(255, 0, 0)');
expect(styles.textAlign).to.equal('left');
});
});
});

View file

@ -0,0 +1,114 @@
/* eslint-env mocha */
/* eslint-disable no-unused-expressions */
import { expect } from '@open-wc/testing';
import { LionSingleton } from '../src/LionSingleton.js';
describe('LionSingleton', () => {
it('provides an instance of the given class via .getInstance()', async () => {
class MySingleton extends LionSingleton {}
const mySingleton = MySingleton.getInstance();
expect(mySingleton).to.be.an.instanceOf(MySingleton);
});
it('supports parameters for .getInstance(foo, bar)', async () => {
class MySingleton extends LionSingleton {
constructor(foo, bar) {
super();
this.foo = foo;
this.bar = bar;
}
}
const mySingleton = MySingleton.getInstance('isFoo', 'isBar');
expect(mySingleton).to.deep.equal({
foo: 'isFoo',
bar: 'isBar',
});
});
it('will return the same instance if already created', async () => {
let counter = 0;
class MySingleton extends LionSingleton {
constructor() {
super();
counter += 1;
}
}
const mySingleton = MySingleton.getInstance();
mySingleton.foo = 'bar';
expect(mySingleton).to.deep.equal(MySingleton.getInstance());
expect(counter).to.equal(1);
expect(new MySingleton()).to.not.deep.equal(MySingleton.getInstance());
});
it('can reset its instance so a new one will be created via .resetInstance()', async () => {
let counter = 0;
class MySingleton extends LionSingleton {
constructor() {
super();
counter += 1;
}
}
const mySingleton = MySingleton.getInstance();
mySingleton.foo = 'bar';
expect(mySingleton.foo).to.equal('bar');
expect(counter).to.equal(1);
MySingleton.resetInstance();
const updatedSingleton = MySingleton.getInstance();
expect(updatedSingleton.foo).to.be.undefined;
expect(counter).to.equal(2);
});
it('can at any time add mixins via .addInstanceMixin()', () => {
const MyMixin = superclass =>
class extends superclass {
constructor() {
super();
this.myMixin = true;
}
};
class MySingleton extends LionSingleton {}
MySingleton.addInstanceMixin(MyMixin);
const mySingleton = MySingleton.getInstance();
expect(mySingleton.myMixin).to.be.true;
const OtherMixin = superclass =>
class extends superclass {
constructor() {
super();
this.otherMixin = true;
}
};
MySingleton.addInstanceMixin(OtherMixin);
expect(mySingleton.otherMixin).to.be.undefined;
MySingleton.resetInstance();
const updatedSingleton = MySingleton.getInstance();
expect(updatedSingleton.myMixin).to.be.true;
expect(updatedSingleton.otherMixin).to.be.true;
});
it('can provide new instances (with applied Mixins) via .getNewInstance()', async () => {
const MyMixin = superclass =>
class extends superclass {
constructor() {
super();
this.myMixin = true;
}
};
class MySingleton extends LionSingleton {}
MySingleton.addInstanceMixin(MyMixin);
const singletonOne = MySingleton.getNewInstance();
singletonOne.one = true;
expect(singletonOne.myMixin).to.be.true;
expect(singletonOne.one).to.be.true;
const singletonTwo = MySingleton.getNewInstance();
expect(singletonTwo.myMixin).to.be.true;
expect(singletonTwo.one).to.be.undefined;
expect(singletonOne.one).to.be.true; // to be sure
});
});

View file

@ -0,0 +1,242 @@
/* eslint-env mocha */
/* eslint-disable no-underscore-dangle, class-methods-use-this, no-unused-expressions */
import { expect, fixture, defineCE } from '@open-wc/testing';
import sinon from 'sinon';
import { LionLitElement } from '../src/LionLitElement.js';
import { ObserverMixin } from '../src/ObserverMixin.js';
describe('ObserverMixin', () => {
afterEach(() => {
sinon.restore();
});
it('throws if a syncObserver function is not found', async () => {
class SyncTest extends ObserverMixin(class {}) {
static get properties() {
return { size: { type: 'String' } };
}
static get syncObservers() {
return { _onSyncMissingFunction: ['size'] };
}
get localName() {
return 'SyncTest';
}
}
let error = false;
try {
new SyncTest(); // eslint-disable-line no-new
} catch (err) {
error = err;
}
expect(error).to.be.instanceOf(Error);
expect(error.message).to.equal(
'SyncTest does not have a function called _onSyncMissingFunction',
);
});
it('throws if a asyncObserver function is not found', async () => {
class AsyncTest extends ObserverMixin(class {}) {
static get properties() {
return { size: { type: 'String' } };
}
static get asyncObservers() {
return { _onAsyncMissingFunction: ['size'] };
}
get localName() {
return 'AsyncTest';
}
}
let error = false;
try {
new AsyncTest(); // eslint-disable-line no-new
} catch (err) {
error = err;
}
expect(error).to.be.instanceOf(Error);
expect(error.message).to.equal(
'AsyncTest does not have a function called _onAsyncMissingFunction',
);
});
// TODO: replace with requestUpdate()?
it('provides triggerObserversFor() which can be used within a setter to hook into the observer system', async () => {
const tag = defineCE(
class extends ObserverMixin(LionLitElement) {
static get syncObservers() {
return { _onSyncSizeChanged: ['size'] };
}
static get asyncObservers() {
return { _onAsyncSizeChanged: ['size'] };
}
set size(newValue) {
const oldValue = this.__mySize;
this.__mySize = newValue;
this.triggerObserversFor('size', newValue, oldValue);
}
get size() {
return this.__mySize;
}
_onSyncSizeChanged() {}
_onAsyncSizeChanged() {}
},
);
const el = await fixture(`<${tag}></${tag}>`);
const asyncSpy = sinon.spy(el, '_onAsyncSizeChanged');
const syncSpy = sinon.spy(el, '_onSyncSizeChanged');
el.size = 'tiny';
expect(syncSpy.callCount).to.equal(1);
expect(syncSpy.calledWith({ size: 'tiny' }, { size: undefined })).to.be.true;
el.size = 'big';
expect(syncSpy.callCount).to.equal(2);
expect(syncSpy.calledWith({ size: 'big' }, { size: 'tiny' })).to.be.true;
expect(asyncSpy.callCount).to.equal(0);
await el.updateComplete;
expect(syncSpy.callCount).to.equal(2);
expect(asyncSpy.callCount).to.equal(1);
expect(asyncSpy.calledWith({ size: 'big' }, { size: undefined })).to.be.true;
el.size = 'medium';
await el.updateComplete;
expect(syncSpy.callCount).to.equal(3);
expect(syncSpy.calledWith({ size: 'medium' }, { size: 'big' })).to.be.true;
expect(asyncSpy.calledWith({ size: 'medium' }, { size: 'big' })).to.be.true;
});
describe('syncObservers', () => {
it('calls observers immediately when the observed property is changed (newValue !== oldValue)', async () => {
const tag = defineCE(
class extends ObserverMixin(LionLitElement) {
static get properties() {
return { size: { type: String } };
}
static get syncObservers() {
return { _onSizeChanged: ['size'] };
}
_onSizeChanged() {}
},
);
const el = await fixture(`<${tag}></${tag}>`);
const observerSpy = sinon.spy(el, '_onSizeChanged');
expect(observerSpy.callCount).to.equal(0);
el.size = 'tiny';
expect(observerSpy.callCount).to.equal(1);
el.size = 'tiny';
expect(observerSpy.callCount).to.equal(1);
el.size = 'big';
expect(observerSpy.callCount).to.equal(2);
});
it('makes call to observer for every observed property change', async () => {
const tag = defineCE(
class extends ObserverMixin(LionLitElement) {
static get properties() {
return {
size: { type: String },
speed: { type: Number },
};
}
static get syncObservers() {
return {
_onSpeedOrTypeChanged: ['size', 'speed'],
};
}
_onSpeedOrTypeChanged() {
this.__testSize = this.size;
}
},
);
const el = await fixture(`<${tag}></${tag}>`);
const observerSpy = sinon.spy(el, '_onSpeedOrTypeChanged');
el.size = 'big';
expect(observerSpy.callCount).to.equal(1);
expect(el.__testSize).to.equal('big');
el.speed = 3;
expect(observerSpy.callCount).to.equal(2);
expect(
observerSpy.calledWith(
{ size: 'big', speed: undefined },
{ size: undefined, speed: undefined },
),
).to.be.true;
expect(observerSpy.calledWith({ size: 'big', speed: 3 }, { size: 'big', speed: undefined }))
.to.be.true;
});
});
describe('asyncObservers', () => {
it('calls observer patched when the observed property is changed', async () => {
const tag = defineCE(
class extends ObserverMixin(LionLitElement) {
static get properties() {
return { size: { type: 'String' } };
}
static get asyncObservers() {
return { _onAsyncSizeChanged: ['size'] };
}
_onAsyncSizeChanged() {}
},
);
const el = await fixture(`<${tag}></${tag}>`);
const observerSpy = sinon.spy(el, '_onAsyncSizeChanged');
el.size = 'tiny';
expect(observerSpy.callCount).to.equal(0);
await el.updateComplete;
expect(observerSpy.callCount).to.equal(1);
});
it('makes only one call to observer even if multiple observed attributes changed', async () => {
const tag = defineCE(
class extends ObserverMixin(LionLitElement) {
static get properties() {
return {
size: { type: 'String' },
speed: { type: 'Number' },
};
}
static get asyncObservers() {
return {
_onAsyncSpeedOrTypeChanged: ['size', 'speed'],
};
}
_onAsyncSpeedOrTypeChanged() {}
},
);
const el = await fixture(`<${tag}></${tag}>`);
const observerSpy = sinon.spy(el, '_onAsyncSpeedOrTypeChanged');
el.size = 'big';
el.speed = 3;
expect(observerSpy.callCount).to.equal(0);
await el.updateComplete;
expect(observerSpy.callCount).to.equal(1);
expect(
observerSpy.calledWith({ size: 'big', speed: 3 }, { size: undefined, speed: undefined }),
).to.be.true;
});
});
});

View file

@ -0,0 +1,118 @@
/* eslint-env mocha */
/* eslint-disable no-underscore-dangle, no-unused-expressions */
import { expect, fixture, defineCE } from '@open-wc/testing';
import { SlotMixin } from '../src/SlotMixin.js';
describe('SlotMixin', () => {
it('inserts provided element into lightdom and sets slot', async () => {
const tag = defineCE(
class extends SlotMixin(HTMLElement) {
get slots() {
return {
...super.slots,
feedback: () => document.createElement('div'),
};
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
expect(element.children[0].slot).to.equal('feedback');
});
it('does not override user provided slots', async () => {
const tag = defineCE(
class extends SlotMixin(HTMLElement) {
get slots() {
return {
...super.slots,
feedback: () => document.createElement('div'),
};
}
},
);
const el = await fixture(`<${tag}><p slot="feedback">user-content</p></${tag}>`);
expect(el.children[0].tagName).to.equal('P');
expect(el.children[0].innerText).to.equal('user-content');
});
it('supports complex dom trees as element', async () => {
const tag = defineCE(
class extends SlotMixin(HTMLElement) {
constructor() {
super();
this.foo = 'bar';
}
get slots() {
return {
...super.slots,
feedback: () => {
const el = document.createElement('div');
el.setAttribute('foo', this.foo);
const subEl = document.createElement('p');
subEl.innerText = 'cat';
el.appendChild(subEl);
return el;
},
};
}
},
);
const element = await fixture(`<${tag}></${tag}>`);
expect(element.children[0].slot).to.equal('feedback');
expect(element.children[0].getAttribute('foo')).to.equal('bar');
expect(element.children[0].children[0].innerText).to.equal('cat');
});
it('supports conditional slots', async () => {
let renderSlot = true;
const tag = defineCE(
class extends SlotMixin(HTMLElement) {
get slots() {
return {
...super.slots,
conditional: () => {
if (renderSlot) {
const el = document.createElement('div');
el.id = 'someSlot';
return el;
}
return undefined;
},
};
}
},
);
const elementSlot = await fixture(`<${tag}><${tag}>`);
expect(elementSlot.querySelector('#someSlot')).to.exist;
renderSlot = false;
const elementNoSlot = await fixture(`<${tag}><${tag}>`);
expect(elementNoSlot.querySelector('#someSlot')).to.not.exist;
});
it("allows to check which slots have been created via this._isPrivateSlot('slotname')", async () => {
let renderSlot = true;
const tag = defineCE(
class extends SlotMixin(HTMLElement) {
get slots() {
return {
...super.slots,
conditional: () => (renderSlot ? document.createElement('div') : undefined),
};
}
didCreateConditionalSlot() {
return this._isPrivateSlot('conditional');
}
},
);
const el = await fixture(`<${tag}><${tag}>`);
expect(el.didCreateConditionalSlot()).to.be.true;
const elUserSlot = await fixture(`<${tag}><p slot="conditional">foo</p><${tag}>`);
expect(elUserSlot.didCreateConditionalSlot()).to.be.false;
renderSlot = false;
const elNoSlot = await fixture(`<${tag}><${tag}>`);
expect(elNoSlot.didCreateConditionalSlot()).to.be.false;
});
});

View file

@ -0,0 +1,78 @@
/* eslint-env mocha */
import { expect } from '@open-wc/testing';
import { dedupeMixin } from '../src/dedupeMixin.js';
describe('dedupeMixin', () => {
function createMixin(name) {
return superClass =>
class MixinClass extends superClass {
getMixinNames() {
const superName = super.getMixinNames ? ` ${super.getMixinNames()}` : '';
return `${name}${superName}`;
}
};
}
function createMixins(...names) {
return names.map(name => createMixin(name));
}
it('dedupes mixins', () => {
const [Mixin1, Mixin2, Mixin3] = createMixins('Mixin1', 'Mixin2', 'Mixin3');
const DedupingMixin3 = dedupeMixin(Mixin3);
class BaseClass {}
class MixedClass1 extends DedupingMixin3(Mixin1(Mixin2(DedupingMixin3(BaseClass)))) {}
const MixedClass2 = DedupingMixin3(Mixin1(Mixin2(DedupingMixin3(BaseClass))));
const myObj1 = new MixedClass1();
const myObj2 = new MixedClass2();
expect(myObj1.getMixinNames()).to.equal('Mixin1 Mixin2 Mixin3');
expect(myObj2.getMixinNames()).to.equal('Mixin1 Mixin2 Mixin3');
});
it('dedupes mixins only explicitely', () => {
const [Mixin1, Mixin2, Mixin3] = createMixins('Mixin1', 'Mixin2', 'Mixin3');
const DedupingMixin3 = dedupeMixin(Mixin3);
class BaseClass {}
class MixedClass1 extends Mixin3(Mixin1(Mixin2(DedupingMixin3(BaseClass)))) {}
class MixedClass2 extends DedupingMixin3(Mixin1(Mixin2(Mixin3(BaseClass)))) {}
const myObj1 = new MixedClass1();
const myObj2 = new MixedClass2();
expect(myObj1.getMixinNames()).to.equal('Mixin3 Mixin1 Mixin2 Mixin3');
expect(myObj2.getMixinNames()).to.equal('Mixin3 Mixin1 Mixin2 Mixin3');
});
it('dedupes mixins applied on inherited base classes', () => {
const [Mixin1, Mixin2, Mixin3] = createMixins('Mixin1', 'Mixin2', 'Mixin3');
const DedupingMixin3 = dedupeMixin(Mixin3);
class BaseClass {}
class BaseMixedClass extends Mixin1(Mixin2(DedupingMixin3(BaseClass))) {}
class ExtendedMixedClass extends DedupingMixin3(BaseMixedClass) {}
const myObj = new ExtendedMixedClass();
expect(myObj.getMixinNames()).to.equal('Mixin1 Mixin2 Mixin3');
});
it('dedupes mixins applied via other mixins', () => {
const [Mixin1, Mixin2, Mixin3] = createMixins('Mixin1', 'Mixin2', 'Mixin3');
const DedupingMixin1 = dedupeMixin(Mixin1);
const DedupingMixin2 = dedupeMixin(Mixin2);
const DedupingMixin3 = dedupeMixin(Mixin3);
const Mixin123 = superClass => DedupingMixin1(DedupingMixin2(DedupingMixin3(superClass)));
const Mixin312 = superClass => DedupingMixin3(DedupingMixin1(DedupingMixin2(superClass)));
class BaseClass {}
class MixedClass extends Mixin312(Mixin123(BaseClass)) {}
const myObj = new MixedClass();
expect(myObj.getMixinNames()).to.equal('Mixin1 Mixin2 Mixin3');
});
// // ToDO: check with polymer3 mixin once we are on npm
// it('works with mixins deduped by Polymer', () => {
// const [Mixin1, Mixin2] = createMixins('Mixin1', 'Mixin2');
// const DedMixin1 = dedupeMixin(Mixin1);
// const PolMixin2 = dedupingMixin(Mixin2);
// class BaseClass {}
// class MixedClass extends DedMixin1(PolMixin2(DedMixin1(PolMixin2(BaseClass)))) {}
// const myObj = new MixedClass();
// expect(myObj.getMixinNames()).to.equal('Mixin1 Mixin2');
// });
});

View file

@ -0,0 +1,26 @@
/* eslint-env mocha */
import { expect, fixture } from '@open-wc/testing';
import { html } from '../src/lit-html.js';
describe('lit-html', () => {
it('binds values when parent has shadow root', async () => {
class ComponentWithShadowDom extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
}
customElements.define('component-with-shadow-dom', ComponentWithShadowDom);
const myNumber = 10;
const myFunction = () => {};
const element = await fixture(html`
<component-with-shadow-dom>
<any-element .propNumber=${myNumber} .propFunction=${myFunction}></any-element>
</component-with-shadow-dom>
`);
expect(element.children[0].propNumber).to.equal(myNumber);
expect(element.children[0].propFunction).to.equal(myFunction);
});
});

53
packages/field/README.md Normal file
View file

@ -0,0 +1,53 @@
# Form Fundaments
[//]: # (AUTO INSERT HEADER PREPUBLISH)
Fields are the most fundamental building block of the Form System. They are the basis of
both `field`s and `fieldset`s.
## What are fields?
Fields are the actual form controls the end user interacts with.
They extend the `LionField` class, which on its turn uses the `FormControlMixin`.
Fields provide a normalized, predictable API for platform components and customly made form controls.
On top of this, they feature:
- data synchronization with models
- formatting of view values
- advanced validation possibilities
- creation of advanced user interaction scenarios via `interaction states`
- provision of labels, help texts in an easy, declaritive manner
- better focus management
- accessibility out of the box
- advanced styling possibilities: map your own Design System to the internal HTML structure
### Platform wrappers
- `LionInput`, a wrapper for `<input>`
- `LionTextarea`, a wrapper for `<textarea>`
- `LionSelect`, a wrapper for `<select>`
### Custom wrappers
Whenever a native form control doesn't exist or is not sufficient, a custom form control should
be created. One could think of components like:
- slider
- combobox
- autocomplete
- etc...
## What are fieldsets?
Fieldsets are groups of fields. They can be considered fields on their own as well, since they
partly share the normalized api via `FormControlMixin`.
Fieldsets are the basis for:
- `LionFieldset`
- `LionForm`
- `LionRadioGroup`
- `LionCheckboxGroup`
# Other Resources
- `FormControlMixin` (TODO: document)
- `LionField` (TODO: document)
- [`Model values`](./docs/modelValue.md)
- [`FormatMixin`](./docs/FormatMixin.md)
- `InteractionStateMixin` (TODO: document)
- `ValidateMixin` (TODO: document)
- `FocusMixin` (TODO: document)
- `FieldCustomMixin` (TODO: document)

View file

@ -0,0 +1,2 @@
# FieldCustomMixin
TODO: document

View file

@ -0,0 +1,60 @@
# Form Fundaments
`Form control`s are the most fundamental building block of the Forms. They are the basis of
both `field`s and `fieldset`s, and the `form` itself.
## Fields
Fields are the actual form controls the end user interacts with.
They extend the `Field`(), which on its turn uses the `FormControlMixin`.
Fields provide a normalized, predictable API for platform components and custom made form controls.
On top of this, they feature:
- data synchronization with models
- formatting of view values
- advanced validation possibilities
- creation of advanced user interaction scenarios via `interaction states`
- provision of labels and help texts in an easy, declarative manner
- better focus management
- accessibility out of the box
- advanced styling possibilities: map your own Design System to the internal HTML structure
### Platform fields (wrappers)
- [`LionInput`](../../input/), a wrapper for `<input>`
- [`LionTextarea`](../../textarea/), a wrapper for `<textarea>`
- [`LionSelect`](../../select/), a wrapper for `<select>`
- [`LionRadio`](../../radio/), a wrapper for `<input type="radio">`
- [`LionCheckbox`](../../checkbox/), a wrapper for `<input type="checkbox">`
### Custom fields (wrappers)
Whenever a native form control doesn't exist or is not sufficient, a custom form control should
be created. One could think of components like:
- slider
- combobox
- autocomplete
- etc...
## Fieldsets
Fieldsets are groups of fields. They can be considered fields on their own as well, since they
partly share the normalized api via `FormControlMixin`.
Fieldsets are the basis for:
- [`LionFieldset`](../../fieldset/)
- [`LionForm`](../../form/)
- [`LionRadioGroup`](../../radio-group/)
- [`LionCheckboxGroup`](../../checkbox-group/)
## Other Resources
<!-- TODO: - [`FormControlMixin`] () -->
<!-- TODO: - [`LionField`] () -->
- [Model Value](./ModelValue.md)
- [Formatting and parsing](./FormattingAndParsing.md)
- [Interaction states](./InteractionStates.md)
- [Validation System](../../validate/docs/ValidationSystem.md)
- [FieldCustomMixin](./FieldCustomMixin.md)
<!-- TODO: - [`FocusMixin`] (/FocusMixin.md) -->

View file

@ -0,0 +1,95 @@
# FormatMixin
The FormatMixin keeps track of the `modelValue`, `formattedValue` and `serializedValue`.
It is designed to work in conjunction with `LionField`.
## Concepts of different values
### modelValue
The model value is the result of the parser function.
It should be considered as the internal value used for validation and reasoning/logic.
The model value is 'ready for consumption' by the outside world (think of a Date object
or a float). The modelValue can(and is recommended to) be used as both input value and
output value of the `<lion-field>`
Examples:
- For a date input: a String '20/01/1999' will be converted to new Date('1999-01-20')
- For a number input: a formatted String '1.234,56' will be converted to a Number: 1234.56
### formattedValue
The view value is the result of the formatter function (when available).
The result will be stored in the native inputElement (usually an input[type=text]).
Examples:
- For a date input, this would be '20/01/1999' (dependent on locale).
- For a number input, this could be '1,234.56' (a String representation of modelValue
1234.56)
### serializedValue
The serialized version of the model value.
This value exists for maximal compatibility with the platform API.
The serialized value can be an interface in context where data binding is not supported
and a serialized string needs to be set.
Examples:
- For a date input, this would be the iso format of a date, e.g. '1999-01-20'.
- For a number input this would be the String representation of a float ('1234.56' instead
of 1234.56)
When no parser is available, the value is usually the same as the formattedValue
(being inputElement.value)
## Formatters, parsers and (de)serializers
In order to create advanced user experiences (automatically formatting a user input or an input
set imperatively by an Application Developer).
Below some concrete examples can be found of implementations of formatters and parsers,
extrapolating the example of a date input.
### Formatters
A formatter should return a `formattedValue`:
```js
function formatDate(modelValue, options) {
if (!(modelValue instanceof Date)) {
return options.formattedValue;
}
return formatDateLocalized(modelValue, options);
}
```
Notice the options object, which holds a fallback value that shows what should be presented on
screen when the user input resulted in an invalid modelValue
### Parsers
A parser should return a `modelValue`:
```js
function parseDate(formattedValue, options) {
return formattedValue === '' ? undefined : parseDateLocalized(formattedValue);
}
```
Notice that when it's not possible to create a valid modelValue based on the formattedValue,
one should return `undefined`.
### Serializers and deserializers
A serializer should return a `serializedValue`:
```js
function serializeDate(modelValue, options) {
return modelValue.toISOString();
}
```
A deserializer should return a `modelValue`:
```js
function deserializeDate(serializeValue, options) {
return new Date(serializeValue);
}
```
### FieldCustomMixin
When creating your own custom input, please use `FieldCustomMixin` as a basis for this.
Concrete examples can be found at [`<lion-input-date>`](../../input-date) and
[`<lion-input-amount>`](../../input-amount).
## Flow diagram
The following flow diagram is based on both end user input and interaction programmed by the
developer. It shows how the 'computation loop' for modelValue, formattedValue and serializedValue
is triggered.
[Flow diagram](./formatterParserFlow.svg)

View file

@ -0,0 +1,114 @@
# Formatting and parsing
The `FormatMixin` keeps track of the `modelValue`, `formattedValue` and `serializedValue`.
It is designed to work in conjunction with `LionField`.
## Concepts of different values
### modelValue
The model value is the result of the parser function.
It should be considered as the internal value used for validation and reasoning/logic.
The model value is 'ready for consumption' by the outside world (think of a Date object
or a float). The modelValue can (and is recommended to) be used as both input value and
output value of the `<lion-field>`.
Examples:
- For a date input: a String '20/01/1999' will be converted to new Date('1999-01-20')
- For a number input: a formatted String '1.234,56' will be converted to a Number: 1234.56
### formattedValue
The view value is the result of the formatter function (when available).
The result will be stored in the native inputElement (usually an input[type=text]).
Examples:
- For a date input, this would be '20/01/1999' (dependent on locale).
- For a number input, this could be '1,234.56' (a String representation of modelValue
1234.56)
### serializedValue
The serialized version of the model value.
This value exists for maximal compatibility with the platform API.
The serialized value can be an interface in context where data binding is not supported
and a serialized string needs to be set.
Examples:
- For a date input, this would be the iso format of a date, e.g. '1999-01-20'.
- For a number input this would be the String representation of a float ('1234.56' instead
of 1234.56)
When no parser is available, the value is usually the same as the formattedValue
(being inputElement.value)
## Formatters, parsers and (de)serializers
In order to create advanced user experiences (automatically formatting a user input or an input
set imperatively by an Application Developer).
Below some concrete examples can be found of implementations of formatters and parsers,
extrapolating the example of a date input.
### Formatters
A formatter should return a `formattedValue`:
```js
function formatDate(modelValue, options) {
if (!(modelValue instanceof Date)) {
return options.formattedValue;
}
return formatDateLocalized(modelValue, options);
}
```
Note: the options object holds a fallback value that shows what should be presented on
screen when the user input resulted in an invalid modelValue
### Parsers
A parser should return a `modelValue`:
```js
function parseDate(formattedValue, options) {
return formattedValue === '' ? undefined : parseDateLocalized(formattedValue);
}
```
Notice that when it's not possible to create a valid modelValue based on the formattedValue,
one should return `undefined`.
### Serializers and deserializers
A serializer should return a `serializedValue`:
```js
function serializeDate(modelValue, options) {
return modelValue.toISOString();
}
```
A deserializer should return a `modelValue`:
```js
function deserializeDate(serializeValue, options) {
return new Date(serializeValue);
}
```
### FieldCustomMixin
When creating your own custom input, please use `FieldCustomMixin` as a basis for this.
Concrete examples can be found at [`<lion-input-date>`](../../input-date/) and
[`<lion-input-amount>`](../../input-amount/).
## Flow diagram
The following flow diagram is based on both end user input and interaction programmed by the
developer. It shows how the 'computation loop' for modelValue, formattedValue and serializedValue
is triggered.
[Flow diagram](./formatterParserFlow.svg)

View file

@ -0,0 +1,53 @@
# Interaction States
`InteractionStateMixin` saves meta information about interaction states. It allows the
Application Developer to create advanced UX scenarios.
Examples of such scenarios are:
- show the validation message of an input only after the user has blurred the input field
- hide the validation message when an invalid value becomes valid
- show a red border around the input right after the input became invalid
The meta information that InteractionStateMixin collects, is stored in the properties:
- `touched` : the user blurred the field
- `dirty` : the value in the field has changed
- `prefilled` : a prepopulated field is non empty
## Listenening for changes
Application Developers can subscribe to the events `touched-changed` and `dirty-changed`.
## Advanced use cases
### Overriding interaction states
When creating a [custom wrapper or platform wrapper](./FormFundaments.md), it can be needed to
override the way prefilled values are 'computed'. The example below shows how this is done for
checkboxes/radio-inputs.
```js
/**
* @override
*/
static _isPrefilled(modelValue) {
return modelValue.checked;
}
```
## How interaction states are preconfigured
We show the validity feedback when one of the following conditions is met:
- prefilled:
The user already filled in something, or the value is prefilled
when the form is initially rendered.
- touched && dirty && !prefilled:
When a user starts typing for the first time in a field with for instance `required` validation,
error message should not be shown until a field becomes `touched` (a user leaves(blurs) a field).
When a user enters a field without altering the value (making it `dirty` but not `touched`),
an error message shouldn't be shown either.
- submitted:
If the form is submitted, always show the error message.

Binary file not shown.

View file

@ -0,0 +1,267 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.2" baseProfile="tiny" width="210mm" height="297mm" viewBox="0 0 21000 29700" preserveAspectRatio="xMidYMid" fill-rule="evenodd" stroke-width="28.222" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve">
<defs>
<font id="EmbeddedFont_1" horiz-adv-x="2048">
<font-face font-family="Liberation Sans embedded" units-per-em="2048" font-weight="normal" font-style="normal" ascent="1851" descent="433"/>
<missing-glyph horiz-adv-x="2048" d="M 0,0 L 2047,0 2047,2047 0,2047 0,0 Z"/>
<glyph unicode="z" horiz-adv-x="927" d="M 40,0 L 40,146 716,922 C 639,918 572,916 513,916 L 80,916 80,1062 948,1062 948,943 373,269 262,146 C 343,152 418,155 489,155 L 980,155 980,0 Z"/>
<glyph unicode="y" horiz-adv-x="985" d="M 127,-409 L 107,-240 C 146,-251 181,-256 210,-256 250,-256 282,-249 306,-236 330,-223 350,-204 365,-180 376,-162 395,-117 420,-46 423,-36 429,-21 436,-2 L 33,1062 227,1062 448,447 C 477,369 502,287 525,201 546,284 570,364 599,443 L 826,1062 1006,1062 602,-18 C 559,-135 525,-215 501,-259 469,-318 432,-362 391,-390 350,-417 300,-431 243,-431 208,-431 170,-424 127,-409 Z"/>
<glyph unicode="w" horiz-adv-x="1458" d="M 331,0 L 6,1062 192,1062 361,449 424,221 C 427,232 445,305 479,440 L 648,1062 833,1062 992,446 1045,243 1106,448 1288,1062 1463,1062 1131,0 944,0 775,636 734,817 519,0 Z"/>
<glyph unicode="v" horiz-adv-x="965" d="M 430,0 L 26,1062 216,1062 444,426 C 469,357 491,286 512,212 528,268 550,335 579,414 L 815,1062 1000,1062 598,0 Z"/>
<glyph unicode="u" horiz-adv-x="867" d="M 831,0 L 831,156 C 748,36 636,-24 494,-24 431,-24 373,-12 319,12 264,36 224,66 198,103 171,139 153,183 142,236 135,271 131,327 131,404 L 131,1062 311,1062 311,473 C 311,379 315,316 322,283 333,236 357,199 394,172 431,145 476,131 530,131 584,131 635,145 682,173 729,200 763,238 783,286 802,333 812,402 812,493 L 812,1062 992,1062 992,0 Z"/>
<glyph unicode="t" horiz-adv-x="532" d="M 528,161 L 554,2 C 503,-9 458,-14 418,-14 353,-14 302,-4 266,17 230,38 205,65 190,99 175,132 168,203 168,311 L 168,922 36,922 36,1062 168,1062 168,1325 347,1433 347,1062 528,1062 528,922 347,922 347,301 C 347,250 350,217 357,202 363,187 373,176 388,167 402,158 422,154 449,154 469,154 495,156 528,161 Z"/>
<glyph unicode="s" horiz-adv-x="867" d="M 63,317 L 241,345 C 251,274 279,219 325,181 370,143 434,124 516,124 599,124 660,141 700,175 740,208 760,248 760,293 760,334 742,366 707,389 682,405 621,425 523,450 391,483 300,512 249,537 198,561 159,595 133,638 106,681 93,728 93,780 93,827 104,871 126,912 147,952 177,985 214,1012 242,1033 280,1050 329,1065 377,1079 429,1086 484,1086 567,1086 641,1074 704,1050 767,1026 813,994 843,953 873,912 894,857 905,788 L 729,764 C 721,819 698,861 660,892 621,923 567,938 497,938 414,938 355,924 320,897 285,870 267,838 267,801 267,778 274,757 289,738 304,719 327,703 358,690 376,683 429,668 517,644 644,610 733,582 784,561 834,539 873,507 902,466 931,425 945,373 945,312 945,252 928,196 893,143 858,90 807,49 741,20 675,-10 600,-24 517,-24 379,-24 274,5 202,62 129,119 83,204 63,317 Z"/>
<glyph unicode="r" horiz-adv-x="592" d="M 133,0 L 133,1062 295,1062 295,901 C 336,976 375,1026 410,1050 445,1074 483,1086 525,1086 586,1086 647,1067 710,1028 L 648,861 C 604,887 560,900 516,900 477,900 441,888 410,865 379,841 356,808 343,766 323,702 313,632 313,556 L 313,0 Z"/>
<glyph unicode="p" horiz-adv-x="927" d="M 135,-407 L 135,1062 299,1062 299,924 C 338,978 381,1019 430,1046 479,1073 538,1086 607,1086 698,1086 778,1063 847,1016 916,969 969,904 1004,819 1039,734 1057,640 1057,539 1057,430 1038,333 999,246 960,159 903,92 829,46 754,-1 676,-24 594,-24 534,-24 480,-11 433,14 385,39 346,71 315,110 L 315,-407 Z M 298,525 C 298,388 326,287 381,222 436,157 503,124 582,124 662,124 731,158 788,226 845,293 873,398 873,540 873,675 845,777 790,844 734,911 667,945 590,945 513,945 446,909 387,838 328,766 298,662 298,525 Z"/>
<glyph unicode="o" horiz-adv-x="986" d="M 68,531 C 68,728 123,873 232,968 323,1047 435,1086 566,1086 712,1086 831,1038 924,943 1017,847 1063,715 1063,546 1063,409 1043,302 1002,224 961,145 901,84 823,41 744,-2 659,-24 566,-24 417,-24 297,24 206,119 114,214 68,352 68,531 Z M 253,531 C 253,395 283,293 342,226 401,158 476,124 566,124 655,124 730,158 789,226 848,294 878,398 878,537 878,668 848,768 789,836 729,903 655,937 566,937 476,937 401,903 342,836 283,769 253,667 253,531 Z"/>
<glyph unicode="n" horiz-adv-x="867" d="M 135,0 L 135,1062 297,1062 297,911 C 375,1028 488,1086 635,1086 699,1086 758,1075 812,1052 865,1029 905,998 932,961 959,924 977,879 988,828 995,795 998,736 998,653 L 998,0 818,0 818,646 C 818,719 811,774 797,811 783,847 758,876 723,898 687,919 645,930 597,930 520,930 454,906 399,857 343,808 315,716 315,580 L 315,0 Z"/>
<glyph unicode="m" horiz-adv-x="1439" d="M 135,0 L 135,1062 296,1062 296,913 C 329,965 374,1007 429,1039 484,1070 547,1086 618,1086 697,1086 761,1070 812,1037 862,1004 897,959 918,900 1002,1024 1111,1086 1246,1086 1351,1086 1432,1057 1489,999 1546,940 1574,850 1574,729 L 1574,0 1395,0 1395,669 C 1395,741 1389,793 1378,825 1366,856 1345,882 1314,901 1283,920 1247,930 1206,930 1131,930 1069,905 1020,856 971,806 946,726 946,617 L 946,0 766,0 766,690 C 766,770 751,830 722,870 693,910 645,930 578,930 527,930 481,917 438,890 395,863 363,824 344,773 325,722 315,648 315,551 L 315,0 Z"/>
<glyph unicode="l" horiz-adv-x="178" d="M 131,0 L 131,1466 311,1466 311,0 Z"/>
<glyph unicode="k" horiz-adv-x="887" d="M 136,0 L 136,1466 316,1466 316,630 742,1062 975,1062 569,668 1016,0 794,0 443,543 316,421 316,0 Z"/>
<glyph unicode="i" horiz-adv-x="198" d="M 136,1259 L 136,1466 316,1466 316,1259 Z M 136,0 L 136,1062 316,1062 316,0 Z"/>
<glyph unicode="f" horiz-adv-x="631" d="M 178,0 L 178,922 19,922 19,1062 178,1062 178,1175 C 178,1246 184,1299 197,1334 214,1381 245,1419 289,1448 332,1477 393,1491 472,1491 523,1491 579,1485 640,1473 L 613,1316 C 576,1323 540,1326 507,1326 452,1326 414,1314 391,1291 368,1268 357,1224 357,1160 L 357,1062 564,1062 564,922 357,922 357,0 Z"/>
<glyph unicode="e" horiz-adv-x="986" d="M 862,342 L 1048,319 C 1019,210 964,126 885,66 806,6 704,-24 581,-24 426,-24 303,24 212,120 121,215 75,349 75,522 75,701 121,839 213,938 305,1037 424,1086 571,1086 713,1086 829,1038 919,941 1009,844 1054,708 1054,533 1054,522 1054,506 1053,485 L 261,485 C 268,368 301,279 360,217 419,155 493,124 582,124 648,124 704,141 751,176 798,211 835,266 862,342 Z M 271,633 L 864,633 C 856,722 833,789 796,834 739,903 664,938 573,938 490,938 421,910 365,855 308,800 277,726 271,633 Z"/>
<glyph unicode="d" horiz-adv-x="926" d="M 824,0 L 824,134 C 757,29 658,-24 527,-24 442,-24 365,-1 294,46 223,93 168,158 129,242 90,325 70,421 70,530 70,636 88,732 123,819 158,905 211,971 282,1017 353,1063 432,1086 519,1086 583,1086 640,1073 690,1046 740,1019 781,983 812,940 L 812,1466 991,1466 991,0 Z M 255,530 C 255,394 284,292 341,225 398,158 466,124 544,124 623,124 690,156 745,221 800,285 827,383 827,515 827,660 799,767 743,835 687,903 618,937 536,937 456,937 389,904 336,839 282,774 255,671 255,530 Z"/>
<glyph unicode="c" horiz-adv-x="926" d="M 828,389 L 1005,366 C 986,244 936,149 857,80 777,11 679,-24 563,-24 418,-24 301,24 213,119 124,214 80,350 80,527 80,642 99,742 137,828 175,914 233,979 311,1022 388,1065 473,1086 564,1086 679,1086 774,1057 847,999 920,940 967,857 988,750 L 813,723 C 796,794 767,848 725,884 682,920 631,938 571,938 480,938 407,906 350,841 293,776 265,673 265,532 265,389 292,286 347,221 402,156 473,124 561,124 632,124 691,146 738,189 785,232 815,299 828,389 Z"/>
<glyph unicode="b" horiz-adv-x="927" d="M 301,0 L 134,0 134,1466 314,1466 314,943 C 390,1038 487,1086 605,1086 670,1086 732,1073 791,1047 849,1020 897,983 935,936 972,888 1002,830 1023,763 1044,696 1055,624 1055,547 1055,365 1010,224 920,125 830,26 722,-24 596,-24 471,-24 372,28 301,133 Z M 299,539 C 299,412 316,320 351,263 408,170 484,124 581,124 660,124 728,158 785,227 842,295 871,397 871,532 871,671 844,773 789,839 734,905 667,938 589,938 510,938 442,904 385,836 328,767 299,668 299,539 Z"/>
<glyph unicode="a" horiz-adv-x="986" d="M 828,131 C 761,74 697,34 636,11 574,-12 508,-24 437,-24 320,-24 231,5 168,62 105,119 74,191 74,280 74,332 86,380 110,423 133,466 164,500 203,526 241,552 284,572 332,585 367,594 421,603 492,612 637,629 744,650 813,674 814,699 814,714 814,721 814,794 797,846 763,876 717,917 649,937 558,937 473,937 411,922 371,893 330,863 300,810 281,735 L 105,759 C 121,834 147,895 184,942 221,988 274,1024 343,1049 412,1074 493,1086 584,1086 675,1086 748,1075 805,1054 862,1033 903,1006 930,974 957,941 975,900 986,851 992,820 995,765 995,685 L 995,445 C 995,278 999,172 1007,128 1014,83 1029,41 1052,0 L 864,0 C 845,37 833,81 828,131 Z M 813,533 C 748,506 650,484 519,465 445,454 393,442 362,429 331,416 308,396 291,371 274,345 266,316 266,285 266,237 284,197 321,165 357,133 410,117 480,117 549,117 611,132 665,163 719,193 759,234 784,287 803,328 813,388 813,467 Z"/>
<glyph unicode="V" horiz-adv-x="1340" d="M 577,0 L 9,1466 219,1466 600,401 C 631,316 656,236 677,161 700,241 726,321 756,401 L 1152,1466 1350,1466 776,0 Z"/>
<glyph unicode="S" horiz-adv-x="1162" d="M 92,471 L 275,487 C 284,414 304,354 336,307 367,260 416,222 483,193 550,164 625,149 708,149 782,149 847,160 904,182 961,204 1003,234 1031,273 1058,311 1072,353 1072,398 1072,444 1059,484 1032,519 1005,553 961,582 900,605 861,620 774,644 639,677 504,709 410,739 356,768 286,805 234,850 200,905 165,959 148,1020 148,1087 148,1161 169,1230 211,1295 253,1359 314,1408 395,1441 476,1474 565,1491 664,1491 773,1491 869,1474 952,1439 1035,1404 1098,1352 1143,1284 1188,1216 1212,1139 1215,1053 L 1029,1039 C 1019,1132 985,1202 928,1249 870,1296 785,1320 672,1320 555,1320 469,1299 416,1256 362,1213 335,1161 335,1100 335,1047 354,1004 392,970 429,936 527,901 685,866 842,830 950,799 1009,772 1094,733 1157,683 1198,623 1239,562 1259,493 1259,414 1259,336 1237,263 1192,194 1147,125 1083,71 1000,33 916,-6 822,-25 717,-25 584,-25 473,-6 384,33 294,72 224,130 173,208 122,285 95,373 92,471 Z"/>
<glyph unicode="C" horiz-adv-x="1301" d="M 1204,514 L 1398,465 C 1357,306 1284,184 1179,101 1073,17 944,-25 791,-25 633,-25 505,7 406,72 307,136 231,229 180,351 128,473 102,604 102,744 102,897 131,1030 190,1144 248,1257 331,1344 439,1403 546,1462 665,1491 794,1491 941,1491 1064,1454 1164,1379 1264,1304 1334,1199 1373,1064 L 1182,1019 C 1148,1126 1099,1203 1034,1252 969,1301 888,1325 790,1325 677,1325 583,1298 508,1244 432,1190 379,1118 348,1027 317,936 302,842 302,745 302,620 320,512 357,419 393,326 449,256 526,210 603,164 686,141 775,141 884,141 976,172 1051,235 1126,298 1177,391 1204,514 Z"/>
<glyph unicode="&gt;" horiz-adv-x="986" d="M 1083,641 L 112,226 112,405 881,724 112,1040 112,1219 1083,809 Z"/>
<glyph unicode="&lt;" horiz-adv-x="986" d="M 112,641 L 112,809 1083,1219 1083,1040 313,724 1083,405 1083,226 Z"/>
<glyph unicode="-" horiz-adv-x="552" d="M 65,440 L 65,621 618,621 618,440 Z"/>
<glyph unicode=")" horiz-adv-x="474" d="M 253,-431 L 124,-431 C 323,-111 423,209 423,530 423,655 409,780 380,903 357,1003 326,1099 285,1191 259,1251 205,1351 124,1491 L 253,1491 C 378,1324 471,1156 531,987 582,842 608,690 608,531 608,351 574,177 505,9 436,-159 352,-306 253,-431 Z"/>
<glyph unicode="(" horiz-adv-x="474" d="M 479,-431 C 380,-306 296,-159 227,9 158,177 124,351 124,531 124,690 150,842 201,987 261,1156 354,1324 479,1491 L 608,1491 C 527,1352 474,1253 448,1194 407,1102 375,1006 352,906 323,781 309,656 309,530 309,209 409,-111 608,-431 Z"/>
<glyph unicode=" " horiz-adv-x="571"/>
</font>
</defs>
<defs>
<font id="EmbeddedFont_2" horiz-adv-x="2048">
<font-face font-family="Liberation Sans embedded" units-per-em="2048" font-weight="bold" font-style="normal" ascent="1851" descent="433"/>
<missing-glyph horiz-adv-x="2048" d="M 0,0 L 2047,0 2047,2047 0,2047 0,0 Z"/>
<glyph unicode="v" horiz-adv-x="1104" d="M 439,0 L 11,1062 306,1062 506,520 564,339 C 579,385 589,415 593,430 602,460 612,490 623,520 L 825,1062 1114,1062 692,0 Z"/>
<glyph unicode="u" horiz-adv-x="966" d="M 846,0 L 846,159 C 807,102 757,58 694,25 631,-8 564,-24 494,-24 423,-24 359,-8 302,23 245,54 204,98 179,155 154,212 141,290 141,390 L 141,1062 422,1062 422,574 C 422,425 427,333 438,300 448,266 467,239 494,220 521,200 556,190 598,190 646,190 689,203 727,230 765,256 791,289 805,328 819,367 826,462 826,614 L 826,1062 1107,1062 1107,0 Z"/>
<glyph unicode="s" horiz-adv-x="986" d="M 48,303 L 330,346 C 342,291 366,250 403,222 440,193 491,179 557,179 630,179 684,192 721,219 746,238 758,263 758,294 758,315 751,333 738,347 724,360 693,373 644,384 417,434 274,480 213,521 129,578 87,658 87,760 87,852 123,929 196,992 269,1055 381,1086 534,1086 679,1086 787,1062 858,1015 929,968 977,898 1004,805 L 739,756 C 728,797 706,829 675,851 643,873 598,884 539,884 465,884 412,874 380,853 359,838 348,819 348,796 348,776 357,759 376,745 401,726 489,700 639,666 788,632 893,590 952,541 1011,491 1040,421 1040,332 1040,235 999,151 918,81 837,11 716,-24 557,-24 412,-24 298,5 214,64 129,123 74,202 48,303 Z"/>
<glyph unicode="r" horiz-adv-x="690" d="M 416,0 L 135,0 135,1062 396,1062 396,911 C 441,982 481,1029 517,1052 552,1075 593,1086 638,1086 702,1086 764,1068 823,1033 L 736,788 C 689,819 645,834 604,834 565,834 531,823 504,802 477,780 455,741 440,684 424,627 416,509 416,328 Z"/>
<glyph unicode="p" horiz-adv-x="1025" d="M 139,1062 L 401,1062 401,906 C 435,959 481,1003 539,1036 597,1069 661,1086 732,1086 855,1086 960,1038 1046,941 1132,844 1175,710 1175,537 1175,360 1132,222 1045,124 958,25 853,-24 730,-24 671,-24 618,-12 571,11 523,34 473,74 420,131 L 420,-404 139,-404 Z M 417,549 C 417,430 441,342 488,285 535,228 593,199 661,199 726,199 781,225 824,278 867,330 889,416 889,535 889,646 867,729 822,783 777,837 722,864 656,864 587,864 530,838 485,785 440,732 417,653 417,549 Z"/>
<glyph unicode="o" horiz-adv-x="1084" d="M 82,546 C 82,639 105,730 151,817 197,904 262,971 347,1017 431,1063 525,1086 629,1086 790,1086 921,1034 1024,930 1127,825 1178,693 1178,534 1178,373 1126,240 1023,135 919,29 788,-24 631,-24 534,-24 441,-2 353,42 264,86 197,151 151,236 105,321 82,424 82,546 Z M 370,531 C 370,426 395,345 445,289 495,233 557,205 630,205 703,205 765,233 815,289 864,345 889,426 889,533 889,637 864,717 815,773 765,829 703,857 630,857 557,857 495,829 445,773 395,717 370,636 370,531 Z"/>
<glyph unicode="n" horiz-adv-x="966" d="M 1113,0 L 832,0 832,542 C 832,657 826,731 814,765 802,798 783,824 756,843 729,862 696,871 658,871 609,871 566,858 527,831 488,804 462,769 448,725 433,681 426,600 426,481 L 426,0 145,0 145,1062 406,1062 406,906 C 499,1026 615,1086 756,1086 818,1086 875,1075 926,1053 977,1030 1016,1002 1043,967 1069,932 1087,893 1098,849 1108,805 1113,742 1113,660 Z"/>
<glyph unicode="l" horiz-adv-x="276" d="M 147,0 L 147,1466 428,1466 428,0 Z"/>
<glyph unicode="e" horiz-adv-x="986" d="M 762,338 L 1042,291 C 1006,188 949,110 872,57 794,3 697,-24 580,-24 395,-24 259,36 170,157 100,254 65,376 65,523 65,699 111,837 203,937 295,1036 411,1086 552,1086 710,1086 835,1034 926,930 1017,825 1061,665 1057,450 L 353,450 C 355,367 378,302 421,256 464,209 518,186 583,186 627,186 664,198 694,222 724,246 747,285 762,338 Z M 778,622 C 776,703 755,765 715,808 675,850 626,871 569,871 508,871 457,849 417,804 377,759 357,699 358,622 Z"/>
<glyph unicode="d" horiz-adv-x="1025" d="M 1121,0 L 860,0 860,156 C 817,95 766,50 707,21 648,-9 588,-24 528,-24 406,-24 302,25 215,124 128,222 84,359 84,535 84,715 126,852 211,946 296,1039 403,1086 532,1086 651,1086 753,1037 840,938 L 840,1466 1121,1466 Z M 371,554 C 371,441 387,359 418,308 463,235 527,198 608,198 673,198 728,226 773,281 818,336 841,418 841,527 841,649 819,737 775,791 731,844 675,871 606,871 539,871 484,845 439,792 394,739 371,659 371,554 Z"/>
<glyph unicode=" " horiz-adv-x="571"/>
</font>
</defs>
<g visibility="visible" id="MasterSlide_1_Default">
<desc>Master slide
</desc>
<rect fill="none" stroke="none" x="0" y="0" width="21000" height="29700"/>
</g>
<g visibility="visible" id="Slide_1_page1">
<g>
<path fill="rgb(238,238,238)" stroke="none" d="M 10500,29700 L 0,29700 0,0 21000,0 21000,29700 10500,29700 Z"/>
<path fill="none" stroke="rgb(52,101,164)" id="Drawing_1_0" stroke-linejoin="round" d="M 10500,29700 L 0,29700 0,0 21000,0 21000,29700 10500,29700 Z"/>
<rect fill="none" stroke="none" x="0" y="0" width="21001" height="29701"/>
</g>
<g>
<path fill="rgb(255,255,255)" stroke="none" d="M 10842,27162 L 2650,27162 2650,2224 19033,2224 19033,27162 10842,27162 Z"/>
<path fill="none" stroke="rgb(52,101,164)" id="Drawing_2_0" stroke-linejoin="round" d="M 10842,27162 L 2650,27162 2650,2224 19033,2224 19033,27162 10842,27162 Z"/>
<rect fill="none" stroke="none" x="2651" y="2224" width="16384" height="24939"/>
</g>
<g id="Drawing_3">
<path fill="rgb(255,255,255)" stroke="none" d="M 7245,1381 C 8613,1381 9658,1821 9658,2397 9658,2973 8613,3413 7245,3413 5877,3413 4832,2973 4832,2397 4832,1821 5877,1381 7245,1381 Z"/>
<path fill="none" stroke="rgb(52,101,164)" stroke-linejoin="round" d="M 7245,1381 C 8613,1381 9658,1821 9658,2397 9658,2973 8613,3413 7245,3413 5877,3413 4832,2973 4832,2397 4832,1821 5877,1381 7245,1381 Z"/>
<rect fill="none" stroke="none" x="4832" y="1381" width="4827" height="2033"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="494" font-style="normal" font-weight="400">
<text x="5473" y="2569">
<tspan x="5473 5719 5994 6133 6269 6680 6955 7230 7505 7615 7945 8220 8331 8601 8877">set modelValue </tspan></text>
</g>
</g>
<g id="Drawing_4">
<path fill="rgb(255,255,255)" stroke="none" d="M 10021,12655 C 9862,12655 9704,12813 9704,12972 L 9704,14243 C 9704,14402 9862,14561 10021,14561 L 14594,14561 C 14753,14561 14912,14402 14912,14243 L 14912,12972 C 14912,12813 14753,12655 14594,12655 L 10021,12655 Z"/>
<path fill="none" stroke="rgb(52,101,164)" stroke-linejoin="round" d="M 10021,12655 C 9862,12655 9704,12813 9704,12972 L 9704,14243 C 9704,14402 9862,14561 10021,14561 L 14594,14561 C 14753,14561 14912,14402 14912,14243 L 14912,12972 C 14912,12813 14753,12655 14594,12655 L 10021,12655 Z"/>
<rect fill="none" stroke="none" x="9705" y="12656" width="5208" height="1906"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="459" font-style="normal" font-weight="400">
<text x="10164" y="13255">
<tspan x="10164 10494 10752 11006 11235 11489 11646 11773 11900 12281 12535 12793 13047 13149 13453 13712 13813 14067 14321">Convert modelValue </tspan></text>
<text fill="rgb(0,0,0)" stroke="none" x="10432" y="13767">
<tspan x="10432 10559 10813 10944 11071 11325 11478 11859 12117 12244 12371 12625 12883 13188 13442 13544 13802 14056">to formattedValue </tspan></text>
<text fill="rgb(0,0,0)" stroke="none" x="10841" y="14279">
<tspan x="10841 10993 11146 11404 11658 11785 11912 12166 12318 12704 12958 13085 13212 13470 13622">(run formatter)</tspan></text>
</g>
</g>
<g id="Drawing_5">
<path fill="rgb(255,255,255)" stroke="none" d="M 9915,16509 C 9809,16509 9704,16614 9704,16720 L 9704,17568 C 9704,17674 9809,17780 9915,17780 L 14700,17780 C 14806,17780 14912,17674 14912,17568 L 14912,16720 C 14912,16614 14806,16509 14700,16509 L 9915,16509 Z"/>
<path fill="none" stroke="rgb(52,101,164)" stroke-linejoin="round" d="M 9915,16509 C 9809,16509 9704,16614 9704,16720 L 9704,17568 C 9704,17674 9809,17780 9915,17780 L 14700,17780 C 14806,17780 14912,17674 14912,17568 L 14912,16720 C 14912,16614 14806,16509 14700,16509 L 9915,16509 Z"/>
<rect fill="none" stroke="none" x="9705" y="16509" width="5208" height="1271"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="459" font-style="normal" font-weight="400">
<text x="10113" y="17047">
<tspan x="10113 10418 10651 10905 11133 11260 11387 11645 11798 12179 12433 12560 12691 12945 13199 13504 13762 13864 14118 14372">Sync formattedValue </tspan></text>
<text fill="rgb(0,0,0)" stroke="none" x="10674" y="17559">
<tspan x="10674 10801 11055 11186 11453 11555 11809 12067 12321 12448 12714 12841 13074 13328 13430 13684">to &lt;input&gt; value</tspan></text>
</g>
</g>
<g id="Drawing_6">
<path fill="rgb(255,255,255)" stroke="none" d="M 3276,8857 C 3095,8857 2914,9038 2914,9219 L 2914,10670 C 2914,10851 3095,11033 3276,11033 L 7759,11033 C 7940,11033 8121,10851 8121,10670 L 8121,9219 C 8121,9038 7940,8857 7759,8857 L 3276,8857 Z"/>
<path fill="none" stroke="rgb(52,101,164)" stroke-linejoin="round" d="M 3276,8857 C 3095,8857 2914,9038 2914,9219 L 2914,10670 C 2914,10851 3095,11033 3276,11033 L 7759,11033 C 7940,11033 8121,10851 8121,10670 L 8121,9219 C 8121,9038 7940,8857 7759,8857 L 3276,8857 Z"/>
<rect fill="none" stroke="none" x="2915" y="8858" width="5208" height="2176"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="459" font-style="normal" font-weight="400">
<text x="3374" y="9435">
<tspan x="3374 3704 3962 4216 4445 4699 4856 4983 5110 5491 5745 6003 6257 6359 6663 6922 7023 7277 7531">Convert modelValue </tspan></text>
<text fill="rgb(0,0,0)" stroke="none" x="3706" y="9947">
<tspan x="3706 3833 4087 4218 4447 4701 4853 4955 5213 5315 5416 5645 5899 6157 6462 6716 6818 7076">to serializedValue</tspan></text>
<text fill="rgb(0,0,0)" stroke="none" x="3987" y="10459"> </text>
<text fill="rgb(0,0,0)" stroke="none" x="4114" y="10459">
<tspan x="4114 4266 4419 4677 4931 5058 5287 5541 5697 5799 6053 6154 6256 6485 6743 6895">(run serializer)</tspan></text>
</g>
</g>
<g id="Drawing_7">
<path fill="rgb(255,255,255)" stroke="none" d="M 13722,1254 C 15090,1254 16135,1694 16135,2270 16135,2846 15090,3286 13722,3286 12354,3286 11309,2846 11309,2270 11309,1694 12354,1254 13722,1254 Z"/>
<path fill="none" stroke="rgb(52,101,164)" stroke-linejoin="round" d="M 13722,1254 C 15090,1254 16135,1694 16135,2270 16135,2846 15090,3286 13722,3286 12354,3286 11309,2846 11309,2270 11309,1694 12354,1254 13722,1254 Z"/>
<rect fill="none" stroke="none" x="11309" y="1254" width="4827" height="2033"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="494" font-style="normal" font-weight="400">
<text x="13324" y="2164">
<tspan x="13324 13570 13845 13984">set </tspan></text>
<text fill="rgb(0,0,0)" stroke="none" x="11978" y="2719">
<tspan x="11978 12224 12499 12664 12774 13049 13159 13269 13515 13790 14065 14395 14670 14780 15051 15327">serializedValue </tspan></text>
</g>
</g>
<g id="Drawing_8">
<path fill="rgb(255,255,255)" stroke="none" d="M 11438,5556 C 11246,5556 11055,5747 11055,5939 L 11055,7475 C 11055,7667 11246,7859 11438,7859 L 16022,7859 C 16214,7859 16406,7667 16406,7475 L 16406,5939 C 16406,5747 16214,5556 16022,5556 L 11438,5556 Z"/>
<path fill="none" stroke="rgb(52,101,164)" stroke-linejoin="round" d="M 11438,5556 C 11246,5556 11055,5747 11055,5939 L 11055,7475 C 11055,7667 11246,7859 11438,7859 L 16022,7859 C 16214,7859 16406,7667 16406,7475 L 16406,5939 C 16406,5747 16214,5556 16022,5556 L 11438,5556 Z"/>
<rect fill="none" stroke="none" x="11055" y="5556" width="5351" height="2303"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="459" font-style="normal" font-weight="400">
<text x="11187" y="6367">
<tspan x="11187 11517 11775 12029 12258 12512 12669 12796">Convert </tspan></text>
</g>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="494" font-style="normal" font-weight="400">
<text x="12923" y="6367">
<tspan x="12923 13169 13444 13609 13719 13994 14104 14214 14460 14735 15010 15340 15615 15725 15996">serializedValue</tspan></text>
</g>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="459" font-style="normal" font-weight="400">
<text x="12136" y="6888"> </text>
<text fill="rgb(0,0,0)" stroke="none" x="12263" y="6888">
<tspan x="12263 12390 12644 12775 13156 13410 13664 13922 14024 14329 14583 14689 14943 15197">to modelValue </tspan></text>
<text fill="rgb(0,0,0)" stroke="none" x="12009" y="7400">
<tspan x="12009 12161 12314 12572 12826 12953 13207 13465 13694 13948 14100 14202 14460 14562 14663 14892 15146 15298">(run deserializer)</tspan></text>
</g>
</g>
<g>
<path fill="rgb(114,159,207)" stroke="none" d="M 14821,12202 C 14647,12202 14503,12245 14503,12297 L 14503,12869 C 14503,12921 14647,12965 14821,12965 14994,12965 15139,12921 15139,12869 L 15139,12297 C 15139,12245 14994,12202 14821,12202 L 14821,12202 Z"/>
<path fill="none" stroke="rgb(52,101,164)" id="Drawing_9_0" stroke-linejoin="round" d="M 14821,12202 C 14647,12202 14503,12245 14503,12297 L 14503,12869 C 14503,12921 14647,12965 14821,12965 14994,12965 15139,12921 15139,12869 L 15139,12297 C 15139,12245 14994,12202 14821,12202 L 14821,12202 Z"/>
<path fill="rgb(165,195,226)" stroke="none" d="M 14821,12202 C 14647,12202 14503,12245 14503,12297 14503,12349 14647,12392 14821,12392 14994,12392 15139,12349 15139,12297 15139,12245 14994,12202 14821,12202 L 14821,12202 Z"/>
<path fill="none" stroke="rgb(52,101,164)" id="Drawing_9_1" stroke-linejoin="round" d="M 14821,12202 C 14647,12202 14503,12245 14503,12297 14503,12349 14647,12392 14821,12392 14994,12392 15139,12349 15139,12297 15139,12245 14994,12202 14821,12202 L 14821,12202 Z"/>
<rect fill="none" stroke="none" x="14504" y="12202" width="636" height="763"/>
</g>
<g>
<path fill="rgb(114,159,207)" stroke="none" d="M 16260,5148 C 16086,5148 15942,5191 15942,5243 L 15942,5815 C 15942,5867 16086,5911 16260,5911 16433,5911 16578,5867 16578,5815 L 16578,5243 C 16578,5191 16433,5148 16260,5148 L 16260,5148 Z"/>
<path fill="none" stroke="rgb(52,101,164)" id="Drawing_10_0" stroke-linejoin="round" d="M 16260,5148 C 16086,5148 15942,5191 15942,5243 L 15942,5815 C 15942,5867 16086,5911 16260,5911 16433,5911 16578,5867 16578,5815 L 16578,5243 C 16578,5191 16433,5148 16260,5148 L 16260,5148 Z"/>
<path fill="rgb(165,195,226)" stroke="none" d="M 16260,5148 C 16086,5148 15942,5191 15942,5243 15942,5295 16086,5338 16260,5338 16433,5338 16578,5295 16578,5243 16578,5191 16433,5148 16260,5148 L 16260,5148 Z"/>
<path fill="none" stroke="rgb(52,101,164)" id="Drawing_10_1" stroke-linejoin="round" d="M 16260,5148 C 16086,5148 15942,5191 15942,5243 15942,5295 16086,5338 16260,5338 16433,5338 16578,5295 16578,5243 16578,5191 16433,5148 16260,5148 L 16260,5148 Z"/>
<rect fill="none" stroke="none" x="15943" y="5148" width="636" height="763"/>
</g>
<g>
<path fill="rgb(114,159,207)" stroke="none" d="M 7931,8377 C 7757,8377 7613,8420 7613,8472 L 7613,9044 C 7613,9096 7757,9140 7931,9140 8104,9140 8249,9096 8249,9044 L 8249,8472 C 8249,8420 8104,8377 7931,8377 L 7931,8377 Z"/>
<path fill="none" stroke="rgb(52,101,164)" id="Drawing_11_0" stroke-linejoin="round" d="M 7931,8377 C 7757,8377 7613,8420 7613,8472 L 7613,9044 C 7613,9096 7757,9140 7931,9140 8104,9140 8249,9096 8249,9044 L 8249,8472 C 8249,8420 8104,8377 7931,8377 L 7931,8377 Z"/>
<path fill="rgb(165,195,226)" stroke="none" d="M 7931,8377 C 7757,8377 7613,8420 7613,8472 7613,8524 7757,8567 7931,8567 8104,8567 8249,8524 8249,8472 8249,8420 8104,8377 7931,8377 L 7931,8377 Z"/>
<path fill="none" stroke="rgb(52,101,164)" id="Drawing_11_1" stroke-linejoin="round" d="M 7931,8377 C 7757,8377 7613,8420 7613,8472 7613,8524 7757,8567 7931,8567 8104,8567 8249,8524 8249,8472 8249,8420 8104,8377 7931,8377 L 7931,8377 Z"/>
<rect fill="none" stroke="none" x="7614" y="8377" width="636" height="763"/>
</g>
<g>
<path fill="rgb(114,159,207)" stroke="none" d="M 14672,16134 C 14619,16134 14567,16165 14567,16197 L 14567,16245 14567,16292 14567,16357 14567,16404 14567,16452 C 14567,16484 14619,16516 14672,16516 L 14692,16929 14831,16516 14938,16516 15018,16516 15097,16516 C 15150,16516 15203,16484 15203,16452 L 15203,16404 15203,16357 15203,16292 15203,16245 15203,16197 C 15203,16165 15150,16134 15097,16134 L 15018,16134 14938,16134 14831,16134 14751,16134 14672,16134 Z"/>
<path fill="none" stroke="rgb(52,101,164)" id="Drawing_12_0" stroke-linejoin="round" d="M 14672,16134 C 14619,16134 14567,16165 14567,16197 L 14567,16245 14567,16292 14567,16357 14567,16404 14567,16452 C 14567,16484 14619,16516 14672,16516 L 14692,16929 14831,16516 14938,16516 15018,16516 15097,16516 C 15150,16516 15203,16484 15203,16452 L 15203,16404 15203,16357 15203,16292 15203,16245 15203,16197 C 15203,16165 15150,16134 15097,16134 L 15018,16134 14938,16134 14831,16134 14751,16134 14672,16134 Z"/>
<rect fill="none" stroke="none" x="14568" y="16135" width="636" height="382"/>
</g>
<g id="Drawing_13">
<rect fill="none" stroke="none" x="8820" y="619" width="3684" height="1266"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="635" font-style="normal" font-weight="700">
<text x="9070" y="1320">
<tspan x="9070 9459 9811 10162 10518 10696 11081 11470 11822 12071">developer </tspan></text>
</g>
</g>
<g id="Drawing_14">
<rect fill="none" stroke="none" x="9509" y="27996" width="3684" height="1801"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="635" font-style="normal" font-weight="700">
<text x="9759" y="28697">
<tspan x="9759 10110 10500 10889 11063 11452 11804 12159 12405">end user </tspan></text>
</g>
</g>
<g id="Drawing_15">
<path fill="rgb(255,255,255)" stroke="none" d="M 7669,26011 C 9037,26011 10082,26451 10082,27027 10082,27603 9037,28043 7669,28043 6301,28043 5256,27603 5256,27027 5256,26451 6301,26011 7669,26011 Z"/>
<path fill="none" stroke="rgb(52,101,164)" stroke-linejoin="round" d="M 7669,26011 C 9037,26011 10082,26451 10082,27027 10082,27603 9037,28043 7669,28043 6301,28043 5256,27603 5256,27027 5256,26451 6301,26011 7669,26011 Z"/>
<rect fill="none" stroke="none" x="5256" y="26011" width="4827" height="2033"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="494" font-style="normal" font-weight="400">
<text x="5732" y="27199">
<tspan x="5732 5978 6253 6502 6773 7049 7408 7684 7819 8107 8217 8492 8767 9042 9178 9466">keydown &lt;input&gt; </tspan></text>
</g>
</g>
<g id="Drawing_16">
<path fill="rgb(255,255,255)" stroke="none" d="M 5274,19796 C 5168,19796 5063,19901 5063,20007 L 5063,20855 C 5063,20961 5168,21067 5274,21067 L 10059,21067 C 10165,21067 10271,20961 10271,20855 L 10271,20007 C 10271,19901 10165,19796 10059,19796 L 5274,19796 Z"/>
<path fill="none" stroke="rgb(52,101,164)" stroke-linejoin="round" d="M 5274,19796 C 5168,19796 5063,19901 5063,20007 L 5063,20855 C 5063,20961 5168,21067 5274,21067 L 10059,21067 C 10165,21067 10271,20961 10271,20855 L 10271,20007 C 10271,19901 10165,19796 10059,19796 L 5274,19796 Z"/>
<rect fill="none" stroke="none" x="5064" y="19796" width="5208" height="1271"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="459" font-style="normal" font-weight="400">
<text x="5459" y="20334">
<tspan x="5459 5764 5997 6251 6479 6606 6873 6979 7233 7487 7741 7872 8139 8266 8494 8753 8854 9108 9362 9489 9616">Sync &lt;input&gt; value to</tspan></text>
<text fill="rgb(0,0,0)" stroke="none" x="6390" y="20846"> </text>
<text fill="rgb(0,0,0)" stroke="none" x="6517" y="20846">
<tspan x="6517 6898 7156 7410 7664 7766 8075 8329 8430 8684">modelValue</tspan></text>
</g>
</g>
<g id="Drawing_17">
<path fill="rgb(255,255,255)" stroke="none" d="M 14754,25811 C 16122,25811 17167,26251 17167,26827 17167,27403 16122,27843 14754,27843 13386,27843 12341,27403 12341,26827 12341,26251 13386,25811 14754,25811 Z"/>
<path fill="none" stroke="rgb(52,101,164)" stroke-linejoin="round" d="M 14754,25811 C 16122,25811 17167,26251 17167,26827 17167,27403 16122,27843 14754,27843 13386,27843 12341,27403 12341,26827 12341,26251 13386,25811 14754,25811 Z"/>
<rect fill="none" stroke="none" x="12341" y="25811" width="4827" height="2033"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="494" font-style="normal" font-weight="400">
<text x="13381" y="26999">
<tspan x="13381 13656 13766 14041 14207 14342 14630 14740 15015 15290 15565 15701 15989">blur &lt;input&gt; </tspan></text>
</g>
</g>
<g>
<path fill="none" stroke="rgb(0,0,0)" id="Drawing_18_0" stroke-linejoin="round" d="M 13730,7858 L 13730,10652 12308,10652 12308,12206"/>
<path fill="rgb(0,0,0)" stroke="none" id="Drawing_18_1" d="M 12308,12656 L 12458,12206 12158,12206 12308,12656 Z"/>
</g>
<g>
<path fill="none" stroke="rgb(0,0,0)" id="Drawing_19_0" stroke-linejoin="round" d="M 13722,3286 L 13722,4421 13730,4421 13730,5106"/>
<path fill="rgb(0,0,0)" stroke="none" id="Drawing_19_1" d="M 13730,5556 L 13880,5106 13580,5106 13730,5556 Z"/>
</g>
<g>
<path fill="none" stroke="rgb(0,0,0)" id="Drawing_20_0" stroke-linejoin="round" d="M 17167,26827 L 17668,26827 17668,13608 15362,13608"/>
<path fill="rgb(0,0,0)" stroke="none" id="Drawing_20_1" d="M 14912,13608 L 15362,13758 15362,13458 14912,13608 Z"/>
</g>
<g>
<path fill="none" stroke="rgb(0,0,0)" id="Drawing_21_0" stroke-linejoin="round" d="M 7669,26011 L 7669,23539 7667,23539 7667,21516"/>
<path fill="rgb(0,0,0)" stroke="none" id="Drawing_21_1" d="M 7667,21066 L 7517,21516 7817,21516 7667,21066 Z"/>
</g>
<g>
<path fill="none" stroke="rgb(0,0,0)" id="Drawing_22_0" stroke-linejoin="round" d="M 8952,3116 L 8952,8620 12308,8620 12308,12206"/>
<path fill="rgb(0,0,0)" stroke="none" id="Drawing_22_1" d="M 12308,12656 L 12458,12206 12158,12206 12308,12656 Z"/>
</g>
<g>
<path fill="none" stroke="rgb(0,0,0)" id="Drawing_23_0" stroke-linejoin="round" d="M 5538,3116 L 5538,6135 5518,6135 5518,8408"/>
<path fill="rgb(0,0,0)" stroke="none" id="Drawing_23_1" d="M 5518,8858 L 5668,8408 5368,8408 5518,8858 Z"/>
</g>
<g>
<path fill="none" stroke="rgb(0,0,0)" id="Drawing_24_0" stroke-linejoin="round" d="M 12308,14561 L 12308,16059"/>
<path fill="rgb(0,0,0)" stroke="none" id="Drawing_24_1" d="M 12308,16509 L 12458,16059 12158,16059 12308,16509 Z"/>
</g>
<g>
<path fill="none" stroke="rgb(0,0,0)" id="Drawing_25_0" stroke-linejoin="round" d="M 7705,19796 L 7705,15665 5518,15665 5518,11483"/>
<path fill="rgb(0,0,0)" stroke="none" id="Drawing_25_1" d="M 5518,11033 L 5368,11483 5668,11483 5518,11033 Z"/>
</g>
<g id="Drawing_26">
<rect fill="none" stroke="none" x="2850" y="3031" width="1675" height="3812"/>
<g fill="rgb(0,0,0)" stroke="none" font-family="Liberation Sans embedded" font-size="635" font-style="normal" font-weight="400">
<g transform="translate(3552,6592) rotate(-90) translate(-3552,-6592)">
<text x="3552" y="6592">
<tspan x="3552 3925 4064 4204 4560 4911 5123 5300 5440 5791 5935 6287">&lt;lion-field&gt;</tspan></text>
</g>
</g>
</g>
<g>
<path fill="none" stroke="rgb(0,0,0)" id="Drawing_27_0" stroke-linejoin="round" d="M 7705,19796 L 7718,19796 7718,13608 9255,13608"/>
<path fill="rgb(0,0,0)" stroke="none" id="Drawing_27_1" d="M 9705,13608 L 9255,13458 9255,13758 9705,13608 Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -0,0 +1,62 @@
# ModelValue
The modelValue or model can be considered as the aorta of our form system.
It is the single source of truth; not only for the current state
of the form, also for all derived states: interaction, validation, visibility and other states are
computed from a modelValue change.
## Single source of truth
ModelValues are designed to provide the Application Developer a single way of programmatical
interaction with the form for an Application Developer.
### One single concept for Application Developers
Application Developers need to only care about interacting with the modelValue on a form control
level, via:
- `.modelValue`
- `@model-value-changed`
> Internal/private concepts like viewValue, formattedValue, serializedValue are therefore not
recommended as a means of interaction.
### One single concept for internals
Internally, all derived states are computed from model-value-changed events.
Since the modelValue is computed 'realtime' and reflects all user interaction, visibility and
validation states, we can guarantee a system that enables the best User Experience
(see Interaction States).
## Unparseable modelValues
A modelValue can demand a certain type (Date, Number, Iban etc.). A correct type will always be
translatable into a String representation (the value presented to the end user) via the `formatter`.
When the type is not valid (usually as a consequence of a user typing in an invalid or incomplete
viewValue), the current truth is captured in the `Unparseable` type.
For example: a viewValue can't be parsed (for instance 'foo' when the type should be Number).
The model(value) concept as implemented in lion-web is conceptually comparable to those found in
popular frameworks like Angular and Vue.
The Unparseable type is an addition on top of this that mainly is added for the following two
purposes:
- restoring user sessions
- realtime updated with all value changes
### Restoring user sessions
As a modelValue is always a single source of truth
### Realtime updated with all value changes
As an Application Developer, you will be notified when a user tries to write the correct type of
a value. This might be handy for giving feedback to the user.
In order to check whether the input is correct, an Application Developer can do the following:
```html
<lion-input @model-value-changed="${handleChange}"></lion-input>
```
```js
function handleChange({ target: { modelValue, errorState } }) {
if (!(modelValue instanceof Unparseable) && !errorState) {
// do my thing
}
}
```

6
packages/field/index.js Normal file
View file

@ -0,0 +1,6 @@
export { FieldCustomMixin } from './src/FieldCustomMixin.js';
export { FocusMixin } from './src/FocusMixin.js';
export { FormatMixin } from './src/FormatMixin.js';
export { FormControlMixin } from './src/FormControlMixin.js';
export { InteractionStateMixin } from './src/InteractionStateMixin.js'; // applies FocusMixin
export { LionField } from './src/LionField.js';

View file

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

View file

@ -0,0 +1,41 @@
{
"name": "@lion/field",
"version": "0.0.0",
"description": "Fields are the most fundamental building block of the Form System",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/field"
},
"scripts": {
"prepublishOnly": "../../scripts/insert-header.js"
},
"keywords": [
"lion",
"web-components",
"field"
],
"main": "index.js",
"module": "index.js",
"files": [
"stories",
"test",
"*.js"
],
"dependencies": {
"@lion/core": "0.0.0",
"@lion/validate": "0.0.0"
},
"devDependencies": {
"@lion/localize": "0.0.0",
"@open-wc/testing": "^0.11.1",
"@open-wc/storybook": "^0.1.5",
"sinon": "^7.2.2"
}
}

View file

@ -0,0 +1,48 @@
/* eslint-disable no-underscore-dangle */
import { dedupeMixin, nothing } from '@lion/core';
/**
* #FieldCustomMixin
*
* @polymerMixin
* @mixinFunction
*/
export const FieldCustomMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow, max-len
class FieldCustomMixin extends superclass {
static get properties() {
return {
...super.properties,
/**
* When no light dom defined and prop set
*/
disableHelpText: {
type: Boolean,
attribute: 'disable-help-text',
},
};
}
get slots() {
return {
...super.slots,
'help-text': () => {
if (!this.disableHelpText) {
return super.slots['help-text']();
}
return null;
},
};
}
helpTextTemplate(...args) {
if (this.disableHelpText || !super.helpTextTemplate) {
return nothing;
}
return super.helpTextTemplate.apply(this, args);
}
},
);

View file

@ -0,0 +1,49 @@
/* eslint-disable no-underscore-dangle */
import { dedupeMixin, DelegateMixin } from '@lion/core';
export const FocusMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-unused-vars, max-len, no-shadow
class FocusMixin extends DelegateMixin(superclass) {
get delegations() {
return {
...super.delegations,
target: () => this.inputElement,
events: [...super.delegations.events, 'focus', 'blur'], // since these events don't bubble
methods: [...super.delegations.methods, 'focus', 'blur'],
properties: [...super.delegations.properties, 'onfocus', 'onblur', 'autofocus'],
attributes: [...super.delegations.attributes, 'onfocus', 'onblur', 'autofocus'],
};
}
get events() {
return {
...super.events,
// Listen to focusin instead of focus, because it blurs
_onFocus: [() => this.inputElement, 'focusin'],
_onBlur: [() => this.inputElement, 'focusout'],
};
}
/**
* Helper Function to easily check if the element is being focused
*
* TODO: performance comparision vs
* return this.inputElement === document.activeElement;
*/
get focused() {
return this.classList.contains('state-focused');
}
_onFocus() {
if (super._onFocus) super._onFocus();
this.classList.add('state-focused');
}
_onBlur() {
if (super._onBlur) super._onBlur();
this.classList.remove('state-focused');
}
},
);

View file

@ -0,0 +1,579 @@
/* eslint-disable no-underscore-dangle */
import { html, css, nothing, dedupeMixin } from '@lion/core';
import { ObserverMixin } from '@lion/core/src/ObserverMixin.js';
/**
* #FormControlMixin :
*
* This Mixin is a shared fundament for all form components, it's applied on:
* - LionField (which is extended to LionInput, LionTextarea, LionSelect etc. etc.)
* - LionFieldset (which is extended to LionRadioGroup, LionCheckboxGroup, LionForm)
*
* @polymerMixin
* @mixinFunction
*/
export const FormControlMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormControlMixin extends ObserverMixin(superclass) {
static get properties() {
return {
...super.properties,
/**
* A list of ids that will be put on the inputElement as a serialized string
*/
_ariaDescribedby: {
type: String,
},
/**
* A list of ids that will be put on the inputElement as a serialized string
*/
_ariaLabelledby: {
type: String,
},
/**
* When no light dom defined and prop set
*/
label: {
type: String,
},
/**
* When no light dom defined and prop set
*/
helpText: {
type: String,
attribute: 'help-text',
},
};
}
get slots() {
return {
...super.slots,
label: () => {
const label = document.createElement('label');
label.textContent = this.label;
return label;
},
'help-text': () => {
const helpText = document.createElement('div');
helpText.textContent = this.helpText;
return helpText;
},
};
}
static get asyncObservers() {
return {
...super.asyncObservers,
_onAriaLabelledbyChanged: ['_ariaLabelledby'],
_onAriaDescribedbyChanged: ['_ariaDescribedby'],
_onLabelChanged: ['label'],
_onHelpTextChanged: ['helpText'],
};
}
get inputElement() {
return (this.$$slot && this.$$slot('input')) || this.querySelector('[slot=input]'); // eslint-disable-line
}
constructor() {
super();
this._inputId = `${this.localName}-${Math.random()
.toString(36)
.substr(2, 10)}`;
this._ariaLabelledby = '';
this._ariaDescribedby = '';
}
connectedCallback() {
super.connectedCallback();
this._enhanceLightDomClasses();
this._enhanceLightDomA11y();
this._registerFormElement();
this._requestParentFormGroupUpdateOfResetModelValue();
}
/**
* Public methods
*/
_enhanceLightDomClasses() {
if (this.inputElement) {
this.inputElement.classList.add('form-control');
}
}
_enhanceLightDomA11y() {
if (this.inputElement) {
this.inputElement.id = this.inputElement.id || this._inputId;
}
if (this.$$slot('label')) {
this.$$slot('label').setAttribute('for', this._inputId);
this.$$slot('label').id = this.$$slot('label').id || `label-${this._inputId}`;
const labelledById = ` ${this.$$slot('label').id}`;
if (this._ariaLabelledby.indexOf(labelledById) === -1) {
this._ariaLabelledby += ` ${this.$$slot('label').id}`;
}
}
if (this.$$slot('help-text')) {
this.$$slot('help-text').id = this.$$slot('help-text').id || `help-text-${this._inputId}`;
const describeIdHelpText = ` ${this.$$slot('help-text').id}`;
if (this._ariaDescribedby.indexOf(describeIdHelpText) === -1) {
this._ariaDescribedby += ` ${this.$$slot('help-text').id}`;
}
}
if (this.$$slot('feedback')) {
this.$$slot('feedback').id = this.$$slot('feedback').id || `feedback-${this._inputId}`;
const describeIdFeedback = ` ${this.$$slot('feedback').id}`;
if (this._ariaDescribedby.indexOf(describeIdFeedback) === -1) {
this._ariaDescribedby += ` ${this.$$slot('feedback').id}`;
}
}
this._enhanceLightDomA11yForAdditionalSlots();
}
/**
* Fires a registration event in the next frame.
*
* Why next frame?
* if ShadyDOM is used and you add a listener and fire the event in the same frame
* it will not bubble and there can not be cought by a parent element
* for more details see: https://github.com/Polymer/lit-element/issues/658
* will requires a `await nextFrame()` in tests
*/
_registerFormElement() {
requestAnimationFrame(() => {
this.dispatchEvent(
new CustomEvent('form-element-register', {
detail: { element: this },
bubbles: true,
}),
);
});
}
/**
* Makes sure our parentFormGroup has the most up to date resetModelValue
* FormGroups will call the same on their parentFormGroup so the full tree gets the correct
* values.
*
* Why next frame?
* @see {@link this._registerFormElement}
*/
_requestParentFormGroupUpdateOfResetModelValue() {
requestAnimationFrame(() => {
if (this.__parentFormGroup) {
this.__parentFormGroup._updateResetModelValue();
}
});
}
/**
* Enhances additional slots(prefix, suffix, before, after) defined by developer.
*
* When boolean attribute data-label or data-description is found,
* the slot element will be connected to the input via aria-labelledby or aria-describedby
*/
_enhanceLightDomA11yForAdditionalSlots(
additionalSlots = ['prefix', 'suffix', 'before', 'after'],
) {
additionalSlots.forEach(additionalSlot => {
const element = this.$$slot(additionalSlot);
if (element) {
element.id = element.id || `${additionalSlot}-${this._inputId}`;
if (element.hasAttribute('data-label') === true) {
this._ariaLabelledby += ` ${element.id}`;
}
if (element.hasAttribute('data-description') === true) {
this._ariaDescribedby += ` ${element.id}`;
}
}
});
}
/**
* Will handle label, prefix/suffix/before/after (if they contain data-label flag attr).
* Also, contents of id references that will be put in the <lion-field>._ariaLabelledby property
* from an external context, will be read by a screen reader.
*/
_onAriaLabelledbyChanged({ _ariaLabelledby }) {
if (this.inputElement) {
this.inputElement.setAttribute('aria-labelledby', _ariaLabelledby);
}
}
/**
* Will handle help text, validation feedback and character counter,
* prefix/suffix/before/after (if they contain data-description flag attr).
* Also, contents of id references that will be put in the <lion-field>._ariaDescribedby property
* from an external context, will be read by a screen reader.
*/
_onAriaDescribedbyChanged({ _ariaDescribedby }) {
if (this.inputElement) {
this.inputElement.setAttribute('aria-describedby', _ariaDescribedby);
}
}
_onLabelChanged({ label }) {
if (this.$$slot && this.$$slot('label')) {
this.$$slot('label').textContent = label;
}
}
_onHelpTextChanged({ helpText }) {
if (this.$$slot && this.$$slot('help-text')) {
this.$$slot('help-text').textContent = helpText;
}
}
/**
*
* Default Render Result:
* <div class="form-field__label">
* <slot name="label"></slot>
* </div>
* <small class="form-field__help-text">
* <slot name="help-text"></slot>
* </small>
* <div class="input-group">
* <div class="input-group__before">
* <slot name="before"></slot>
* </div>
* <div class="input-group__container">
* <div class="input-group__prefix">
* <slot name="prefix"></slot>
* </div>
* <div class="input-group__input">
* <slot name="input"></slot>
* </div>
* <div class="input-group__suffix">
* <slot name="suffix"></slot>
* </div>
* </div>
* <div class="input-group__after">
* <slot name="after"></slot>
* </div>
* </div>
* <div class="form-field__feedback">
* <slot name="feedback"></slot>
* </div>
*/
render() {
return html`
${this.labelTemplate()} ${this.helpTextTemplate()} ${this.inputGroupTemplate()}
${this.feedbackTemplate()}
`;
}
// eslint-disable-next-line class-methods-use-this
labelTemplate() {
return html`
<div class="form-field__label">
<slot name="label"></slot>
</div>
`;
}
// eslint-disable-next-line class-methods-use-this
helpTextTemplate() {
return html`
<small class="form-field__help-text">
<slot name="help-text"></slot>
</small>
`;
}
inputGroupTemplate() {
return html`
<div class="input-group">
${this.inputGroupBeforeTemplate()}
<div class="input-group__container">
${this.inputGroupPrefixTemplate()} ${this.inputGroupInputTemplate()}
${this.inputGroupSuffixTemplate()}
</div>
${this.inputGroupAfterTemplate()}
</div>
`;
}
// eslint-disable-next-line class-methods-use-this
inputGroupBeforeTemplate() {
return html`
<div class="input-group__before">
<slot name="before"></slot>
</div>
`;
}
inputGroupPrefixTemplate() {
return !this.$$slot('prefix')
? nothing
: html`
<div class="input-group__prefix">
<slot name="prefix"></slot>
</div>
`;
}
// eslint-disable-next-line class-methods-use-this
inputGroupInputTemplate() {
return html`
<div class="input-group__input">
<slot name="input"></slot>
</div>
`;
}
inputGroupSuffixTemplate() {
return !this.$$slot('suffix')
? nothing
: html`
<div class="input-group__suffix">
<slot name="suffix"></slot>
</div>
`;
}
// eslint-disable-next-line class-methods-use-this
inputGroupAfterTemplate() {
return html`
<div class="input-group__after">
<slot name="after"></slot>
</div>
`;
}
// eslint-disable-next-line class-methods-use-this
feedbackTemplate() {
return html`
<div class="form-field__feedback">
<slot name="feedback"></slot>
</div>
`;
}
/**
* All CSS below is written from a generic mindset, following BEM conventions:
* https://en.bem.info/methodology/
* Although the CSS and HTML are implemented by the component, they should be regarded as
* totally decoupled.
*
* Not only does this force us to write better structured css, it also allows for future
* reusability in many different ways like:
* - disabling shadow DOM for a component (for water proof encapsulation can be combined with
* a build step)
* - easier translation to more flexible, WebComponents agnostic solutions like JSS
* (allowing extends, mixins, reasoning, IDE integration, tree shaking etc.)
* - export to a CSS module for reuse in an outer context
*
*
* Please note that the HTML structure is purposely 'loose', allowing multiple design systems
* to be compatible
* with the CSS component.
* Note that every occurence of '::slotted(*)' can be rewritten to '> *' for use in an other
* context
*
* TODO: find best naming convention: https://en.bem.info/methodology/naming-convention/
* (react style would align better with JSS)
*/
/**
* {block} .form-field
*
* Structure:
* - {element} .form-field__label : a wrapper element around the projected label
* - {element} .form-field__help-text (optional) : a wrapper element around the projected
* help-text
* - {block} .input-group : a container around the input element, including prefixes and
* suffixes
* - {element} .form-field__feedback (optional) : a wrapper element around the projected
* (validation) feedback message
*
* Modifiers:
* - {state} .state-disabled : when .form-control (<input>, <textarea> etc.) has disabled set
* to true
* - {state} .state-focused: when .form-control (<input>, <textarea> etc.) <input> has focus
* - {state} .state-filled: whether <input> has a value
* - {state} .state-touched: whether the user had blurred the field once
* - {state} .state-dirty: whether the value has changed since initial value
*
* - {state} .state-invalid: when input has error(s) (regardless of whether they should be
* shown to the user)
* - {state} .state-error: when input has error(s) and this/these should be shown to the user
* - {state} .state-warning: when input has warning(s) and this/these should be shown to the
* user
* - {state} .state-info: when input has info feedback message(s) and this/these should be shown
* to the user
* - {state} .state-success: when input has success feedback message(s) and this/these should be
* shown to the user
*/
/**
* {block} .input-group
*
* Structure:
* - {element} .input-group__before (optional) : a prefix that resides outside the container
* - {element} .input-group__container : an inner container: this element contains all styling
* - {element} .input-group__prefix (optional) : a prefix that resides in the container,
* allowing it to be detectable as a :first-child
* - {element} .input-group__input : a wrapper around the form-control component
* - {block} .form-control : the actual input element (input/select/textarea)
* - {element} .input-group__suffix (optional) : a suffix that resides inside the container,
* allowing it to be detectable as a :last-child
* - {element} .input-group__bottom (optional) : placeholder element for additional styling
* (like an animated line for material design input)
* - {element} .input-group__after (optional) : a suffix that resides outside the container
*/
static get styles() {
return [
css`
/**********************
{block} .form-field
********************/
:host {
display: block;
}
:host(.state-disabled) {
pointer-events: none;
}
:host(.state-disabled) .form-field__label ::slotted(*),
:host(.state-disabled) .form-field__help-text ::slotted(*) {
color: var(--disabled-text-color, #adadad);
}
/***********************
{block} .input-group
*********************/
.input-group__container {
display: flex;
}
.input-group__input {
flex: 1;
display: flex;
}
/***** {state} .state-disabled *****/
:host(.state-disabled) .input-group ::slotted(*) {
color: var(--disabled-text-color, #adadad);
}
/***********************
{block} .form-control
**********************/
.input-group__container > .input-group__input ::slotted(.form-control) {
flex: 1 1 auto;
margin: 0; /* remove input margin in Safari */
font-size: 100%; /* normalize default input font-size */
}
`,
];
}
// Extend validity showing conditions of ValidateMixin
showErrorCondition(newStates) {
return super.showErrorCondition(newStates) && this._interactionStateFeedbackCondition();
}
showWarningCondition(newStates) {
return super.showWarningCondition(newStates) && this._interactionStateFeedbackCondition();
}
showInfoCondition(newStates) {
return super.showInfoCondition(newStates) && this._interactionStateFeedbackCondition();
}
showSuccessCondition(newStates, oldStates) {
return (
super.showSuccessCondition(newStates, oldStates) &&
this._interactionStateFeedbackCondition()
); // eslint-disable-line max-len
}
_interactionStateFeedbackCondition() {
/**
* Show the validity feedback when one of the following conditions is met:
*
* - submitted
* If the form is submitted, always show the error message.
*
* - prefilled
* the user already filled in something, or the value is prefilled
* when the form is initially rendered.
*
* - touched && dirty && !prefilled
* When a user starts typing for the first time in a field with for instance `required`
* validation, error message should not be shown until a field becomes `touched`
* (a user leaves(blurs) a field).
* When a user enters a field without altering the value(making it `dirty`),
* an error message shouldn't be shown either.
*
*/
return (this.touched && this.dirty && !this.prefilled) || this.prefilled || this.submitted;
}
// aria-labelledby and aria-describedby helpers
// TODO: consider extracting to generic ariaLabel helper mixin
/**
* Let the order of adding ids to aria element by DOM order, so that the screen reader
* respects visual order when reading:
* https://developers.google.com/web/fundamentals/accessibility/focus/dom-order-matters
* @param {array} descriptionElements - holds references to description or label elements whose
* id should be returned
* @returns {array} sorted set of elements based on dom order
*
* TODO: make this method part of a more generic mixin or util and also use for lion-field
*/
static _getAriaElementsInRightDomOrder(descriptionElements) {
const putPrecedingSiblingsAndLocalParentsFirst = (a, b) => {
// https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
const pos = a.compareDocumentPosition(b);
if (
pos === Node.DOCUMENT_POSITION_PRECEDING ||
pos === Node.DOCUMENT_POSITION_CONTAINED_BY
) {
return 1;
}
return -1;
};
const descriptionEls = descriptionElements.filter(el => el); // filter out null references
return descriptionEls.sort(putPrecedingSiblingsAndLocalParentsFirst);
}
// Returns dom references to all elements that should be referred to by field(s)
_getAriaDescriptionElements() {
return [this.$$slot('help-text'), this.$$slot('feedback')];
}
/**
* Meant for Application Developers wanting to add to aria-labelledby attribute.
* @param {string} id - should be the id of an element that contains the label for the
* concerned field or fieldset, living in the same shadow root as the host element of field or
* fieldset.
*/
addToAriaLabel(id) {
this._ariaLabelledby += ` ${id}`;
}
/**
* Meant for Application Developers wanting to add to aria-describedby attribute.
* @param {string} id - should be the id of an element that contains the label for the
* concerned field or fieldset, living in the same shadow root as the host element of field or
* fieldset.
*/
addToAriaDescription(id) {
this._ariaDescribedby += ` ${id}`;
}
},
);

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