diff --git a/packages/button/package.json b/packages/button/package.json index 54cdd0de6..e258b9b9e 100644 --- a/packages/button/package.json +++ b/packages/button/package.json @@ -1,7 +1,7 @@ { "name": "@lion/button", "version": "0.1.48", - "description": "A button that is easily stylable and accessible in all contexts", + "description": "A button that is easily styleable and accessible in all contexts", "author": "ing-bank", "homepage": "https://github.com/ing-bank/lion/", "license": "MIT", diff --git a/packages/button/src/LionButton.js b/packages/button/src/LionButton.js index 196b48008..c7be8621c 100644 --- a/packages/button/src/LionButton.js +++ b/packages/button/src/LionButton.js @@ -1,34 +1,40 @@ -import { css, html, DelegateMixin, SlotMixin } from '@lion/core'; +import { css, html, DelegateMixin, SlotMixin, DisabledWithTabIndexMixin } from '@lion/core'; import { LionLitElement } from '@lion/core/src/LionLitElement.js'; -export class LionButton extends DelegateMixin(SlotMixin(LionLitElement)) { +export class LionButton extends DisabledWithTabIndexMixin( + DelegateMixin(SlotMixin(LionLitElement)), +) { static get properties() { return { - disabled: { - type: Boolean, - reflect: true, - }, role: { type: String, reflect: true, }, - tabindex: { - type: Number, - reflect: true, - }, }; } render() { return html`
+ ${this._renderBefore()} + ${this._renderAfter()}
`; } + // eslint-disable-next-line class-methods-use-this + _renderBefore() { + return html``; + } + + // eslint-disable-next-line class-methods-use-this + _renderAfter() { + return html``; + } + static get styles() { return [ css` @@ -36,22 +42,20 @@ export class LionButton extends DelegateMixin(SlotMixin(LionLitElement)) { 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/ */ + min-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; + min-height: 24px; display: flex; align-items: center; position: relative; - border: 1px solid black; - border-radius: 8px; - background: whitesmoke; - color: black; + background: #eee; /* minimal styling to make it recognizable as btn */ padding: 7px 15px; + outline: none; /* focus style handled below, else it follows boundaries of click-area */ } :host .btn ::slotted(button) { @@ -69,27 +73,20 @@ export class LionButton extends DelegateMixin(SlotMixin(LionLitElement)) { padding: 0; } - :host(:focus) { - outline: none; - } - :host(:focus) .btn { - border-color: lightblue; - box-shadow: 0 0 8px lightblue, 0 0 0 1px lightblue; + /* if you extend, please overwrite */ + outline: 2px solid #bde4ff; } :host(:hover) .btn { - background: black; - color: whitesmoke; - } - - :host(:hover) .btn ::slotted(lion-icon) { - fill: whitesmoke; + /* if you extend, please overwrite */ + background: #f4f6f7; } :host(:active) .btn, .btn[active] { - background: grey; + /* if you extend, please overwrite */ + background: gray; } :host([disabled]) { @@ -97,22 +94,15 @@ export class LionButton extends DelegateMixin(SlotMixin(LionLitElement)) { } :host([disabled]) .btn { + /* if you extend, please overwrite */ background: lightgray; - color: gray; - fill: gray; - border-color: gray; + color: #adadad; + fill: #adadad; } `, ]; } - _requestUpdate(name, oldValue) { - super._requestUpdate(name, oldValue); - if (name === 'disabled') { - this.__onDisabledChanged(oldValue); - } - } - get delegations() { return { ...super.delegations, @@ -137,9 +127,7 @@ export class LionButton extends DelegateMixin(SlotMixin(LionLitElement)) { constructor() { super(); - this.disabled = false; this.role = 'button'; - this.tabindex = 0; } connectedCallback() { @@ -203,13 +191,4 @@ export class LionButton extends DelegateMixin(SlotMixin(LionLitElement)) { this.shadowRoot.querySelector('.click-area').click(); } } - - __onDisabledChanged() { - if (this.disabled) { - this.__originalTabIndex = this.tabindex; - this.tabindex = -1; - } else { - this.tabindex = this.__originalTabIndex; - } - } } diff --git a/packages/core/index.js b/packages/core/index.js index 599274df4..8a418776c 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -51,3 +51,5 @@ export { DelegateMixin } from './src/DelegateMixin.js'; export { DomHelpersMixin } from './src/DomHelpersMixin.js'; export { LionSingleton } from './src/LionSingleton.js'; export { SlotMixin } from './src/SlotMixin.js'; +export { DisabledMixin } from './src/DisabledMixin.js'; +export { DisabledWithTabIndexMixin } from './src/DisabledWithTabIndexMixin.js'; diff --git a/packages/core/src/DisabledMixin.js b/packages/core/src/DisabledMixin.js new file mode 100644 index 000000000..b279695b3 --- /dev/null +++ b/packages/core/src/DisabledMixin.js @@ -0,0 +1,64 @@ +import { dedupeMixin } from './dedupeMixin.js'; + +/** + * #DisabledMixin + * + * @polymerMixin + * @mixinFunction + */ +export const DisabledMixin = dedupeMixin( + superclass => + // eslint-disable-next-line no-shadow + class DisabledMixin extends superclass { + static get properties() { + return { + disabled: { + type: Boolean, + reflect: true, + }, + }; + } + + constructor() { + super(); + this.__requestedToBeDisabled = false; + this.__isUserSettingDisabled = true; + + this.__restoreDisabledTo = false; + this.disabled = false; + } + + makeRequestToBeDisabled() { + if (this.__requestedToBeDisabled === false) { + this.__requestedToBeDisabled = true; + this.__restoreDisabledTo = this.disabled; + this.__internalSetDisabled(true); + } + } + + retractRequestToBeDisabled() { + if (this.__requestedToBeDisabled === true) { + this.__requestedToBeDisabled = false; + this.__internalSetDisabled(this.__restoreDisabledTo); + } + } + + __internalSetDisabled(value) { + this.__isUserSettingDisabled = false; + this.disabled = value; + this.__isUserSettingDisabled = true; + } + + _requestUpdate(name, oldValue) { + super._requestUpdate(name, oldValue); + if (name === 'disabled') { + if (this.__isUserSettingDisabled) { + this.__restoreDisabledTo = this.disabled; + } + if (this.disabled === false && this.__requestedToBeDisabled === true) { + this.__internalSetDisabled(true); + } + } + } + }, +); diff --git a/packages/core/src/DisabledWithTabIndexMixin.js b/packages/core/src/DisabledWithTabIndexMixin.js new file mode 100644 index 000000000..ac9189fab --- /dev/null +++ b/packages/core/src/DisabledWithTabIndexMixin.js @@ -0,0 +1,86 @@ +import { dedupeMixin } from './dedupeMixin.js'; +import { DisabledMixin } from './DisabledMixin.js'; + +/** + * #DisabledWithTabIndexMixin + * + * @polymerMixin + * @mixinFunction + */ +export const DisabledWithTabIndexMixin = dedupeMixin( + superclass => + // eslint-disable-next-line no-shadow + class DisabledWithTabIndexMixin extends DisabledMixin(superclass) { + static get properties() { + return { + // we use a property here as if we use the native tabIndex we can not set a default value + // in the constructor as it synchronously sets the attribute which is not allowed in the + // constructor phase + tabIndex: { + type: Number, + reflect: true, + attribute: 'tabindex', + }, + }; + } + + constructor() { + super(); + this.__isUserSettingTabIndex = true; + + this.__restoreTabIndexTo = 0; + this.__internalSetTabIndex(0); + } + + makeRequestToBeDisabled() { + super.makeRequestToBeDisabled(); + if (this.__requestedToBeDisabled === false) { + this.__restoreTabIndexTo = this.tabIndex; + } + } + + retractRequestToBeDisabled() { + super.retractRequestToBeDisabled(); + if (this.__requestedToBeDisabled === true) { + this.__internalSetTabIndex(this.__restoreTabIndexTo); + } + } + + __internalSetTabIndex(value) { + this.__isUserSettingTabIndex = false; + this.tabIndex = value; + this.__isUserSettingTabIndex = true; + } + + _requestUpdate(name, oldValue) { + super._requestUpdate(name, oldValue); + + if (name === 'disabled') { + if (this.disabled) { + this.__internalSetTabIndex(-1); + } else { + this.__internalSetTabIndex(this.__restoreTabIndexTo); + } + } + + if (name === 'tabIndex') { + if (this.__isUserSettingTabIndex) { + this.__restoreTabIndexTo = this.tabIndex; + } + + if (this.tabIndex !== -1 && this.__requestedToBeDisabled === true) { + this.__internalSetTabIndex(-1); + } + } + } + + firstUpdated(changedProperties) { + super.firstUpdated(changedProperties); + // for ShadyDom the timing is a little different and we need to make sure + // the tabindex gets correctly updated here + if (this.disabled) { + this.__internalSetTabIndex(-1); + } + } + }, +); diff --git a/packages/core/test/DisabledMixin.test.js b/packages/core/test/DisabledMixin.test.js new file mode 100644 index 000000000..cbd4389d5 --- /dev/null +++ b/packages/core/test/DisabledMixin.test.js @@ -0,0 +1,86 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +import { LitElement } from '../index.js'; +import { DisabledMixin } from '../src/DisabledMixin.js'; + +describe('DisabledMixin', () => { + before(() => { + class CanBeDisabled extends DisabledMixin(LitElement) {} + customElements.define('can-be-disabled', CanBeDisabled); + }); + + it('reflects disabled to attribute', async () => { + const el = await fixture(html` + + `); + expect(el.hasAttribute('disabled')).to.be.false; + el.disabled = true; + await el.updateComplete; + expect(el.hasAttribute('disabled')).to.be.true; + }); + + it('can be requested to be disabled', async () => { + const el = await fixture(html` + + `); + el.makeRequestToBeDisabled(); + expect(el.disabled).to.be.true; + await el.updateComplete; + expect(el.hasAttribute('disabled')).to.be.true; + }); + + it('will not allow to become enabled after makeRequestToBeDisabled()', async () => { + const el = await fixture(html` + + `); + el.makeRequestToBeDisabled(); + expect(el.disabled).to.be.true; + + el.disabled = false; + expect(el.disabled).to.be.true; + }); + + it('will stay disabled after retractRequestToBeDisabled() if it was disabled before', async () => { + const el = await fixture(html` + + `); + el.makeRequestToBeDisabled(); + el.retractRequestToBeDisabled(); + expect(el.disabled).to.be.true; + }); + + it('will become enabled after retractRequestToBeDisabled() if it was enabled before', async () => { + const el = await fixture(html` + + `); + el.makeRequestToBeDisabled(); + expect(el.disabled).to.be.true; + el.retractRequestToBeDisabled(); + expect(el.disabled).to.be.false; + }); + + it('may allow multiple calls to makeRequestToBeDisabled()', async () => { + const el = await fixture(html` + + `); + el.makeRequestToBeDisabled(); + el.makeRequestToBeDisabled(); + el.retractRequestToBeDisabled(); + expect(el.disabled).to.be.false; + }); + + it('will restore last state after retractRequestToBeDisabled()', async () => { + const el = await fixture(html` + + `); + el.makeRequestToBeDisabled(); + el.disabled = true; + el.retractRequestToBeDisabled(); + expect(el.disabled).to.be.true; + + el.makeRequestToBeDisabled(); + el.disabled = false; + el.retractRequestToBeDisabled(); + expect(el.disabled).to.be.false; + }); +}); diff --git a/packages/core/test/DisabledWithTabIndexMixin.test.js b/packages/core/test/DisabledWithTabIndexMixin.test.js new file mode 100644 index 000000000..3a037f8bc --- /dev/null +++ b/packages/core/test/DisabledWithTabIndexMixin.test.js @@ -0,0 +1,107 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +import { LitElement } from '../index.js'; +import { DisabledWithTabIndexMixin } from '../src/DisabledWithTabIndexMixin.js'; + +describe('DisabledWithTabIndexMixin', () => { + before(() => { + class WithTabIndex extends DisabledWithTabIndexMixin(LitElement) {} + customElements.define('can-be-disabled-with-tab-index', WithTabIndex); + }); + + it('has an initial tabIndex of 0', async () => { + const el = await fixture(html` + + `); + expect(el.tabIndex).to.equal(0); + expect(el.getAttribute('tabindex')).to.equal('0'); + }); + + it('sets tabIndex to -1 if disabled', async () => { + const el = await fixture(html` + + `); + el.disabled = true; + expect(el.tabIndex).to.equal(-1); + await el.updateComplete; + expect(el.getAttribute('tabindex')).to.equal('-1'); + }); + + it('disabled does not override user provided tabindex', async () => { + const el = await fixture(html` + + `); + expect(el.getAttribute('tabindex')).to.equal('-1'); + el.disabled = false; + await el.updateComplete; + expect(el.getAttribute('tabindex')).to.equal('5'); + }); + + it('can be disabled imperatively', async () => { + const el = await fixture(html` + + `); + expect(el.getAttribute('tabindex')).to.equal('-1'); + + el.disabled = false; + await el.updateComplete; + expect(el.getAttribute('tabindex')).to.equal('0'); + expect(el.hasAttribute('disabled')).to.equal(false); + + el.disabled = true; + await el.updateComplete; + expect(el.getAttribute('tabindex')).to.equal('-1'); + expect(el.hasAttribute('disabled')).to.equal(true); + }); + + it('will not allow to change tabIndex after makeRequestToBeDisabled()', async () => { + const el = await fixture(html` + + `); + el.makeRequestToBeDisabled(); + + el.tabIndex = 5; + expect(el.tabIndex).to.equal(-1); + await el.updateComplete; + expect(el.getAttribute('tabindex')).to.equal('-1'); + }); + + it('will restore last tabIndex after retractRequestToBeDisabled()', async () => { + const el = await fixture(html` + + `); + el.makeRequestToBeDisabled(); + expect(el.tabIndex).to.equal(-1); + await el.updateComplete; + expect(el.getAttribute('tabindex')).to.equal('-1'); + el.retractRequestToBeDisabled(); + expect(el.tabIndex).to.equal(5); + await el.updateComplete; + expect(el.getAttribute('tabindex')).to.equal('5'); + + el.makeRequestToBeDisabled(); + el.tabIndex = 12; + el.retractRequestToBeDisabled(); + expect(el.tabIndex).to.equal(12); + await el.updateComplete; + expect(el.getAttribute('tabindex')).to.equal('12'); + + el.makeRequestToBeDisabled(); + el.tabIndex = 13; + el.tabIndex = 14; + el.retractRequestToBeDisabled(); + expect(el.tabIndex).to.equal(14); + await el.updateComplete; + expect(el.getAttribute('tabindex')).to.equal('14'); + }); + + it('may allow multiple calls to retractRequestToBeDisabled', async () => { + const el = await fixture(html` + + `); + el.retractRequestToBeDisabled(); + el.retractRequestToBeDisabled(); + expect(el.disabled).to.be.true; + expect(el.tabIndex).to.be.equal(-1); + }); +});