feat(button): split off separate buttons for reset & submit

Co-authored-by: Gerjan van Geest <Gerjan.van.Geest@ing.com>
This commit is contained in:
Thijs Louisse 2021-05-19 18:32:10 +02:00 committed by qa46hx
parent 2b73c50f6e
commit 57b2fb9ff8
23 changed files with 1115 additions and 838 deletions

View file

@ -0,0 +1,14 @@
---
'@lion/button': minor
'@lion/form-integrations': minor
'@lion/select-rich': minor
---
- BREAKING: In `lion-button` package split of separate buttons for reset & submit:
- LionButton (a clean fundament, **use outside forms**)
- LionButtonReset (logic for. submit and reset events, but without implicit form submission logic: **use for reset buttons**)
- LionButtonSubmit (full featured button: **use for submit buttons and buttons with dynamic types**)
- fixed axe criterium for LionButton (which contained a native button to support form submission)
- removed `_beforeTemplate()` & `_afterTemplate()` from LionButton

View file

@ -47,4 +47,5 @@ npm i --save @lion/combobox
```js ```js
import '@lion/combobox/define'; import '@lion/combobox/define';
import '@lion/listbox/lion-option.js';
``` ```

View file

@ -10,7 +10,7 @@ import '@lion/form/define';
## Submit & Reset ## Submit & Reset
To submit a form, use a regular button (or `LionButton`) with `type="submit"` (which is default) somewhere inside the native `<form>`. To submit a form, use a regular button (or `LionButtonSubmit`) somewhere inside the native `<form>`.
Then, add a `submit` handler on the `lion-form`. Then, add a `submit` handler on the `lion-form`.

View file

@ -38,12 +38,12 @@ export const minimumClickArea = () => html` <style>
## Usage with native form ## Usage with native form
Supports the following use cases: `<lion-button-reset>` and `<lion-button-submit>` are especially created to supports the following use cases:
- Submit on button click - Submit on button click
- Reset native form fields when using type="reset"
- Submit on button enter or space keypress - Submit on button enter or space keypress
- Submit on enter keypress inside an input - Submit on enter keypress inside an input
- Reset native form fields when using type="reset"
```js preview-story ```js preview-story
export const withinForm = () => html` export const withinForm = () => html`
@ -57,16 +57,21 @@ export const withinForm = () => html`
<input id="firstNameId" name="firstName" /> <input id="firstNameId" name="firstName" />
<label for="lastNameId">Last name</label> <label for="lastNameId">Last name</label>
<input id="lastNameId" name="lastName" /> <input id="lastNameId" name="lastName" />
<lion-button @click=${ev => console.log('click handler', ev.target)}>Submit</lion-button> <lion-button-submit @click=${ev => console.log('click submit handler', ev.target)}
>Submit</lion-button-submit
>
<lion-button-reset @click=${ev => console.log('click reset handler', ev.target)}
>Reset</lion-button-reset
>
</form> </form>
`; `;
``` ```
Important notes: Important notes:
- A (lion)-button of type submit is mandatory for the last use case, if you have multiple inputs. This is native behavior. - A `<lion-button-submit>` is mandatory for the last use case, if you have multiple inputs. This is native behaviour.
- `@click` on `<lion-button>` and `@submit` on `<form>` are triggered by these use cases. We strongly encourage you to listen to the submit handler if your goal is to do something on form-submit. - `@click` on `<lion-button-submit>` and `@submit` on `<form>` are triggered by these use cases. We strongly encourage you to listen to the submit handler if your goal is to do something on form-submit.
- To prevent form submission full page reloads, add a **submit handler on the form** `@submit` with `event.preventDefault()`. Adding it on the `<lion-button>` is not enough. - To prevent form submission full page reloads, add a **submit handler on the form** `@submit` with `event.preventDefault()`. Adding it on the `<lion-button-submit>` is not enough.
## Considerations ## Considerations
@ -76,15 +81,15 @@ There are multiple reasons why we used a Web Component as opposed to a CSS compo
- **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. - **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.
- **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. - **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.
- **Native form integration**: The lion button works with native `<form>` submission, and even implicit form submission on-enter. A lot of delegation logic had to be created for this to work. - **Native form integration**: The `<lion-button-submit>` works with native `<form>` submission, and even implicit form submission on-enter. A lot of delegation logic had to be created for this to work.
### Event target ### Event target
We want to ensure that the event target returned to the user is `<lion-button>`, not `button`. Therefore, simply delegating the click to the native button immediately, is not desired. Instead, we catch the click event in the `<lion-button>`, and ensure delegation inside of there. We want to ensure that the event target returned to the user is `<lion-button>`, not `<button>`. Therefore, simply delegating the click to the native button immediately, is not desired. Instead, we catch the click event in the `<lion-button>`, and ensure delegation inside of there.
### Flashing a native button click as a direct child of form ### Flashing a native button click as a direct child of form
By delegating the `click()` to the native button, it will bubble back up to `<lion-button>` which would cause duplicate actions. We have to simulate the full `.click()` however, otherwise form submission is not triggered. So this bubbling cannot be prevented. By delegating the `click()` to the native button, it will bubble back up to `<lion-button-reset>` and `<lion-button-submit>` which would cause duplicate actions. We have to simulate the full `.click()` however, otherwise form submission is not triggered. So this bubbling cannot be prevented.
Therefore, on click, we flash a `<button>` to the form as a direct child and fire the click on that button. We then immediately remove that button. This is a fully synchronous process; users or developers will not notice this, it should not cause problems. Therefore, on click, we flash a `<button>` to the form as a direct child and fire the click on that button. We then immediately remove that button. This is a fully synchronous process; users or developers will not notice this, it should not cause problems.
### Native button & implicit form submission ### Native button & implicit form submission
@ -93,7 +98,7 @@ Flashing the button in the way we do solves almost all issues except for one.
One of the specs of W3C is that when you have a form with multiple inputs, One of the specs of W3C is that when you have a form with multiple inputs,
pressing enter while inside one of the inputs only triggers a form submit if that form has a button of type submit. pressing enter while inside one of the inputs only triggers a form submit if that form has a button of type submit.
To get this particular implicit form submission to work, having a native button in our `<lion-button>` is a hard requirement. To get this particular implicit form submission to work, having a native button in our `<lion-button-submit>` is a hard requirement.
Therefore, not only do we flash a native button on the form to delegate `<lion-button>` trigger to `<button>` Therefore, not only do we flash a native button on the form to delegate `<lion-button-submit>` trigger to `<button>`
and thereby trigger form submission, we **also** add a native `button` inside the `<lion-button>` and thereby trigger form submission, we **also** add a native `button` inside the `<lion-button-submit>`
whose `type` property is synchronized with the type of the `<lion-button>`. whose `type` property is synchronized with the type of the `<lion-button-submit>`.

View file

@ -8,14 +8,14 @@ import '@lion/button/define';
``` ```
```js preview-story ```js preview-story
export const main = () => html`<lion-button>Default</lion-button>`; export const main = () => html` <lion-button>Default</lion-button> `;
``` ```
## Features ## Features
- Clickable area that is bigger than visual size - Clickable area that is bigger than visual size
- Works with native form / inputs - A special `button-reset` and `button-submit` works with native form / inputs
- Has integration for implicit form submission similar to how native `<form>`, `<input>` and `<button>` work together. - `button-submit` has integration for implicit form submission similar to how native `<form>`, `<input>` and `<button>` work together.
## Installation ## Installation
@ -24,7 +24,7 @@ npm i --save @lion/button
``` ```
```js ```js
import { LionButton } from '@lion/button'; import { LionButton, LionButtonReset, LionButtonSubmit } from '@lion/button';
// or // or
import '@lion/button/define'; import '@lion/button/define';
``` ```

View file

@ -141,12 +141,10 @@ export const main = () => {
</lion-input-stepper> </lion-input-stepper>
<lion-textarea name="comments" label="Comments"></lion-textarea> <lion-textarea name="comments" label="Comments"></lion-textarea>
<div class="buttons"> <div class="buttons">
<lion-button raised>Submit</lion-button> <lion-button-submit>Submit</lion-button-submit>
<lion-button <lion-button-reset
type="button"
raised
@click=${ev => ev.currentTarget.parentElement.parentElement.parentElement.resetGroup()} @click=${ev => ev.currentTarget.parentElement.parentElement.parentElement.resetGroup()}
>Reset</lion-button >Reset</lion-button-reset
> >
</div> </div>
</form> </form>

View file

@ -94,8 +94,8 @@ Due to our custom inputs being Web Components, it is possible to put HTML conten
```js preview-story ```js preview-story
export const ButtonsWithFields = () => html` export const ButtonsWithFields = () => html`
<lion-input label="Prefix and suffix"> <lion-input label="Prefix and suffix">
<lion-button slot="prefix" type="button">prefix</lion-button> <lion-button slot="prefix">prefix</lion-button>
<lion-button slot="suffix" type="button">suffix</lion-button> <lion-button slot="suffix">suffix</lion-button>
</lion-input> </lion-input>
`; `;
``` ```

View file

@ -800,7 +800,7 @@ export const backendValidation = () => {
<lion-checkbox-group name="simulateError"> <lion-checkbox-group name="simulateError">
<lion-checkbox label="Check to simulate a backend error"></lion-checkbox> <lion-checkbox label="Check to simulate a backend error"></lion-checkbox>
</lion-checkbox-group> </lion-checkbox-group>
<lion-button raised>Submit</lion-button> <lion-button-submit>Submit</lion-button-submit>
</form> </form>
</lion-form> </lion-form>
`; `;

View file

@ -61,6 +61,7 @@
"@web/dev-server-legacy": "^0.1.7", "@web/dev-server-legacy": "^0.1.7",
"@web/test-runner": "^0.13.4", "@web/test-runner": "^0.13.4",
"@web/test-runner-browserstack": "^0.4.2", "@web/test-runner-browserstack": "^0.4.2",
"@web/test-runner-commands": "^0.4.5",
"@web/test-runner-playwright": "^0.8.6", "@web/test-runner-playwright": "^0.8.6",
"babel-polyfill": "^6.26.0", "babel-polyfill": "^6.26.0",
"bundlesize": "^1.0.0-beta.2", "bundlesize": "^1.0.0-beta.2",
@ -107,7 +108,7 @@
}, },
{ {
"path": "./bundlesize/dist/all/*.js", "path": "./bundlesize/dist/all/*.js",
"maxSize": "45 kB" "maxSize": "46 kB"
} }
], ],
"prettier": { "prettier": {

View file

@ -0,0 +1,3 @@
import '@lion/button/define-button';
import '@lion/button/define-button-reset';
import '@lion/button/define-button-submit';

View file

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

View file

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

View file

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

View file

@ -49,7 +49,10 @@
"customElementsManifest": "custom-elements.json", "customElementsManifest": "custom-elements.json",
"exports": { "exports": {
".": "./index.js", ".": "./index.js",
"./define": "./lion-button.js", "./define-button": "./lion-button.js",
"./define-button-reset": "./lion-button-reset.js",
"./define-button-submit": "./lion-button-submit.js",
"./define": "./define.js",
"./docs/": "./docs/" "./docs/": "./docs/"
} }
} }

View file

@ -1,11 +1,4 @@
import { import { browserDetection, css, DisabledWithTabIndexMixin, html, LitElement } from '@lion/core';
browserDetection,
css,
DisabledWithTabIndexMixin,
html,
LitElement,
SlotMixin,
} from '@lion/core';
import '@lion/core/differentKeyEventNamesShimIE'; import '@lion/core/differentKeyEventNamesShimIE';
const isKeyboardClickEvent = (/** @type {KeyboardEvent} */ e) => e.key === ' ' || e.key === 'Enter'; const isKeyboardClickEvent = (/** @type {KeyboardEvent} */ e) => e.key === ' ' || e.key === 'Enter';
@ -15,51 +8,34 @@ const isSpaceKeyboardClickEvent = (/** @type {KeyboardEvent} */ e) => e.key ===
* @typedef {import('@lion/core').TemplateResult} TemplateResult * @typedef {import('@lion/core').TemplateResult} TemplateResult
*/ */
export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement)) { /**
* Use LionButton (or LionButtonReset|LionButtonSubmit) when there is a need to extend HTMLButtonElement.
* It allows to create complex shadow DOM for buttons needing this. Think of:
* - a material Design button that needs a JS controlled ripple
* - a LionSelectRich invoker that needs a complex shadow DOM structure
* (for styling/maintainability purposes)
* - a specialized button (for instance a primary button or icon button in a Design System) that
* needs a simple api: `<my-button>text</my-button>` is always better than
* `<button class="my-button"><div class="my-button__container">text</div><button>`
*
* In other cases, whenever you can, still use native HTMLButtonElement (`<button>`).
*
* Note that LionButton is meant for buttons with type="button". It's cleaner and more
* lightweight than LionButtonReset and LionButtonSubmit, which should only be considered when native
* `<form>` support is needed:
* - When type="reset|submit" should be supported, use LionButtonReset.
* - When implicit form submission should be supported on top, use LionButtonSubmit.
*/
export class LionButton extends DisabledWithTabIndexMixin(LitElement) {
static get properties() { static get properties() {
return { return {
role: { active: { type: Boolean, reflect: true },
type: String, type: { type: String, reflect: true },
reflect: true,
},
active: {
type: Boolean,
reflect: true,
},
type: {
type: String,
reflect: true,
},
}; };
} }
render() { render() {
return html` return html` <div class="button-content" id="${this._buttonId}"><slot></slot></div> `;
${this._beforeTemplate()}
<div class="button-content" id="${this._buttonId}"><slot></slot></div>
${this._afterTemplate()}
<slot name="_button"></slot>
`;
}
/**
*
* @returns {TemplateResult} button template
* @protected
*/
// eslint-disable-next-line class-methods-use-this
_beforeTemplate() {
return html``;
}
/**
*
* @returns {TemplateResult} button template
* @protected
*/
// eslint-disable-next-line class-methods-use-this
_afterTemplate() {
return html``;
} }
static get styles() { static get styles() {
@ -74,7 +50,8 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
background: #eee; /* minimal styling to make it recognizable as btn */ background: #eee; /* minimal styling to make it recognizable as btn */
padding: 8px; /* padding to fix with min-height */ padding: 8px; /* padding to fix with min-height */
outline: none; /* focus style handled below */ outline: none; /* focus style handled below */
cursor: default; /* /* we should always see the default arrow, never a caret */ cursor: default; /* we should always see the default arrow, never a caret */
/* TODO: remove, native button also allows selection. Could be usability concern... */
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
@ -104,20 +81,6 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
justify-content: center; justify-content: center;
} }
:host ::slotted(button) {
position: absolute;
top: 0;
left: 0;
clip: rect(0 0 0 0);
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
height: 1px;
width: 1px;
padding: 0; /* reset default agent styles */
border: 0; /* reset default agent styles */
}
/* Show focus styles on keyboard focus. */ /* Show focus styles on keyboard focus. */
:host(:focus:not([disabled])), :host(:focus:not([disabled])),
:host(:focus-visible) { :host(:focus-visible) {
@ -157,35 +120,10 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
]; ];
} }
/**
* @type {HTMLButtonElement}
* @protected
*/
get _nativeButtonNode() {
return /** @type {HTMLButtonElement} */ (
Array.from(this.children).find(child => child.slot === '_button')
);
}
get slots() {
return {
...super.slots,
_button: () => {
/** @type {HTMLButtonElement} */
const buttonEl = document.createElement('button');
buttonEl.setAttribute('tabindex', '-1');
buttonEl.setAttribute('aria-hidden', 'true');
return buttonEl;
},
};
}
constructor() { constructor() {
super(); super();
this.role = 'button'; this.type = 'button';
this.type = 'submit';
this.active = false; this.active = false;
this.__setupDelegationInConstructor();
this._buttonId = `button-${Math.random().toString(36).substr(2, 10)}`; this._buttonId = `button-${Math.random().toString(36).substr(2, 10)}`;
if (browserDetection.isIE11) { if (browserDetection.isIE11) {
@ -195,28 +133,14 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
} }
}); });
} }
this.__setupEvents();
/** @type {HTMLButtonElement} */
this.__submitAndResetHelperButton = document.createElement('button');
/** @type {EventListener} */
this.__preventEventLeakage = this.__preventEventLeakage.bind(this);
} }
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.__setupEvents(); if (!this.hasAttribute('role')) {
// Old browsers (IE11, Old Edge, Firefox ESR 60) don't have the `.form` this.setAttribute('role', 'button');
// property defined immediately on the native button, so do this after first render on connected. }
this.updateComplete.then(() => {
this.__setupSubmitAndResetHelperOnConnected();
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.__teardownEvents();
this.__teardownSubmitAndResetHelperOnDisconnected();
} }
/** /**
@ -224,59 +148,12 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
*/ */
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has('type')) {
const native = this._nativeButtonNode;
if (native) {
native.type = this.type;
}
}
if (changedProperties.has('disabled')) { if (changedProperties.has('disabled')) {
this.setAttribute('aria-disabled', `${this.disabled}`); // create mixin if we need it in more places this.setAttribute('aria-disabled', `${this.disabled}`); // create mixin if we need it in more places
} }
} }
/**
* Delegate click, by flashing a native button as a direct child
* of the form, and firing click on this button. This will fire the form submit
* without side effects caused by the click bubbling back up to lion-button.
* @param {Event} ev
* @protected
* @returns {Promise<void>}
*/
// TODO: rename to _clickDelegationHandler in v1
async __clickDelegationHandler(ev) {
// Wait for updateComplete if form is not yet available
if (!this._form) {
await this.updateComplete;
}
if ((this.type === 'submit' || this.type === 'reset') && ev.target === this && this._form) {
/**
* Here, we make sure our button is compatible with a native form, by firing a click
* from a native button that our form responds to. The native button we spawn will be a direct
* child of the form, plus the click event that will be sent will be prevented from
* propagating outside of the form. This will keep the amount of 'noise' (click events
* from 'ghost elements' that can be intercepted by listeners in the bubble chain) to an
* absolute minimum.
*/
this.__submitAndResetHelperButton.type = this.type;
this._form.appendChild(this.__submitAndResetHelperButton);
// Form submission or reset will happen
this.__submitAndResetHelperButton.click();
this._form.removeChild(this.__submitAndResetHelperButton);
}
}
/**
* @private
*/
__setupDelegationInConstructor() {
// do not move to connectedCallback, otherwise IE11 breaks.
// more info: https://github.com/ing-bank/lion/issues/179#issuecomment-511763835
this.addEventListener('click', this.__clickDelegationHandler, true);
}
/** /**
* @private * @private
*/ */
@ -286,16 +163,6 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
this.addEventListener('keyup', this.__keyupHandler); this.addEventListener('keyup', this.__keyupHandler);
} }
/**
* @private
*/
__teardownEvents() {
this.removeEventListener('mousedown', this.__mousedownHandler);
this.removeEventListener('keydown', this.__keydownHandler);
this.removeEventListener('keyup', this.__keyupHandler);
this.removeEventListener('click', this.__clickDelegationHandler);
}
/** /**
* @private * @private
*/ */
@ -311,19 +178,19 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
} }
/** /**
* @param {KeyboardEvent} e * @param {KeyboardEvent} event
* @private * @private
*/ */
__keydownHandler(e) { __keydownHandler(event) {
if (this.active || !isKeyboardClickEvent(e)) { if (this.active || !isKeyboardClickEvent(event)) {
if (isSpaceKeyboardClickEvent(e)) { if (isSpaceKeyboardClickEvent(event)) {
e.preventDefault(); event.preventDefault();
} }
return; return;
} }
if (isSpaceKeyboardClickEvent(e)) { if (isSpaceKeyboardClickEvent(event)) {
e.preventDefault(); event.preventDefault();
} }
this.active = true; this.active = true;
@ -340,48 +207,17 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
} }
/** /**
* @param {KeyboardEvent} e * @param {KeyboardEvent} event
* @private * @private
*/ */
__keyupHandler(e) { __keyupHandler(event) {
if (isKeyboardClickEvent(e)) { if (isKeyboardClickEvent(event)) {
// Fixes IE11 double submit/click. Enter keypress somehow triggers the __keyUpHandler on the native <button> // Fixes IE11 double submit/click. Enter keypress somehow triggers the __keyUpHandler on the native <button>
if (e.srcElement && e.srcElement !== this) { if (event.target && event.target !== this) {
return; return;
} }
// dispatch click // dispatch click
this.click(); this.click();
} }
} }
/**
* Prevents that someone who listens outside or on form catches the click event
* @param {Event} e
* @private
*/
__preventEventLeakage(e) {
if (e.target === this.__submitAndResetHelperButton) {
e.stopImmediatePropagation();
}
}
/**
* @private
*/
__setupSubmitAndResetHelperOnConnected() {
this._form = this._nativeButtonNode.form;
if (this._form) {
this._form.addEventListener('click', this.__preventEventLeakage);
}
}
/**
* @private
*/
__teardownSubmitAndResetHelperOnDisconnected() {
if (this._form) {
this._form.removeEventListener('click', this.__preventEventLeakage);
}
}
} }

View file

@ -0,0 +1,124 @@
import { LionButton } from './LionButton.js';
/**
* @typedef {import('@lion/core').TemplateResult} TemplateResult
*/
/**
* This adds functionality for form buttons (type 'submit' and 'reset').
* It allows to submit or reset a <form> by spawning a click on a temporrary native button inside
* the form.
* Use LionButtonSubmit when implicit form submission should be supported as well.
*
* Functionality in this button is not purely for type="reset", also for type="submit".
* For mainainability purposes the submit functionality is part of LionButtonReset
* (it needs the same logic).
* LionButtonReset could therefore actually be considered as 'LionButtonForm' (without the
* implicit form submission logic), but LionButtonReset is an easier to grasp name for
* Application Developers: for reset buttons, always use LionButtonReset, for submit
* buttons always use LionButtonSubmit.
*/
export class LionButtonReset extends LionButton {
constructor() {
super();
this.type = 'reset';
this.__setupDelegationInConstructor();
/** @type {HTMLButtonElement} */
this.__submitAndResetHelperButton = document.createElement('button');
/** @type {EventListener} */
this.__preventEventLeakage = this.__preventEventLeakage.bind(this);
}
connectedCallback() {
super.connectedCallback();
// Old browsers (IE11, Old Edge, Firefox ESR 60) don't have the `.form`
// property defined immediately on the native button, so do this after first render on connected.
this.updateComplete.then(() => {
this._setupSubmitAndResetHelperOnConnected();
});
}
disconnectedCallback() {
super.disconnectedCallback();
this._teardownSubmitAndResetHelperOnDisconnected();
}
/**
* Prevents that someone who listens outside or on form catches the click event
* @param {Event} e
* @private
*/
__preventEventLeakage(e) {
if (e.target === this.__submitAndResetHelperButton) {
e.stopImmediatePropagation();
}
}
/**
* @protected
*/
_setupSubmitAndResetHelperOnConnected() {
// Get form
this.appendChild(this.__submitAndResetHelperButton);
/** @type {HTMLFormElement|null} */
this._form = this.__submitAndResetHelperButton.form;
this.removeChild(this.__submitAndResetHelperButton);
if (this._form) {
this._form.addEventListener('click', this.__preventEventLeakage);
}
}
/**
* @protected
*/
_teardownSubmitAndResetHelperOnDisconnected() {
if (this._form) {
this._form.removeEventListener('click', this.__preventEventLeakage);
}
}
/**
* Delegate click, by flashing a native button as a direct child
* of the form, and firing click on this button. This will fire the form submit
* without side effects caused by the click bubbling back up to lion-button.
* @param {Event} ev
* @protected
* @returns {Promise<void>}
*/
// TODO: rename to _clickDelegationHandler in v1
async __clickDelegationHandler(ev) {
// Wait for updateComplete if form is not yet available
if (!this._form) {
await this.updateComplete;
}
if ((this.type === 'submit' || this.type === 'reset') && ev.target === this && this._form) {
/**
* Here, we make sure our button is compatible with a native form, by firing a click
* from a native button that our form responds to. The native button we spawn will be a direct
* child of the form, plus the click event that will be sent will be prevented from
* propagating outside of the form. This will keep the amount of 'noise' (click events
* from 'ghost elements' that can be intercepted by listeners in the bubble chain) to an
* absolute minimum.
*/
this.__submitAndResetHelperButton.type = this.type;
this._form.appendChild(this.__submitAndResetHelperButton);
// Form submission or reset will happen
this.__submitAndResetHelperButton.click();
this._form.removeChild(this.__submitAndResetHelperButton);
}
}
/**
* @private
*/
__setupDelegationInConstructor() {
// do not move to connectedCallback, otherwise IE11 breaks.
// more info: https://github.com/ing-bank/lion/issues/179#issuecomment-511763835
this.addEventListener('click', this.__clickDelegationHandler, true);
}
}

View file

@ -0,0 +1,109 @@
import { LionButtonReset } from './LionButtonReset.js';
/**
* @typedef {import('@lion/core').TemplateResult} TemplateResult
* @typedef {{lionButtons: Set<LionButtonSubmit>, helper:HTMLButtonElement, observer:MutationObserver}} HelperRegistration
*/
/** @type {WeakMap<HTMLFormElement, HelperRegistration>} */
const implicitHelperMap = new WeakMap();
function createImplicitSubmitHelperButton() {
const buttonEl = document.createElement('button');
buttonEl.tabIndex = -1;
buttonEl.type = 'submit';
buttonEl.setAttribute('aria-hidden', 'true');
// Make it sr-only
buttonEl.style.cssText = `
position: absolute;
top: 0;
left: 0;
clip: rect(0 0 0 0);
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
height: 1px;
width: 1px;
padding: 0; /* reset default agent styles */
border: 0; /* reset default agent styles */
`;
return buttonEl;
}
/**
* Contains all the funcionaility of LionButton and LionButtonReset. On top of that it
* supports implicit form submission.
*
* Use when:
* - the Application Developer should be able to switch types between 'submit'|'reset'|'button'
* (this is similar to how native HTMLButtonElement works)
* - a submit button with native form support is needed
*/
export class LionButtonSubmit extends LionButtonReset {
/**
* @type {HTMLButtonElement|null}
* @protected
*/
get _nativeButtonNode() {
return implicitHelperMap.get(/** @type {HTMLFormElement} */ (this._form))?.helper || null;
}
constructor() {
super();
this.type = 'submit';
/** @type {HTMLButtonElement|null} */
this.__implicitSubmitHelperButton = null;
}
_setupSubmitAndResetHelperOnConnected() {
super._setupSubmitAndResetHelperOnConnected();
if (!this._form || this.type !== 'submit') {
return;
}
/** @type {HTMLFormElement} */
const form = this._form;
const registrationForCurForm = implicitHelperMap.get(this._form);
if (!registrationForCurForm) {
const buttonEl = createImplicitSubmitHelperButton();
const wrapperEl = document.createElement('div');
wrapperEl.appendChild(buttonEl);
implicitHelperMap.set(this._form, {
lionButtons: new Set(),
helper: buttonEl,
observer: new MutationObserver(() => {
form.appendChild(wrapperEl);
}),
});
form.appendChild(wrapperEl);
// Prevent that the just created button gets lost during rerender
implicitHelperMap.get(form)?.observer.observe(wrapperEl, { childList: true });
}
implicitHelperMap.get(form)?.lionButtons.add(this);
}
_teardownSubmitAndResetHelperOnDisconnected() {
super._teardownSubmitAndResetHelperOnDisconnected();
if (this._form) {
// If we are the last button to leave the form, clean up the
const registrationForCurForm = /** @type {HelperRegistration} */ (
implicitHelperMap.get(/** @type {HTMLFormElement} */ (this._form))
);
if (registrationForCurForm) {
registrationForCurForm.lionButtons.delete(this);
if (!registrationForCurForm.lionButtons.size) {
if (this._form.contains(registrationForCurForm.helper)) {
registrationForCurForm.helper.remove();
}
implicitHelperMap.get(this._form)?.observer.disconnect();
implicitHelperMap.delete(this._form);
}
}
}
}
}

View file

@ -0,0 +1,378 @@
/* eslint-disable lit-a11y/click-events-have-key-events */
import { browserDetection } from '@lion/core';
import { aTimeout, expect, fixture, oneEvent, defineCE } from '@open-wc/testing';
import { html, unsafeStatic } from 'lit/static-html.js';
import { LionButton } from '@lion/button';
import sinon from 'sinon';
import '@lion/core/differentKeyEventNamesShimIE';
export function LionButtonSuite({ klass = LionButton } = {}) {
const tagStringButton = defineCE(class extends klass {});
const tagButton = unsafeStatic(tagStringButton);
describe('LionButton', () => {
it('has .type="button" and type="button" by default', async () => {
const el = /** @type {LionButton} */ (await fixture(html`<${tagButton}>foo</${tagButton}>`));
expect(el.type).to.equal('button');
expect(el.getAttribute('type')).to.be.equal('button');
});
it('is hidden when attribute hidden is true', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton} hidden>foo</${tagButton}>`)
);
expect(el).not.to.be.displayed;
});
it('can be disabled imperatively', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton} disabled>foo</${tagButton}>`)
);
expect(el.getAttribute('tabindex')).to.equal('-1');
expect(el.getAttribute('aria-disabled')).to.equal('true');
el.disabled = false;
await el.updateComplete;
expect(el.getAttribute('tabindex')).to.equal('0');
expect(el.getAttribute('aria-disabled')).to.equal('false');
expect(el.hasAttribute('disabled')).to.equal(false);
el.disabled = true;
await el.updateComplete;
expect(el.getAttribute('tabindex')).to.equal('-1');
expect(el.getAttribute('aria-disabled')).to.equal('true');
expect(el.hasAttribute('disabled')).to.equal(true);
});
describe('Active', () => {
it('updates "active" attribute on host when mousedown/mouseup on button', async () => {
const el = /** @type {LionButton} */ (await fixture(html`<${tagButton}>foo</${tagButton}`));
el.dispatchEvent(new Event('mousedown'));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
el.dispatchEvent(new Event('mouseup'));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
it('updates "active" attribute on host when mousedown on button and mouseup anywhere else', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
el.dispatchEvent(new Event('mousedown'));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
document.dispatchEvent(new Event('mouseup'));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
it('updates "active" attribute on host when space keydown/keyup on button', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
el.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
it('updates "active" attribute on host when space keydown on button and space keyup anywhere else', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
el.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
it('updates "active" attribute on host when enter keydown/keyup on button', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
it('updates "active" attribute on host when enter keydown on button and space keyup anywhere else', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
document.body.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
});
describe('Accessibility', () => {
it('has a role="button" by default', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
expect(el.getAttribute('role')).to.equal('button');
el.setAttribute('role', 'foo');
await el.updateComplete;
expect(el.getAttribute('role')).to.equal('foo');
});
it('does not override user provided role', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton} role="foo">foo</${tagButton}>`)
);
expect(el.getAttribute('role')).to.equal('foo');
});
it('has a tabindex="0" by default', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
expect(el.getAttribute('tabindex')).to.equal('0');
});
it('has a tabindex="-1" when disabled', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton} disabled>foo</${tagButton}>`)
);
expect(el.getAttribute('tabindex')).to.equal('-1');
el.disabled = false;
await el.updateComplete;
expect(el.getAttribute('tabindex')).to.equal('0');
el.disabled = true;
await el.updateComplete;
expect(el.getAttribute('tabindex')).to.equal('-1');
});
it('does not override user provided tabindex', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton} tabindex="5">foo</${tagButton}>`)
);
expect(el.getAttribute('tabindex')).to.equal('5');
});
it('disabled does not override user provided tabindex', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton} tabindex="5" disabled>foo</${tagButton}>`)
);
expect(el.getAttribute('tabindex')).to.equal('-1');
el.disabled = false;
await el.updateComplete;
expect(el.getAttribute('tabindex')).to.equal('5');
});
it('has an aria-labelledby and wrapper element in IE11', async () => {
const browserDetectionStub = sinon.stub(browserDetection, 'isIE11').value(true);
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
expect(el.hasAttribute('aria-labelledby')).to.be.true;
const wrapperId = el.getAttribute('aria-labelledby');
expect(/** @type {ShadowRoot} */ (el.shadowRoot).querySelector(`#${wrapperId}`)).to.exist;
expect(
/** @type {ShadowRoot} */ (el.shadowRoot).querySelector(`#${wrapperId}`),
).dom.to.equal(`<div class="button-content" id="${wrapperId}"><slot></slot></div>`);
browserDetectionStub.restore();
});
it('does not override aria-labelledby when provided by user', async () => {
const browserDetectionStub = sinon.stub(browserDetection, 'isIE11').value(true);
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton} aria-labelledby="some-id another-id">foo</${tagButton}>`)
);
expect(el.getAttribute('aria-labelledby')).to.equal('some-id another-id');
browserDetectionStub.restore();
});
it('[axe] is accessible', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
await expect(el).to.be.accessible();
});
it('[axe] is accessible when disabled', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton} disabled>foo</${tagButton}>`)
);
await expect(el).to.be.accessible({ ignoredRules: ['color-contrast'] });
});
});
describe('Click event', () => {
/**
* @param {HTMLButtonElement | LionButton} el
*/
async function prepareClickEvent(el) {
setTimeout(() => {
el.click();
});
return oneEvent(el, 'click');
}
it('is fired once', async () => {
const clickSpy = /** @type {EventListener} */ (sinon.spy());
const el = /** @type {LionButton} */ (
await fixture(html` <${tagButton} @click="${clickSpy}">foo</${tagButton}> `)
);
el.click();
// trying to wait for other possible redispatched events
await aTimeout(0);
await aTimeout(0);
expect(clickSpy).to.have.been.calledOnce;
});
describe('Native button behavior', async () => {
/** @type {Event} */
let nativeButtonEvent;
/** @type {Event} */
let lionButtonEvent;
before(async () => {
const nativeButtonEl = /** @type {LionButton} */ (await fixture('<button>foo</button>'));
const lionButtonEl = /** @type {LionButton} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
nativeButtonEvent = await prepareClickEvent(nativeButtonEl);
lionButtonEvent = await prepareClickEvent(lionButtonEl);
});
const sameProperties = [
'constructor',
'composed',
'bubbles',
'cancelable',
'clientX',
'clientY',
];
sameProperties.forEach(property => {
it(`has same value of the property "${property}" as in native button event`, () => {
expect(lionButtonEvent[property]).to.equal(nativeButtonEvent[property]);
});
});
});
describe('Event target', async () => {
it('is host by default', async () => {
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
const event = await prepareClickEvent(el);
expect(event.target).to.equal(el);
});
const useCases = [
{ container: 'div', type: 'submit' },
{ container: 'div', type: 'reset' },
{ container: 'div', type: 'button' },
{ container: 'form', type: 'submit' },
{ container: 'form', type: 'reset' },
{ container: 'form', type: 'button' },
];
useCases.forEach(useCase => {
const { container, type } = useCase;
const targetName = 'host';
it(`is ${targetName} with type ${type} and it is inside a ${container}`, async () => {
const clickSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const el = /** @type {LionButton} */ (
await fixture(html`<${tagButton} type="${type}">foo</${tagButton}>`)
);
const tag = unsafeStatic(container);
await fixture(html`<${tag} @click="${clickSpy}">${el}</${tag}>`);
const event = await prepareClickEvent(el);
expect(event.target).to.equal(el);
});
});
});
});
describe('With click event', () => {
it('behaves like native `button` when clicked', async () => {
const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html`
<form @submit=${/** @type {EventListener} */ ev => ev.preventDefault()}>
<${tagButton} @click="${formButtonClickedSpy}" type="submit">foo</${tagButton}>
</form>
`);
const button = /** @type {LionButton} */ (form.querySelector(tagStringButton));
button.click();
expect(formButtonClickedSpy).to.have.been.calledOnce;
});
it('behaves like native `button` when interacted with keyboard space', async () => {
const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html`
<form @submit=${/** @type {EventListener} */ ev => ev.preventDefault()}>
<${tagButton} @click="${formButtonClickedSpy}" type="submit">foo</${tagButton}>
</form>
`);
const lionButton = /** @type {LionButton} */ (form.querySelector(tagStringButton));
lionButton.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
await aTimeout(0);
await aTimeout(0);
expect(formButtonClickedSpy).to.have.been.calledOnce;
});
it('behaves like native `button` when interacted with keyboard enter', async () => {
const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html`
<form @submit=${/** @type {EventListener} */ ev => ev.preventDefault()}>
<${tagButton} @click="${formButtonClickedSpy}" type="submit">foo</${tagButton}>
</form>
`);
const button = /** @type {LionButton} */ (form.querySelector(tagStringButton));
button.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
await aTimeout(0);
await aTimeout(0);
expect(formButtonClickedSpy).to.have.been.calledOnce;
});
});
});
}

View file

@ -0,0 +1,197 @@
/* eslint-disable lit-a11y/click-events-have-key-events */
import { aTimeout, expect, fixture, defineCE } from '@open-wc/testing';
import { html, unsafeStatic } from 'lit/static-html.js';
import { LionButtonReset } from '@lion/button';
import sinon from 'sinon';
import '@lion/core/differentKeyEventNamesShimIE';
export function LionButtonResetSuite({ klass = LionButtonReset } = {}) {
const tagStringButtonReset = defineCE(class extends klass {});
const tagButtonReset = unsafeStatic(tagStringButtonReset);
describe('LionButtonReset', () => {
it('has .type="reset" and type="reset" by default', async () => {
const el = /** @type {LionButtonReset} */ (
await fixture(html`<${tagButtonReset}>foo</${tagButtonReset}>`)
);
expect(el.type).to.equal('reset');
expect(el.getAttribute('type')).to.be.equal('reset');
});
/**
* Notice functionality below is not purely for type="reset", also for type="submit".
* For mainainability purposes the submit functionality is part of LionButtonReset.
* (it needs the same logic)
* LionButtonReset could therefore actually be considered as 'LionButtonForm' (without the
* implicit form submission logic), but LionButtonReset is an easier to grasp name for
* Application Developers: for reset buttons, always use LionButtonReset, for submit
* buttons always use LionButton.
* For buttons that should support all three types (like native <button>); use LionButton.
*/
describe('Form integration', () => {
describe('With submit event', () => {
it('behaves like native `button` when clicked', async () => {
const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<${tagButtonReset} type="submit">foo</${tagButtonReset}>
</form>
`);
const button /** @type {LionButtonReset} */ = /** @type {LionButtonReset} */ (
form.querySelector(tagStringButtonReset)
);
button.click();
expect(formSubmitSpy).to.have.been.calledOnce;
});
it('behaves like native `button` when interacted with keyboard space', async () => {
const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<${tagButtonReset} type="submit">foo</${tagButtonReset}>
</form>
`);
const button /** @type {LionButtonReset} */ = /** @type {LionButtonReset} */ (
form.querySelector(tagStringButtonReset)
);
button.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
await aTimeout(0);
await aTimeout(0);
expect(formSubmitSpy).to.have.been.calledOnce;
});
it('behaves like native `button` when interacted with keyboard enter', async () => {
const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<${tagButtonReset} type="submit">foo</${tagButtonReset}>
</form>
`);
const button = /** @type {LionButtonReset} */ (form.querySelector(tagStringButtonReset));
button.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
await aTimeout(0);
await aTimeout(0);
expect(formSubmitSpy).to.have.been.calledOnce;
});
it('supports resetting form inputs in a native form', async () => {
const form = await fixture(html`
<form>
<input name="firstName" />
<input name="lastName" />
<${tagButtonReset} type="reset">reset</${tagButtonReset}>
</form>
`);
const btn /** @type {LionButtonReset} */ = /** @type {LionButtonReset} */ (
form.querySelector(tagStringButtonReset)
);
const firstName = /** @type {HTMLInputElement} */ (
form.querySelector('input[name=firstName]')
);
const lastName = /** @type {HTMLInputElement} */ (
form.querySelector('input[name=lastName]')
);
firstName.value = 'Foo';
lastName.value = 'Bar';
expect(firstName.value).to.equal('Foo');
expect(lastName.value).to.equal('Bar');
btn.click();
expect(firstName.value).to.be.empty;
expect(lastName.value).to.be.empty;
});
});
});
it('is fired once outside and inside the form', async () => {
const outsideSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const insideSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const formSpyEarly = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const formSpyLater = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const el = /** @type {HTMLDivElement} */ (
await fixture(
html`
<div @click="${outsideSpy}">
<form @click="${formSpyEarly}">
<div @click="${insideSpy}">
<${tagButtonReset}>foo</${tagButtonReset}>
</div>
</form>
</div>
`,
)
);
const lionButton = /** @type {LionButtonReset} */ (el.querySelector(tagStringButtonReset));
const form = /** @type {HTMLFormElement} */ (el.querySelector('form'));
form.addEventListener('click', formSpyLater);
lionButton.click();
// trying to wait for other possible redispatched events
await aTimeout(0);
await aTimeout(0);
expect(insideSpy).to.have.been.calledOnce;
expect(outsideSpy).to.have.been.calledOnce;
// A small sacrifice for event listeners registered early: we get the native button evt.
expect(formSpyEarly).to.have.been.calledTwice;
expect(formSpyLater).to.have.been.calledOnce;
});
it('works when connected to different form', async () => {
const form1El = /** @type {HTMLFormElement} */ (
await fixture(
html`
<form>
<${tagButtonReset}>foo</${tagButtonReset}>
</form>
`,
)
);
const lionButton = /** @type {LionButtonReset} */ (
form1El.querySelector(tagStringButtonReset)
);
expect(lionButton._form).to.equal(form1El);
// Now we add the lionButton to a different form.
// We disconnect and connect and check if everything still works as expected
const outsideSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const insideSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const formSpyEarly = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const formSpyLater = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form2El = /** @type {HTMLFormElement} */ (
await fixture(
html`
<div @click="${outsideSpy}">
<form @click="${formSpyEarly}">
<div @click="${insideSpy}">${lionButton}</div>
</form>
</div>
`,
)
);
const form2Node = /** @type {HTMLFormElement} */ (form2El.querySelector('form'));
expect(lionButton._form).to.equal(form2Node);
form2Node.addEventListener('click', formSpyLater);
lionButton.click();
// trying to wait for other possible redispatched events
await aTimeout(0);
await aTimeout(0);
expect(insideSpy).to.have.been.calledOnce;
expect(outsideSpy).to.have.been.calledOnce;
// A small sacrifice for event listeners registered early: we get the native button evt.
expect(formSpyEarly).to.have.been.calledTwice;
expect(formSpyLater).to.have.been.calledOnce;
});
});
}

View file

@ -0,0 +1,189 @@
/* eslint-disable lit-a11y/click-events-have-key-events */
import { aTimeout, expect, fixture, defineCE } from '@open-wc/testing';
import { html, unsafeStatic } from 'lit/static-html.js';
import { sendKeys } from '@web/test-runner-commands';
import { LionButtonSubmit } from '@lion/button';
import sinon from 'sinon';
import '@lion/core/differentKeyEventNamesShimIE';
export function LionButtonSubmitSuite({ klass = LionButtonSubmit } = {}) {
const tagStringButton = defineCE(class extends klass {});
const tagButton = unsafeStatic(tagStringButton);
describe('LionButtonSubmit', () => {
it('has .type="submit" and type="submit" by default', async () => {
const el = /** @type {LionButtonSubmit} */ (
await fixture(html`<${tagButton}>foo</${tagButton}>`)
);
expect(el.type).to.equal('submit');
expect(el.getAttribute('type')).to.be.equal('submit');
});
describe('Implicit form submission', () => {
describe('Helper submit button', () => {
it('creates a helper submit button when type is "submit"', async () => {
let lionBtnEl;
const elTypeSubmit = /** @type {HTMLFormElement} */ (
await fixture(html`<form><${tagButton} type="submit">foo</${tagButton}></form>`)
);
lionBtnEl = /** @type {LionButtonSubmit} */ (elTypeSubmit.querySelector('[type=submit]'));
// @ts-ignore [allow-protected] in test
expect(lionBtnEl._nativeButtonNode instanceof HTMLButtonElement).to.be.true;
// @ts-ignore [allow-protected] in test
expect(lionBtnEl._nativeButtonNode.type).to.equal('submit');
const elTypeReset = /** @type {LionButtonSubmit} */ (
await fixture(html`<form><${tagButton} type="reset">foo</${tagButton}></form>`)
);
lionBtnEl = /** @type {LionButtonSubmit} */ (elTypeReset.querySelector('[type=reset]'));
// @ts-ignore [allow-protected] in test
expect(lionBtnEl._nativeButtonNode).to.be.null;
const elTypeButton = /** @type {LionButtonSubmit} */ (
await fixture(html`<form><${tagButton} type="button">foo</${tagButton}></form>`)
);
lionBtnEl = /** @type {LionButtonSubmit} */ (elTypeButton.querySelector('[type=button]'));
// @ts-ignore [allow-protected] in test
expect(lionBtnEl._nativeButtonNode).to.be.null;
});
it('only creates a helper submit button when LionButtonSubmit is inside a form', async () => {
const elForm = /** @type {HTMLFormElement} */ (await fixture(html`<form></form>`));
const el = /** @type {LionButtonSubmit} */ (
await fixture(html`<${tagButton} type="submit">foo</${tagButton}>`)
);
// @ts-ignore [allow-protected] in test
expect(el._nativeButtonNode).to.be.null;
elForm.appendChild(el);
await el.updateComplete;
// @ts-ignore [allow-protected] in test
expect(el._nativeButtonNode).to.be.not.null;
elForm.removeChild(el);
// @ts-ignore [allow-protected] in test
expect(el._nativeButtonNode).to.be.null;
});
it('puts helper submit button at the bottom of a form', async () => {
const elForm = /** @type {HTMLFormElement} */ (
await fixture(
html`<form><input /><${tagButton} type="submit">foo</${tagButton}><input /></form>`,
)
);
const lionBtnEl = /** @type {LionButtonSubmit} */ (elForm.querySelector('[type=submit]'));
expect(elForm.children.length).to.equal(4); // 3 + 1
// @ts-ignore [allow-protected] in test
expect(lionBtnEl._nativeButtonNode).to.be.not.null;
// @ts-ignore [allow-protected] in test
expect(elForm.children[3].firstChild).to.equal(lionBtnEl._nativeButtonNode);
});
it('creates max one helper submit button per form', async () => {
const elForm = /** @type {HTMLFormElement} */ (
await fixture(html`
<form>
<input />
<${tagButton} type="submit">foo</${tagButton}>
<${tagButton} type="submit">foo</${tagButton}>
<input />
</form>
`)
);
const [lionBtnEl1, lionBtnEl2] = /** @type {LionButtonSubmit[]} */ (
Array.from(elForm.querySelectorAll('[type=submit]'))
);
const { children } = elForm;
expect(children.length).to.equal(5); // 4 + 1
// @ts-ignore [allow-protected] in test
expect(lionBtnEl1._nativeButtonNode).to.be.not.null;
// @ts-ignore [allow-protected] in test
expect(lionBtnEl2._nativeButtonNode).to.be.not.null;
// @ts-ignore [allow-protected] in test
expect(children[children.length - 1].firstChild).to.equal(lionBtnEl1._nativeButtonNode);
// @ts-ignore [allow-protected] in test
expect(children[children.length - 1].firstChild).to.equal(lionBtnEl2._nativeButtonNode);
});
it('helper submit button gets reconnected when external context changes (rerenders)', async () => {
const elForm = /** @type {HTMLFormElement} */ (
await fixture(html`<form><${tagButton} type="submit">foo</${tagButton}></form>`)
);
const helperBtnEl = /** @type {HTMLButtonElement} */ (
elForm.querySelector('button[type=submit]')
);
helperBtnEl.remove();
expect(elForm).to.not.include(helperBtnEl);
await aTimeout(0);
expect(elForm).to.include(helperBtnEl);
});
it('helper submit button gets removed when last LionbuttonSubmit gets disconnected from form', async () => {
const elForm = /** @type {HTMLFormElement} */ (
await fixture(
html`<form><${tagButton} type="submit">foo</${tagButton}><${tagButton} type="submit">foo</${tagButton}></form>`,
)
);
const [lionBtnEl1, lionBtnEl2] = /** @type {LionButtonSubmit[]} */ (
Array.from(elForm.querySelectorAll('[type=submit]'))
);
const helperBtnEl = elForm.children[elForm.children.length - 1].firstChild;
// @ts-ignore [allow-protected] in test
expect(helperBtnEl).to.equal(lionBtnEl1._nativeButtonNode);
// @ts-ignore [allow-protected] in test
expect(helperBtnEl).to.equal(lionBtnEl2._nativeButtonNode);
elForm.removeChild(lionBtnEl1);
// @ts-ignore [allow-protected] in test
expect(helperBtnEl).to.equal(lionBtnEl2._nativeButtonNode);
elForm.removeChild(lionBtnEl2);
// @ts-ignore [allow-protected] in test
expect(helperBtnEl).to.not.equal(lionBtnEl2._nativeButtonNode);
expect(Array.from(elForm.children)).to.not.include(helperBtnEl);
});
it('hides the helper submit button in the UI', async () => {
const el = /** @type {LionButtonSubmit} */ (
await fixture(html`<form><${tagButton}>foo</${tagButton}></form>`)
);
// @ts-ignore [allow-protected] in test
const helperButtonEl = el.querySelector(tagStringButton)._nativeButtonNode;
expect(helperButtonEl.getAttribute('tabindex')).to.equal('-1');
expect(window.getComputedStyle(helperButtonEl).clip).to.equal('rect(0px, 0px, 0px, 0px)');
});
});
it('works with implicit form submission on-enter inside an input', async () => {
const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<input name="foo" />
<input name="foo2" />
<${tagButton} type="submit">foo</${tagButton}>
</form>
`);
const input2 = /** @type {HTMLInputElement} */ (form.querySelector('input[name="foo2"]'));
input2.focus();
await sendKeys({
press: 'Enter',
});
expect(formSubmitSpy).to.have.been.calledOnce;
});
});
describe('Accessibility', () => {
it('the helper button has aria-hidden set to true', async () => {
const el = /** @type {LionButtonSubmit} */ (
await fixture(html`<form><${tagButton}></${tagButton}></form>`)
);
// @ts-ignore [allow-protected] in test
const helperButtonEl = el.querySelector(tagStringButton)._nativeButtonNode;
expect(helperButtonEl.getAttribute('aria-hidden')).to.equal('true');
});
});
});
}

View file

@ -1,602 +1,9 @@
/* eslint-disable lit-a11y/click-events-have-key-events */ import { LionButtonSuite } from '../test-suites/LionButton.suite.js';
import { browserDetection } from '@lion/core'; import { LionButtonResetSuite } from '../test-suites/LionButtonReset.suite.js';
import { aTimeout, expect, fixture, oneEvent } from '@open-wc/testing'; import { LionButtonSubmitSuite } from '../test-suites/LionButtonSubmit.suite.js';
import { unsafeStatic, html } from 'lit/static-html.js';
import sinon from 'sinon';
import '@lion/core/differentKeyEventNamesShimIE';
import '@lion/button/define';
/**
* @typedef {import('@lion/button/src/LionButton').LionButton} LionButton
*/
/**
* @param {LionButton} el
*/
function getProtectedMembers(el) {
return {
// @ts-ignore
nativeButtonNode: el._nativeButtonNode,
};
}
describe('lion-button', () => { describe('lion-button', () => {
it('behaves like native `button` in terms of a11y', async () => { LionButtonSuite();
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`)); LionButtonResetSuite();
expect(el.getAttribute('role')).to.equal('button'); LionButtonSubmitSuite();
expect(el.getAttribute('tabindex')).to.equal('0');
});
it('has .type="submit" and type="submit" by default', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
const { nativeButtonNode } = getProtectedMembers(el);
expect(el.type).to.equal('submit');
expect(el.getAttribute('type')).to.be.equal('submit');
expect(nativeButtonNode.type).to.equal('submit');
expect(nativeButtonNode.getAttribute('type')).to.be.equal('submit');
});
it('sync type down to the native button', async () => {
const el = /** @type {LionButton} */ (
await fixture(`<lion-button type="button">foo</lion-button>`)
);
const { nativeButtonNode } = getProtectedMembers(el);
expect(el.type).to.equal('button');
expect(el.getAttribute('type')).to.be.equal('button');
expect(nativeButtonNode.type).to.equal('button');
expect(nativeButtonNode.getAttribute('type')).to.be.equal('button');
});
it('hides the native button in the UI', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
const { nativeButtonNode } = getProtectedMembers(el);
expect(nativeButtonNode.getAttribute('tabindex')).to.equal('-1');
expect(window.getComputedStyle(nativeButtonNode).clip).to.equal('rect(0px, 0px, 0px, 0px)');
});
it('is hidden when attribute hidden is true', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button hidden>foo</lion-button>`));
expect(el).not.to.be.displayed;
});
it('can be disabled imperatively', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button disabled>foo</lion-button>`));
expect(el.getAttribute('tabindex')).to.equal('-1');
expect(el.getAttribute('aria-disabled')).to.equal('true');
el.disabled = false;
await el.updateComplete;
expect(el.getAttribute('tabindex')).to.equal('0');
expect(el.getAttribute('aria-disabled')).to.equal('false');
expect(el.hasAttribute('disabled')).to.equal(false);
el.disabled = true;
await el.updateComplete;
expect(el.getAttribute('tabindex')).to.equal('-1');
expect(el.getAttribute('aria-disabled')).to.equal('true');
expect(el.hasAttribute('disabled')).to.equal(true);
});
describe('active', () => {
it('updates "active" attribute on host when mousedown/mouseup on button', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new Event('mousedown'));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
el.dispatchEvent(new Event('mouseup'));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
it('updates "active" attribute on host when mousedown on button and mouseup anywhere else', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new Event('mousedown'));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
document.dispatchEvent(new Event('mouseup'));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
it('updates "active" attribute on host when space keydown/keyup on button', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
el.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
it('updates "active" attribute on host when space keydown on button and space keyup anywhere else', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
el.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
it('updates "active" attribute on host when enter keydown/keyup on button', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
it('updates "active" attribute on host when enter keydown on button and space keyup anywhere else', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(el.active).to.be.true;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.true;
document.body.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
expect(el.active).to.be.false;
await el.updateComplete;
expect(el.hasAttribute('active')).to.be.false;
});
});
describe('a11y', () => {
it('has a role="button" by default', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
expect(el.getAttribute('role')).to.equal('button');
el.role = 'foo';
await el.updateComplete;
expect(el.getAttribute('role')).to.equal('foo');
});
it('does not override user provided role', async () => {
const el = /** @type {LionButton} */ (
await fixture(`<lion-button role="foo">foo</lion-button>`)
);
expect(el.getAttribute('role')).to.equal('foo');
});
it('has a tabindex="0" by default', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
expect(el.getAttribute('tabindex')).to.equal('0');
});
it('has a tabindex="-1" when disabled', async () => {
const el = /** @type {LionButton} */ (
await fixture(`<lion-button disabled>foo</lion-button>`)
);
expect(el.getAttribute('tabindex')).to.equal('-1');
el.disabled = false;
await el.updateComplete;
expect(el.getAttribute('tabindex')).to.equal('0');
el.disabled = true;
await el.updateComplete;
expect(el.getAttribute('tabindex')).to.equal('-1');
});
it('does not override user provided tabindex', async () => {
const el = /** @type {LionButton} */ (
await fixture(`<lion-button tabindex="5">foo</lion-button>`)
);
expect(el.getAttribute('tabindex')).to.equal('5');
});
it('disabled does not override user provided tabindex', async () => {
const el = /** @type {LionButton} */ (
await fixture(`<lion-button tabindex="5" disabled>foo</lion-button>`)
);
expect(el.getAttribute('tabindex')).to.equal('-1');
el.disabled = false;
await el.updateComplete;
expect(el.getAttribute('tabindex')).to.equal('5');
});
it('has an aria-labelledby and wrapper element in IE11', async () => {
const browserDetectionStub = sinon.stub(browserDetection, 'isIE11').value(true);
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
expect(el.hasAttribute('aria-labelledby')).to.be.true;
const wrapperId = el.getAttribute('aria-labelledby');
expect(/** @type {ShadowRoot} */ (el.shadowRoot).querySelector(`#${wrapperId}`)).to.exist;
expect(/** @type {ShadowRoot} */ (el.shadowRoot).querySelector(`#${wrapperId}`)).dom.to.equal(
`<div class="button-content" id="${wrapperId}"><slot></slot></div>`,
);
browserDetectionStub.restore();
});
it('does not override aria-labelledby when provided by user', async () => {
const browserDetectionStub = sinon.stub(browserDetection, 'isIE11').value(true);
const el = /** @type {LionButton} */ (
await fixture(`<lion-button aria-labelledby="some-id another-id">foo</lion-button>`)
);
expect(el.getAttribute('aria-labelledby')).to.equal('some-id another-id');
browserDetectionStub.restore();
});
it('has a native button node with aria-hidden set to true', async () => {
const el = /** @type {LionButton} */ (await fixture('<lion-button></lion-button>'));
const { nativeButtonNode } = getProtectedMembers(el);
expect(nativeButtonNode.getAttribute('aria-hidden')).to.equal('true');
});
// TODO: enable when native button is not a child anymore
it.skip('is accessible', async () => {
const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
await expect(el).to.be.accessible();
});
// TODO: enable when native button is not a child anymore
it.skip('is accessible when disabled', async () => {
const el = /** @type {LionButton} */ (
await fixture(`<lion-button disabled>foo</lion-button>`)
);
await expect(el).to.be.accessible({ ignoredRules: ['color-contrast'] });
});
});
describe('form integration', () => {
describe('with submit event', () => {
it('behaves like native `button` when clicked', async () => {
const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<lion-button type="submit">foo</lion-button>
</form>
`);
const button /** @type {LionButton} */ = /** @type {LionButton} */ (
form.querySelector('lion-button')
);
button.click();
expect(formSubmitSpy).to.have.been.calledOnce;
});
it('behaves like native `button` when interacted with keyboard space', async () => {
const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<lion-button type="submit">foo</lion-button>
</form>
`);
const button /** @type {LionButton} */ = /** @type {LionButton} */ (
form.querySelector('lion-button')
);
button.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
await aTimeout(0);
await aTimeout(0);
expect(formSubmitSpy).to.have.been.calledOnce;
});
it('behaves like native `button` when interacted with keyboard enter', async () => {
const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<lion-button type="submit">foo</lion-button>
</form>
`);
const button = /** @type {LionButton} */ (form.querySelector('lion-button'));
button.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
await aTimeout(0);
await aTimeout(0);
expect(formSubmitSpy).to.have.been.calledOnce;
});
it('supports resetting form inputs in a native form', async () => {
const form = await fixture(html`
<form>
<input name="firstName" />
<input name="lastName" />
<lion-button type="reset">reset</lion-button>
</form>
`);
const btn /** @type {LionButton} */ = /** @type {LionButton} */ (
form.querySelector('lion-button')
);
const firstName = /** @type {HTMLInputElement} */ (
form.querySelector('input[name=firstName]')
);
const lastName = /** @type {HTMLInputElement} */ (
form.querySelector('input[name=lastName]')
);
firstName.value = 'Foo';
lastName.value = 'Bar';
expect(firstName.value).to.equal('Foo');
expect(lastName.value).to.equal('Bar');
btn.click();
expect(firstName.value).to.be.empty;
expect(lastName.value).to.be.empty;
});
// input "enter" keypress mock doesn't seem to work right now, but should be tested in the future (maybe with Selenium)
it.skip('works with implicit form submission on-enter inside an input', async () => {
const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<input name="foo" />
<input name="foo2" />
<lion-button type="submit">foo</lion-button>
</form>
`);
const input2 = /** @type {HTMLInputElement} */ (form.querySelector('input[name="foo2"]'));
input2.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
await aTimeout(0);
await aTimeout(0);
expect(formSubmitSpy).to.have.been.calledOnce;
});
});
describe('with click event', () => {
it('behaves like native `button` when clicked', async () => {
const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html`
<form @submit=${/** @type {EventListener} */ ev => ev.preventDefault()}>
<lion-button @click="${formButtonClickedSpy}" type="submit">foo</lion-button>
</form>
`);
const button = /** @type {LionButton} */ (form.querySelector('lion-button'));
button.click();
expect(formButtonClickedSpy).to.have.been.calledOnce;
});
it('behaves like native `button` when interacted with keyboard space', async () => {
const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html`
<form @submit=${/** @type {EventListener} */ ev => ev.preventDefault()}>
<lion-button @click="${formButtonClickedSpy}" type="submit">foo</lion-button>
</form>
`);
const lionButton = /** @type {LionButton} */ (form.querySelector('lion-button'));
lionButton.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
await aTimeout(0);
await aTimeout(0);
expect(formButtonClickedSpy).to.have.been.calledOnce;
});
it('behaves like native `button` when interacted with keyboard enter', async () => {
const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html`
<form @submit=${/** @type {EventListener} */ ev => ev.preventDefault()}>
<lion-button @click="${formButtonClickedSpy}" type="submit">foo</lion-button>
</form>
`);
const button = /** @type {LionButton} */ (form.querySelector('lion-button'));
button.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
await aTimeout(0);
await aTimeout(0);
expect(formButtonClickedSpy).to.have.been.calledOnce;
});
// input "enter" keypress mock doesn't seem to work right now, but should be tested in the future (maybe with Selenium)
it.skip('works with implicit form submission on-enter inside an input', async () => {
const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html`
<form @submit=${/** @type {EventListener} */ ev => ev.preventDefault()}>
<input name="foo" />
<input name="foo2" />
<lion-button @click="${formButtonClickedSpy}" type="submit">foo</lion-button>
</form>
`);
const input2 = /** @type {HTMLInputElement} */ (form.querySelector('input[name="foo2"]'));
input2.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
await aTimeout(0);
await aTimeout(0);
expect(formButtonClickedSpy).to.have.been.calledOnce;
});
});
});
describe('click event', () => {
/**
* @param {HTMLButtonElement | LionButton} el
*/
async function prepareClickEvent(el) {
setTimeout(() => {
el.click();
});
return oneEvent(el, 'click');
}
it('is fired once', async () => {
const clickSpy = /** @type {EventListener} */ (sinon.spy());
const el = /** @type {LionButton} */ (
await fixture(html` <lion-button @click="${clickSpy}">foo</lion-button> `)
);
el.click();
// trying to wait for other possible redispatched events
await aTimeout(0);
await aTimeout(0);
expect(clickSpy).to.have.been.calledOnce;
});
it('is fired once outside and inside the form', async () => {
const outsideSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const insideSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const formSpyEarly = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const formSpyLater = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const el = /** @type {HTMLDivElement} */ (
await fixture(
html`
<div @click="${outsideSpy}">
<form @click="${formSpyEarly}">
<div @click="${insideSpy}">
<lion-button>foo</lion-button>
</div>
</form>
</div>
`,
)
);
const lionButton = /** @type {LionButton} */ (el.querySelector('lion-button'));
const form = /** @type {HTMLFormElement} */ (el.querySelector('form'));
form.addEventListener('click', formSpyLater);
lionButton.click();
// trying to wait for other possible redispatched events
await aTimeout(0);
await aTimeout(0);
expect(insideSpy).to.have.been.calledOnce;
expect(outsideSpy).to.have.been.calledOnce;
// A small sacrifice for event listeners registered early: we get the native button evt.
expect(formSpyEarly).to.have.been.calledTwice;
expect(formSpyLater).to.have.been.calledOnce;
});
it('works when connected to different form', async () => {
const form1El = /** @type {HTMLFormElement} */ (
await fixture(
html`
<form>
<lion-button>foo</lion-button>
</form>
`,
)
);
const lionButton = /** @type {LionButton} */ (form1El.querySelector('lion-button'));
expect(lionButton._form).to.equal(form1El);
// Now we add the lionButton to a different form.
// We disconnect and connect and check if everything still works as expected
const outsideSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const insideSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const formSpyEarly = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const formSpyLater = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form2El = /** @type {HTMLFormElement} */ (
await fixture(
html`
<div @click="${outsideSpy}">
<form @click="${formSpyEarly}">
<div @click="${insideSpy}">${lionButton}</div>
</form>
</div>
`,
)
);
const form2Node = /** @type {HTMLFormElement} */ (form2El.querySelector('form'));
expect(lionButton._form).to.equal(form2Node);
form2Node.addEventListener('click', formSpyLater);
lionButton.click();
// trying to wait for other possible redispatched events
await aTimeout(0);
await aTimeout(0);
expect(insideSpy).to.have.been.calledOnce;
expect(outsideSpy).to.have.been.calledOnce;
// A small sacrifice for event listeners registered early: we get the native button evt.
expect(formSpyEarly).to.have.been.calledTwice;
expect(formSpyLater).to.have.been.calledOnce;
});
describe('native button behavior', async () => {
/** @type {Event} */
let nativeButtonEvent;
/** @type {Event} */
let lionButtonEvent;
before(async () => {
const nativeButtonEl = /** @type {LionButton} */ (await fixture('<button>foo</button>'));
const lionButtonEl = /** @type {LionButton} */ (
await fixture('<lion-button>foo</lion-button>')
);
nativeButtonEvent = await prepareClickEvent(nativeButtonEl);
lionButtonEvent = await prepareClickEvent(lionButtonEl);
});
const sameProperties = [
'constructor',
'composed',
'bubbles',
'cancelable',
'clientX',
'clientY',
];
sameProperties.forEach(property => {
it(`has same value of the property "${property}" as in native button event`, () => {
expect(lionButtonEvent[property]).to.equal(nativeButtonEvent[property]);
});
});
});
describe('event target', async () => {
it('is host by default', async () => {
const el = /** @type {LionButton} */ (await fixture('<lion-button>foo</lion-button>'));
const event = await prepareClickEvent(el);
expect(event.target).to.equal(el);
});
const useCases = [
{ container: 'div', type: 'submit' },
{ container: 'div', type: 'reset' },
{ container: 'div', type: 'button' },
{ container: 'form', type: 'submit' },
{ container: 'form', type: 'reset' },
{ container: 'form', type: 'button' },
];
useCases.forEach(useCase => {
const { container, type } = useCase;
const targetName = 'host';
it(`is ${targetName} with type ${type} and it is inside a ${container}`, async () => {
const clickSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const el = /** @type {LionButton} */ (
await fixture(`<lion-button type="${type}">foo</lion-button>`)
);
const tag = unsafeStatic(container);
await fixture(html`<${tag} @click="${clickSpy}">${el}</${tag}>`);
const event = await prepareClickEvent(el);
expect(event.target).to.equal(el);
});
});
});
});
}); });

View file

@ -145,15 +145,15 @@ export class UmbrellaForm extends LitElement {
</lion-input-stepper> </lion-input-stepper>
<lion-textarea name="comments" label="Comments"></lion-textarea> <lion-textarea name="comments" label="Comments"></lion-textarea>
<div class="buttons"> <div class="buttons">
<lion-button id="submit_button" raised>Submit</lion-button> <lion-button-submit id="submit_button" raised>Submit</lion-button-submit>
<lion-button <lion-button-reset
id="reset_button" id="reset_button"
type="button" type="button"
raised raised
@click="${() => { @click="${() => {
this._lionFormNode.resetGroup(); this._lionFormNode.resetGroup();
}}" }}"
>Reset</lion-button >Reset</lion-button-reset
> >
</div> </div>
</form> </form>

View file

@ -1,5 +1,5 @@
import { LionButton } from '@lion/button'; import { LionButton } from '@lion/button';
import { css, html } from '@lion/core'; import { css, html, SlotMixin } from '@lion/core';
/** /**
* @typedef {import('@lion/core').CSSResult} CSSResult * @typedef {import('@lion/core').CSSResult} CSSResult
@ -10,7 +10,7 @@ import { css, html } from '@lion/core';
/** /**
* LionSelectInvoker: invoker button consuming a selected element * LionSelectInvoker: invoker button consuming a selected element
*/ */
export class LionSelectInvoker extends LionButton { export class LionSelectInvoker extends SlotMixin(LionButton) {
static get styles() { static get styles() {
return [ return [
...super.styles, ...super.styles,
@ -121,6 +121,10 @@ export class LionSelectInvoker extends LionButton {
return this._noSelectionTemplate(); return this._noSelectionTemplate();
} }
render() {
return html` ${this._beforeTemplate()} ${super.render()} ${this._afterTemplate()} `;
}
/** /**
* To be overriden for a placeholder, used when `hasNoDefaultSelected` is true on the select rich * To be overriden for a placeholder, used when `hasNoDefaultSelected` is true on the select rich
* @protected * @protected