diff --git a/packages/core/index.js b/packages/core/index.js
index 06781fe61..8a418776c 100644
--- a/packages/core/index.js
+++ b/packages/core/index.js
@@ -52,3 +52,4 @@ 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/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/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);
+ });
+});