Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
Comment thread
marchbox marked this conversation as resolved.
"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"
}
1 change: 0 additions & 1 deletion packages/web-components/docs/web-components.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/web-components/src/button/button.base.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -248,6 +249,7 @@ export class BaseButton extends FASTElement {
super.connectedCallback();
this.elementInternals.ariaDisabled = `${!!this.disabledFocusable}`;
this.setTabIndex();
maybeSetAutoFocus(this);
}

constructor() {
Expand Down
2 changes: 2 additions & 0 deletions packages/web-components/src/checkbox/checkbox.base.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -348,6 +349,7 @@ export class BaseCheckbox extends FASTElement {
this.disabled = !!this.disabledAttribute;
this.setAriaChecked();
this.setValidity();
maybeSetAutoFocus(this);
}

/**
Expand Down
6 changes: 4 additions & 2 deletions packages/web-components/src/combobox/combobox.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ export default {
parameters: {
docs: {
description: {
component: dedent`hello
world
component: dedent`
The Combobox component is a variant of the <a href="/docs/components-dropdown--docs">Dropdown</a> component.
To use a combobox, use <code>&lt;fluent-dropdown type="combobox"&gt;</code>.
`,
},
},
Expand Down
5 changes: 4 additions & 1 deletion packages/web-components/src/dropdown/dropdown.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -858,7 +859,7 @@ export class BaseDropdown extends FASTElement {
private searchTimeout?: ReturnType<typeof setTimeout>;

/**
* 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.
*
Expand Down Expand Up @@ -1107,6 +1108,8 @@ export class BaseDropdown extends FASTElement {
Updates.enqueue(() => {
this.insertControl();
});

maybeSetAutoFocus(this);
}

disconnectedCallback(): void {
Expand Down
3 changes: 3 additions & 0 deletions packages/web-components/src/menu-item/menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/web-components/src/slider/slider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -552,6 +553,8 @@ export class Slider extends FASTElement implements SliderConfiguration {
notifier.subscribe(this, 'min');
notifier.subscribe(this, 'step');
});

maybeSetAutoFocus(this);
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/web-components/src/tab/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,6 +66,8 @@ export class Tab extends FASTElement {
`;

this.$fastController.addStyles(this.styles);

maybeSetAutoFocus(this);
}

private setDisabledSideEffect(disabled: boolean) {
Expand Down
27 changes: 3 additions & 24 deletions packages/web-components/src/text-input/text-input.base.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down
6 changes: 0 additions & 6 deletions packages/web-components/src/text-input/text-input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ import type { TextInputOptions } from './text-input.options.js';
*/
export function textInputTemplate<T extends TextInput>(options: TextInputOptions = {}): ElementViewTemplate<T> {
return html<T>`
<template
@focusin="${(x, c) => x.focusinHandler(c.event as FocusEvent)}"
@keydown="${(x, c) => x.keydownHandler(c.event as KeyboardEvent)}"
>
<template @keydown="${(x, c) => x.keydownHandler(c.event as KeyboardEvent)}">
<label part="label" for="control" class="label" ${ref('controlLabel')}>
<slot ${slotted('defaultSlottedNodes')}></slot>
</label>
Expand Down
2 changes: 2 additions & 0 deletions packages/web-components/src/textarea/textarea.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -443,6 +444,7 @@ export class BaseTextArea extends FASTElement {
this.preConnectControlEl = null;

this.maybeCreateAutoSizerEl();
maybeSetAutoFocus(this);
});
}

Expand Down
3 changes: 3 additions & 0 deletions packages/web-components/src/tree-item/tree-item.base.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -46,6 +47,8 @@ export class BaseTreeItem extends FASTElement {
if (isTreeItem(this.parentElement)) {
this.slot ||= 'item';
}

maybeSetAutoFocus(this);
}

/**
Expand Down
27 changes: 27 additions & 0 deletions packages/web-components/src/utils/autofocus.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
}
Loading