diff --git a/change/@fluentui-web-components-8407ff54-9881-408f-86cd-9bf5bb6f5e83.json b/change/@fluentui-web-components-8407ff54-9881-408f-86cd-9bf5bb6f5e83.json
new file mode 100644
index 0000000000000..dd3587187eea3
--- /dev/null
+++ b/change/@fluentui-web-components-8407ff54-9881-408f-86cd-9bf5bb6f5e83.json
@@ -0,0 +1,7 @@
+{
+ "type": "prerelease",
+ "comment": "fix: make sure autofocus works on all focusable custom elements during initial page load",
+ "packageName": "@fluentui/web-components",
+ "email": "machi@microsoft.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/web-components/docs/web-components.api.md b/packages/web-components/docs/web-components.api.md
index bc2e50a46bb25..fef7fb412a672 100644
--- a/packages/web-components/docs/web-components.api.md
+++ b/packages/web-components/docs/web-components.api.md
@@ -1029,7 +1029,6 @@ export class BaseTextInput extends FASTElement {
disabled?: boolean;
// @internal
elementInternals: ElementInternals;
- focusinHandler(e: FocusEvent): boolean | void;
get form(): HTMLFormElement | null;
static readonly formAssociated = true;
formAttribute?: string;
diff --git a/packages/web-components/src/button/button.base.ts b/packages/web-components/src/button/button.base.ts
index e42c2a858fedc..891e46f5b59c3 100644
--- a/packages/web-components/src/button/button.base.ts
+++ b/packages/web-components/src/button/button.base.ts
@@ -1,4 +1,5 @@
import { attr, FASTElement, observable } from '@microsoft/fast-element';
+import { maybeSetAutoFocus } from '../utils/autofocus.js';
import { type ButtonFormTarget, ButtonType } from './button.options.js';
/**
@@ -248,6 +249,7 @@ export class BaseButton extends FASTElement {
super.connectedCallback();
this.elementInternals.ariaDisabled = `${!!this.disabledFocusable}`;
this.setTabIndex();
+ maybeSetAutoFocus(this);
}
constructor() {
diff --git a/packages/web-components/src/checkbox/checkbox.base.ts b/packages/web-components/src/checkbox/checkbox.base.ts
index 77cbb38abef38..b9660fe1911cd 100644
--- a/packages/web-components/src/checkbox/checkbox.base.ts
+++ b/packages/web-components/src/checkbox/checkbox.base.ts
@@ -1,5 +1,6 @@
import { attr, FASTElement, Observable, observable } from '@microsoft/fast-element';
import { toggleState } from '../utils/element-internals.js';
+import { maybeSetAutoFocus } from '../utils/autofocus.js';
/**
* The base class for a component with a toggleable checked state.
@@ -348,6 +349,7 @@ export class BaseCheckbox extends FASTElement {
this.disabled = !!this.disabledAttribute;
this.setAriaChecked();
this.setValidity();
+ maybeSetAutoFocus(this);
}
/**
diff --git a/packages/web-components/src/combobox/combobox.stories.ts b/packages/web-components/src/combobox/combobox.stories.ts
index e79f9eb5f6d55..a0fba980aa835 100644
--- a/packages/web-components/src/combobox/combobox.stories.ts
+++ b/packages/web-components/src/combobox/combobox.stories.ts
@@ -49,8 +49,10 @@ export default {
parameters: {
docs: {
description: {
- component: dedent`hello
- world
+ component: dedent`
+ The Combobox component is a variant of the Dropdown component.
+
+ To use a combobox, use <fluent-dropdown type="combobox">.
`,
},
},
diff --git a/packages/web-components/src/dropdown/dropdown.base.ts b/packages/web-components/src/dropdown/dropdown.base.ts
index 82f683b366746..a28225207c723 100644
--- a/packages/web-components/src/dropdown/dropdown.base.ts
+++ b/packages/web-components/src/dropdown/dropdown.base.ts
@@ -9,6 +9,7 @@ import { getLanguage } from '../utils/language.js';
import { waitForConnectedDescendants } from '../utils/request-idle-callback.js';
import { AnchorPositioningCSSSupported } from '../utils/support.js';
import { uniqueId } from '../utils/unique-id.js';
+import { maybeSetAutoFocus } from '../utils/autofocus.js';
import { DropdownType } from './dropdown.options.js';
import { dropdownButtonTemplate, dropdownInputTemplate } from './dropdown.template.js';
@@ -858,7 +859,7 @@ export class BaseDropdown extends FASTElement {
private searchTimeout?: ReturnType;
/**
- * Handles printable character input by moving {@link activeIndex} to the next option whose label matches the
+ * Handles printable character input by moving {@link Dropdown#activeIndex} to the next option whose label matches the
* accumulated search string. When the string is a single character (or the same character repeated), matching
* options are cycled through; otherwise the string is treated as a prefix match.
*
@@ -1107,6 +1108,8 @@ export class BaseDropdown extends FASTElement {
Updates.enqueue(() => {
this.insertControl();
});
+
+ maybeSetAutoFocus(this);
}
disconnectedCallback(): void {
diff --git a/packages/web-components/src/menu-item/menu-item.ts b/packages/web-components/src/menu-item/menu-item.ts
index 7bf1b5be923a3..4d10eda2c9ad2 100644
--- a/packages/web-components/src/menu-item/menu-item.ts
+++ b/packages/web-components/src/menu-item/menu-item.ts
@@ -4,6 +4,7 @@ import { StartEnd } from '../patterns/start-end.js';
import { applyMixins } from '../utils/apply-mixins.js';
import { toggleState } from '../utils/element-internals.js';
import type { StaticallyComposableHTML } from '../utils/template-helpers.js';
+import { maybeSetAutoFocus } from '../utils/autofocus.js';
import { MenuItemRole, roleForMenuItem } from './menu-item.options.js';
export type MenuItemColumnCount = 0 | 1 | 2;
@@ -160,6 +161,8 @@ export class MenuItem extends FASTElement {
this.elementInternals.role = this.role ?? MenuItemRole.menuitem;
this.elementInternals.ariaChecked = this.role !== MenuItemRole.menuitem ? `${!!this.checked}` : null;
+
+ maybeSetAutoFocus(this);
}
/**
diff --git a/packages/web-components/src/slider/slider.ts b/packages/web-components/src/slider/slider.ts
index 469d4420eb9b8..40f2f212d7f69 100644
--- a/packages/web-components/src/slider/slider.ts
+++ b/packages/web-components/src/slider/slider.ts
@@ -5,6 +5,7 @@ import { limit } from '../utils/numbers.js';
import { Orientation } from '../utils/orientation.js';
import { numberLikeStringConverter } from '../utils/converters.js';
import { getDirection } from '../utils/direction.js';
+import { maybeSetAutoFocus } from '../utils/autofocus.js';
import { convertPixelToPercent } from './slider-utilities.js';
import { type SliderConfiguration, SliderMode, SliderOrientation, SliderSize } from './slider.options.js';
@@ -552,6 +553,8 @@ export class Slider extends FASTElement implements SliderConfiguration {
notifier.subscribe(this, 'min');
notifier.subscribe(this, 'step');
});
+
+ maybeSetAutoFocus(this);
}
/**
diff --git a/packages/web-components/src/tab/tab.ts b/packages/web-components/src/tab/tab.ts
index 42929cc963bae..25cf4683b7f90 100644
--- a/packages/web-components/src/tab/tab.ts
+++ b/packages/web-components/src/tab/tab.ts
@@ -2,6 +2,7 @@ import { attr, css, type ElementStyles, FASTElement } from '@microsoft/fast-elem
import type { StartEndOptions } from '../patterns/start-end.js';
import { StartEnd } from '../patterns/start-end.js';
import { applyMixins } from '../utils/apply-mixins.js';
+import { maybeSetAutoFocus } from '../utils/autofocus.js';
/**
* Tab configuration options
@@ -65,6 +66,8 @@ export class Tab extends FASTElement {
`;
this.$fastController.addStyles(this.styles);
+
+ maybeSetAutoFocus(this);
}
private setDisabledSideEffect(disabled: boolean) {
diff --git a/packages/web-components/src/text-input/text-input.base.ts b/packages/web-components/src/text-input/text-input.base.ts
index fa1e96095c795..4a2f08be10eb9 100644
--- a/packages/web-components/src/text-input/text-input.base.ts
+++ b/packages/web-components/src/text-input/text-input.base.ts
@@ -1,12 +1,5 @@
-import {
- attr,
- FASTElement,
- nullableNumberConverter,
- Observable,
- observable,
- type Subscriber,
- Updates,
-} from '@microsoft/fast-element';
+import { attr, FASTElement, nullableNumberConverter, Observable, observable, Updates } from '@microsoft/fast-element';
+import { maybeSetAutoFocus } from '../utils/autofocus.js';
import { ImplicitSubmissionBlockingTypes, TextInputType } from './text-input.options.js';
/**
@@ -456,24 +449,10 @@ export class BaseTextInput extends FASTElement {
public connectedCallback(): void {
super.connectedCallback();
- this.tabIndex = Number(this.getAttribute('tabindex') ?? 0) < 0 ? -1 : 0;
-
this.setFormValue(this.value);
this.setValidity();
- }
- /**
- * Focuses the inner control when the component is focused.
- *
- * @param e - the event object
- * @public
- */
- public focusinHandler(e: FocusEvent): boolean | void {
- if (e.target === this) {
- this.control?.focus();
- }
-
- return true;
+ maybeSetAutoFocus(this);
}
/**
diff --git a/packages/web-components/src/text-input/text-input.spec.ts b/packages/web-components/src/text-input/text-input.spec.ts
index 43c131a9e03ff..190f4966a9465 100644
--- a/packages/web-components/src/text-input/text-input.spec.ts
+++ b/packages/web-components/src/text-input/text-input.spec.ts
@@ -29,12 +29,6 @@ test.describe('TextInput', () => {
const attributes: InitialTemplateAttributes = { autofocus: true };
- if (ssr) {
- // the host element needs to be focusable for autofocus to work on the server,
- // so we need to set tabindex="0"
- attributes.tabindex = '0';
- }
-
await fastPage.setTemplate({ attributes });
await expect(element).toBeFocused();
diff --git a/packages/web-components/src/text-input/text-input.template.ts b/packages/web-components/src/text-input/text-input.template.ts
index f19fe9cfe2def..be19023a99857 100644
--- a/packages/web-components/src/text-input/text-input.template.ts
+++ b/packages/web-components/src/text-input/text-input.template.ts
@@ -10,10 +10,7 @@ import type { TextInputOptions } from './text-input.options.js';
*/
export function textInputTemplate(options: TextInputOptions = {}): ElementViewTemplate {
return html`
- x.focusinHandler(c.event as FocusEvent)}"
- @keydown="${(x, c) => x.keydownHandler(c.event as KeyboardEvent)}"
- >
+ x.keydownHandler(c.event as KeyboardEvent)}">
diff --git a/packages/web-components/src/textarea/textarea.base.ts b/packages/web-components/src/textarea/textarea.base.ts
index 11a6fceb394dc..e0cbb42deb7e7 100644
--- a/packages/web-components/src/textarea/textarea.base.ts
+++ b/packages/web-components/src/textarea/textarea.base.ts
@@ -2,6 +2,7 @@ import { attr, FASTElement, nullableNumberConverter, observable, Updates } from
import { whitespaceFilter } from '../utils/whitespace-filter.js';
import type { Label } from '../label/label.js';
import { hasMatchingState, swapStates, toggleState } from '../utils/element-internals.js';
+import { maybeSetAutoFocus } from '../utils/autofocus.js';
import { TextAreaAutocomplete, TextAreaResize } from './textarea.options.js';
/**
@@ -443,6 +444,7 @@ export class BaseTextArea extends FASTElement {
this.preConnectControlEl = null;
this.maybeCreateAutoSizerEl();
+ maybeSetAutoFocus(this);
});
}
diff --git a/packages/web-components/src/tree-item/tree-item.base.ts b/packages/web-components/src/tree-item/tree-item.base.ts
index bfe441b918591..b349a367db055 100644
--- a/packages/web-components/src/tree-item/tree-item.base.ts
+++ b/packages/web-components/src/tree-item/tree-item.base.ts
@@ -1,5 +1,6 @@
import { attr, css, type ElementStyles, FASTElement, observable } from '@microsoft/fast-element';
import { toggleState } from '../utils/element-internals.js';
+import { maybeSetAutoFocus } from '../utils/autofocus.js';
import { isTreeItem } from './tree-item.options.js';
/**
@@ -46,6 +47,8 @@ export class BaseTreeItem extends FASTElement {
if (isTreeItem(this.parentElement)) {
this.slot ||= 'item';
}
+
+ maybeSetAutoFocus(this);
}
/**
diff --git a/packages/web-components/src/utils/autofocus.ts b/packages/web-components/src/utils/autofocus.ts
new file mode 100644
index 0000000000000..69ede1129ec48
--- /dev/null
+++ b/packages/web-components/src/utils/autofocus.ts
@@ -0,0 +1,27 @@
+import { Updates } from '@microsoft/fast-element';
+
+/**
+ * Artificial sets the focus to the given element, if no other element in the
+ * document is currently focused and the given element meets the following
+ * conditions:
+ *
+ * - is connected to DOM
+ * - has `autofocus` attribute
+ * - is visible
+ *
+ * For more details of this issue, see https://codepen.io/editor/marchbox/pen/019e9ab9-cd81-7c21-a3ae-1b7fe2d3458a
+ */
+export function maybeSetAutoFocus(element: HTMLElement) {
+ const doc = element.ownerDocument;
+ if (
+ element?.isConnected &&
+ element?.hasAttribute('autofocus') &&
+ // Note: opacity=0 is considered visible based on the native `autofocus` implementation
+ element?.checkVisibility?.({ contentVisibilityAuto: true, visibilityProperty: true }) &&
+ [null, element, doc.body, doc.documentElement].includes(doc.activeElement as HTMLElement | null)
+ ) {
+ Updates.enqueue(() => {
+ element.focus();
+ });
+ }
+}