Skip to content

Commit d19ca7c

Browse files
gerjanvangeestJoren Broekema
andcommitted
fix(button): fix form integration, fires only once, submit preventable
Co-authored-by: Joren Broekema <Joren.Broekema@ing.com>
1 parent 1caf1a9 commit d19ca7c

File tree

4 files changed

+231
-93
lines changed

4 files changed

+231
-93
lines changed

packages/button/README.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,38 @@ import '@lion/button/lion-button.js';
2828
<lion-button>Button Text</lion-button>
2929
```
3030

31-
- Don't use a button when you want a user to navigate. Use a link instead.
32-
- Not all color and font size combinations are available because some do not meet accessibility contrast requirements
33-
3431
## Considerations
3532

36-
### Why a webcomponent?
33+
### Why a Web Component?
3734

38-
There are multiple reasons why we used a web component as opposed to a CSS component.
35+
There are multiple reasons why we used a Web Component as opposed to a CSS component.
3936

4037
- **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.
4138
- **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.
4239
- **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.
40+
41+
### Event target
42+
43+
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.
44+
45+
### Flashing a native button click as a direct child of form
46+
47+
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.
48+
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.
49+
50+
### Native button & implicit form submission
51+
52+
Flashing the button in the way we do solves almost all issues except for one.
53+
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.
54+
55+
To get this particular implicit form submission to work, having a native button in our `lion-button` is a hard requirement. Therefore, not only do we flash a native button on the form to delegate `lion-button` trigger to `button` and thereby trigger form submission, we **also** add a native `button` inside the `lion-button` which `type` property is synchronized with the type of the `lion-button`.
56+
57+
### Preventing full page reloads
58+
59+
To prevent form submission full page reloads, add a **submit handler on the form** like so:
60+
61+
```html
62+
<form @submit=${ev => ev.preventDefault()} >
63+
```
64+
65+
Putting this on the `@click` of the `lion-button` is not enough.

packages/button/src/LionButton.js

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { css, html, SlotMixin, DisabledWithTabIndexMixin, LitElement } from '@lion/core';
22

3+
// eslint-disable-next-line class-methods-use-this
4+
const isKeyboardClickEvent = e => e.keyCode === 32 /* space */ || e.keyCode === 13; /* enter */
5+
36
export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement)) {
47
static get properties() {
58
return {
@@ -119,6 +122,14 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
119122
];
120123
}
121124

125+
get _nativeButtonNode() {
126+
return this.querySelector('[slot=_button]');
127+
}
128+
129+
get _form() {
130+
return this._nativeButtonNode.form;
131+
}
132+
122133
get slots() {
123134
return {
124135
...super.slots,
@@ -132,10 +143,6 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
132143
};
133144
}
134145

135-
get _nativeButtonNode() {
136-
return this.querySelector('[slot=_button]');
137-
}
138-
139146
constructor() {
140147
super();
141148
this.role = 'button';
@@ -172,12 +179,22 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
172179
}
173180

174181
/**
175-
* Dispatch submit event and invoke submit on the native form when clicked
182+
* Delegate click, by flashing a native button as a direct child
183+
* of the form, and firing click on this button. This will fire the form submit
184+
* without side effects caused by the click bubbling back up to lion-button.
176185
*/
177-
__clickDelegationHandler() {
178-
if (this.type === 'submit' && this._nativeButtonNode && this._nativeButtonNode.form) {
179-
this._nativeButtonNode.form.dispatchEvent(new Event('submit'));
180-
this._nativeButtonNode.form.submit();
186+
__clickDelegationHandler(e) {
187+
if (this.constructor.__isIE11()) {
188+
e.stopPropagation();
189+
}
190+
191+
if (this.type === 'submit' && e.target === this) {
192+
if (this._form) {
193+
const nativeButton = document.createElement('button');
194+
this._form.appendChild(nativeButton);
195+
nativeButton.click();
196+
this._form.removeChild(nativeButton);
197+
}
181198
}
182199
}
183200

@@ -209,12 +226,13 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
209226
}
210227

211228
__keydownHandler(e) {
212-
if (this.active || !this.__isKeyboardClickEvent(e)) {
229+
if (this.active || !isKeyboardClickEvent(e)) {
213230
return;
214231
}
232+
// FIXME: In Edge & IE11, this toggling the active state to prevent bounce, does not work.
215233
this.active = true;
216234
const keyupHandler = keyupEvent => {
217-
if (this.__isKeyboardClickEvent(keyupEvent)) {
235+
if (isKeyboardClickEvent(keyupEvent)) {
218236
this.active = false;
219237
document.removeEventListener('keyup', keyupHandler, true);
220238
}
@@ -223,17 +241,16 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
223241
}
224242

225243
__keyupHandler(e) {
226-
if (this.__isKeyboardClickEvent(e)) {
227-
// redispatch click
244+
if (isKeyboardClickEvent(e)) {
245+
// Fixes IE11 double submit/click. Enter keypress somehow triggers the __keyUpHandler on the native <button>
246+
if (e.srcElement && e.srcElement !== this) {
247+
return;
248+
}
249+
// dispatch click
228250
this.click();
229251
}
230252
}
231253

232-
// eslint-disable-next-line class-methods-use-this
233-
__isKeyboardClickEvent(e) {
234-
return e.keyCode === 32 /* space */ || e.keyCode === 13 /* enter */;
235-
}
236-
237254
static __isIE11() {
238255
const ua = window.navigator.userAgent;
239256
const result = /Trident/.test(ua);

packages/button/stories/index.stories.js

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,56 @@ storiesOf('Buttons|Button', module)
3333
`,
3434
)
3535
.add(
36-
'Within a form',
36+
'Within a native form',
3737
() => html`
38-
<form @submit=${() => console.log('native form submitted')}>
39-
<input name="foo" label="Foo" .modelValue=${'bar'} />
40-
<input name="foo2" label="Foo2" .modelValue=${'bar'} />
41-
<lion-button
42-
type="submit"
43-
@click=${() => console.log(document.querySelector('#form').serializeGroup())}
44-
>Submit</lion-button
45-
>
38+
<form
39+
@submit=${ev => {
40+
ev.preventDefault();
41+
console.log('submit handler');
42+
}}
43+
>
44+
<label>First name</label>
45+
<input name="firstName" />
46+
<label>Last name</label>
47+
<input name="lastName" />
48+
<lion-button @click=${() => console.log('click handler')}>Submit</lion-button>
4649
</form>
50+
<p>
51+
Supports the following use cases:
52+
</p>
53+
<ul>
54+
<li>
55+
Submit on button click
56+
</li>
57+
<li>
58+
Submit on button enter or space keypress
59+
</li>
60+
<li>
61+
Submit on enter keypress inside an input
62+
</li>
63+
</ul>
64+
<p>Important notes:</p>
65+
<ul>
66+
<li>
67+
A (lion)-button of type submit is mandatory for the last use case, if you have multiple
68+
inputs. This is native behavior.
69+
</li>
70+
<li>
71+
<span style="background-color: azure">
72+
<code>@click</code> on <code>lion-button</code>
73+
</span>
74+
and
75+
<span style="background-color: seashell">
76+
<code>@submit</code> on <code>form</code>
77+
</span>
78+
are triggered by these use cases. We strongly encourage you to listen to the submit
79+
handler if your goal is to do something on form-submit
80+
</li>
81+
<li>
82+
To prevent form submission full page reloads, add a <b>submit handler on the form</b>
83+
<code>@submit</code> with <code>event.preventDefault()</code>. Adding it on the
84+
<code>lion-button</code> is not enough.
85+
</li>
86+
</ul>
4787
`,
4888
);

0 commit comments

Comments
 (0)