diff --git a/.changeset/silly-shirts-promise.md b/.changeset/silly-shirts-promise.md
new file mode 100644
index 000000000..9cb5fb8da
--- /dev/null
+++ b/.changeset/silly-shirts-promise.md
@@ -0,0 +1,5 @@
+---
+'@lion/ui': patch
+---
+
+[FocusMixin] now syncs autofocus between host and the focusable node.
diff --git a/packages/ui/components/dialog/test/lion-dialog.test.js b/packages/ui/components/dialog/test/lion-dialog.test.js
index 389eaf62d..97b81a22d 100644
--- a/packages/ui/components/dialog/test/lion-dialog.test.js
+++ b/packages/ui/components/dialog/test/lion-dialog.test.js
@@ -1,3 +1,4 @@
+/* eslint-disable lit-a11y/no-autofocus */
import { expect, fixture as _fixture, html, unsafeStatic } from '@open-wc/testing';
import { runOverlayMixinSuite } from '../../overlays/test-suites/OverlayMixin.suite.js';
import '@lion/ui/define/lion-dialog.js';
@@ -72,4 +73,59 @@ describe('lion-dialog', () => {
expect(nestedDialogEl.opened).to.be.true;
});
});
+
+ describe('focus', () => {
+ it('sets focus on contentSlot by default', async () => {
+ const el = await fixture(html`
+
+
+
+
+
+
+
+ `);
+ // @ts-expect-error [allow-protected-in-tests]
+ const invokerNode = el._overlayInvokerNode;
+ invokerNode.focus();
+ invokerNode.click();
+ const contentNode = el.querySelector('[slot="content"]');
+ expect(document.activeElement).to.equal(contentNode);
+ });
+
+ it('sets focus on autofocused element', async () => {
+ const el = await fixture(html`
+
+
+
+
+
+
+
+ `);
+ // @ts-expect-error [allow-protected-in-tests]
+ const invokerNode = el._overlayInvokerNode;
+ invokerNode.focus();
+ invokerNode.click();
+ const input = el.querySelector('input');
+ expect(document.activeElement).to.equal(input);
+ });
+
+ it('with trapsKeyboardFocus set to false the focus stays on the invoker', async () => {
+ const el = /** @type {LionDialog} */ await fixture(html`
+
+
+
+
+
+
+
+ `);
+ // @ts-expect-error [allow-protected-in-tests]
+ const invokerNode = el._overlayInvokerNode;
+ invokerNode.focus();
+ invokerNode.click();
+ expect(document.activeElement).to.equal(invokerNode);
+ });
+ });
});
diff --git a/packages/ui/components/form-core/src/FocusMixin.js b/packages/ui/components/form-core/src/FocusMixin.js
index 7490e54ec..a1102cdaf 100644
--- a/packages/ui/components/form-core/src/FocusMixin.js
+++ b/packages/ui/components/form-core/src/FocusMixin.js
@@ -27,6 +27,7 @@ const FocusMixinImplementation = superclass =>
return {
focused: { type: Boolean, reflect: true },
focusedVisible: { type: Boolean, reflect: true, attribute: 'focused-visible' },
+ autofocus: { type: Boolean, reflect: true }, // Required in Lit to observe autofocus
};
}
@@ -47,11 +48,13 @@ const FocusMixinImplementation = superclass =>
* @type {boolean}
*/
this.focusedVisible = false;
+ this.autofocus = false;
}
connectedCallback() {
super.connectedCallback();
this.__registerEventsForFocusMixin();
+ this.__syncAutofocusToFocusableElement();
}
disconnectedCallback() {
@@ -59,6 +62,31 @@ const FocusMixinImplementation = superclass =>
this.__teardownEventsForFocusMixin();
}
+ /**
+ * @param {import('lit').PropertyValues } changedProperties
+ */
+ updated(changedProperties) {
+ super.updated(changedProperties);
+ if (changedProperties.has('autofocus')) {
+ this.__syncAutofocusToFocusableElement();
+ }
+ }
+
+ /**
+ * @private
+ */
+ __syncAutofocusToFocusableElement() {
+ if (!this._focusableNode) {
+ return;
+ }
+
+ if (this.hasAttribute('autofocus')) {
+ this._focusableNode.setAttribute('autofocus', '');
+ } else {
+ this._focusableNode.removeAttribute('autofocus');
+ }
+ }
+
/**
* Calls `focus()` on focusable element within
*/
diff --git a/packages/ui/components/form-core/test/FocusMixin.test.js b/packages/ui/components/form-core/test/FocusMixin.test.js
index c5c5edb01..3638e55f8 100644
--- a/packages/ui/components/form-core/test/FocusMixin.test.js
+++ b/packages/ui/components/form-core/test/FocusMixin.test.js
@@ -323,6 +323,39 @@ describe('FocusMixin', () => {
spy4.restore();
restoreMock4();
});
+
+ it('has mirrors syncs autofocus on the focusable element when autofocus changes', async () => {
+ const el = /** @type {Focusable} */ (
+ await fixture(html`
+ <${tag}>${tag}>
+ `)
+ );
+
+ // @ts-ignore [allow-protected] in test
+ const { _focusableNode } = el;
+
+ el.setAttribute('autofocus', '');
+ await el.updateComplete;
+ expect(_focusableNode.hasAttribute('autofocus')).to.be.true;
+
+ el.removeAttribute('autofocus');
+ await el.updateComplete;
+ expect(_focusableNode.hasAttribute('autofocus')).not.to.be.true;
+ });
+
+ it('has mirrors syncs autofocus on the focusable element when autofocus was set on render', async () => {
+ const el = /** @type {Focusable} */ (
+ await fixture(html`
+ <${tag} autofocus>${tag}>
+ `)
+ );
+
+ // @ts-ignore [allow-protected] in test
+ const { _focusableNode } = el;
+
+ expect(el.hasAttribute('autofocus')).to.be.true;
+ expect(_focusableNode.hasAttribute('autofocus')).to.be.true;
+ });
});
});
});
diff --git a/packages/ui/components/form-integrations/test/dialog-integrations.test.js b/packages/ui/components/form-integrations/test/dialog-integrations.test.js
index a2be44e07..393da46d6 100644
--- a/packages/ui/components/form-integrations/test/dialog-integrations.test.js
+++ b/packages/ui/components/form-integrations/test/dialog-integrations.test.js
@@ -1,3 +1,4 @@
+/* eslint-disable lit-a11y/no-autofocus */
import { expect, fixture } from '@open-wc/testing';
import { html } from 'lit';
import { getAllTagNames } from './helpers/helpers.js';
@@ -70,4 +71,21 @@ describe('Form inside dialog Integrations', () => {
'lion-textarea',
]);
});
+
+ it('sets focus on first focusable element with autofocus', async () => {
+ const el = /** @type {LionDialog} */ await fixture(html`
+
+
+
+
+
+
+
+ `);
+ // @ts-expect-error [allow-protected-in-tests]
+ el._overlayInvokerNode.click();
+ const lionInput = el.querySelector('[name="input"]');
+ // @ts-expect-error [allow-protected-in-tests]
+ expect(document.activeElement).to.equal(lionInput._focusableNode);
+ });
});