Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "adopt focusgroup and polyfill",
"packageName": "@fluentui/web-components",
"email": "machi@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "fix components for native focusgroup implementation",
"packageName": "@fluentui/web-components",
"email": "machi@microsoft.com",
"dependentChangeType": "patch"
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"@microsoft/api-extractor": "7.51.0",
"@microsoft/api-extractor-model": "7.31.2",
"@microsoft/eslint-plugin-sdl": "1.0.1",
"@microsoft/focusgroup-polyfill": "^1.2.1",
"@microsoft/focusgroup-polyfill": "^1.3.0",
"@microsoft/load-themed-styles": "1.10.26",
"@microsoft/loader-load-themed-styles": "2.0.17",
"@microsoft/tsdoc": "0.15.1",
Expand Down
59 changes: 36 additions & 23 deletions packages/web-components/docs/web-components.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -739,19 +739,17 @@ export class BaseMenuList extends FASTElement {
elementInternals: ElementInternals;
focus(): void;
handleChange(source: any, propertyName: string): void;
// @internal
handleFocusOut: (e: FocusEvent) => void;
// @internal (undocumented)
handleMenuKeyDown(e: KeyboardEvent): void | boolean;
protected isMenuItemElement: (el: Element) => el is HTMLElement;
protected isMenuItemElement(el: Element): el is MenuItem;
// @internal (undocumented)
readonly isNestedMenu: () => boolean;
// @internal (undocumented)
items: HTMLElement[];
// (undocumented)
protected itemsChanged(oldValue: HTMLElement[], newValue: HTMLElement[]): void;
// (undocumented)
protected menuItems: Element[] | undefined;
protected menuChildren: HTMLElement[] | undefined;
// (undocumented)
protected menuItems: MenuItem[] | undefined;
// (undocumented)
protected setItems(): void;
}
Expand Down Expand Up @@ -808,8 +806,6 @@ export class BaseRadioGroup extends FASTElement {
focus(): void;
// @internal
focusinHandler(e: FocusEvent): boolean | void;
// @internal
focusoutHandler(e: FocusEvent): boolean | void;
static formAssociated: boolean;
// (undocumented)
formResetCallback(): void;
Expand Down Expand Up @@ -893,22 +889,26 @@ export class BaseTablist extends FASTElement {
// @internal (undocumented)
protected activeidChanged(oldValue: string, newValue: string): void;
activetab: Tab;
adjust(adjustment: number): void;
// @internal (undocumented)
connectedCallback(): void;
disabled: boolean;
// @internal
// @internal (undocumented)
protected disabledChanged(prev: boolean, next: boolean): void;
// @internal
elementInternals: ElementInternals;
orientation: TablistOrientation;
// @internal (undocumented)
handleFocusIn(event: FocusEvent): void;
orientation: TablistOrientation;
// (undocumented)
protected orientationChanged(prev: TablistOrientation, next: TablistOrientation): void;
protected setTabs(): void;
protected setTabs({ connectToPanel, forceDisabled }?: {
connectToPanel?: boolean | undefined;
forceDisabled?: boolean | undefined;
}): void;
// @internal
slottedTabs: Node[];
// @internal
slottedTabsChanged(prev: Node[] | undefined, next: Node[] | undefined): void;
// @internal (undocumented)
protected slottedTabsChanged(prev: Node[] | undefined, next: Node[] | undefined): void;
// @internal (undocumented)
tabs: Tab[];
// @internal (undocumented)
Expand Down Expand Up @@ -1076,8 +1076,6 @@ export class BaseTextInput extends FASTElement {
export class BaseTree extends FASTElement {
constructor();
// @internal
blurHandler(e: FocusEvent): void;
// @internal
changeHandler(e: Event): boolean | void;
// Warning: (ae-forgotten-export) The symbol "BaseTreeItem" needs to be exported by the entry point index.d.ts
//
Expand All @@ -1087,17 +1085,14 @@ export class BaseTree extends FASTElement {
childTreeItemsChanged(): void;
// @internal
clickHandler(e: Event): boolean | void;
// (undocumented)
connectedCallback(): void;
currentSelected: HTMLElement | null;
// @internal (undocumented)
defaultSlot: HTMLSlotElement;
// @internal
defaultSlotChanged(): void;
protected get descendantTreeItems(): BaseTreeItem[];
// @internal
elementInternals: ElementInternals;
// @internal
focusHandler(e: FocusEvent): void;
// @internal (undocumented)
handleDefaultSlotChange(): void;
// @internal
Expand Down Expand Up @@ -3287,6 +3282,10 @@ export class MenuItem extends FASTElement {
handleMouseOut: (e: MouseEvent) => boolean;
// @internal (undocumented)
handleMouseOver: (e: MouseEvent) => boolean;
// @internal (undocumented)
handleSubmenuFocusOut: (e: FocusEvent) => void;
// @internal
handleToggle: (e: Event) => void;
hidden: boolean;
role: MenuItemRole;
roleChanged(prev: MenuItemRole | undefined, next: MenuItemRole | undefined): void;
Expand All @@ -3298,8 +3297,6 @@ export class MenuItem extends FASTElement {
protected slottedSubmenuChanged(prev: HTMLElement[] | undefined, next: HTMLElement[]): void;
// @internal (undocumented)
submenu: HTMLElement | undefined;
// @internal
toggleHandler: (e: Event) => void;
}

// @internal
Expand Down Expand Up @@ -3340,6 +3337,10 @@ export const MenuItemTemplate: ElementViewTemplate<MenuItem>;

// @public
export class MenuList extends BaseMenuList {
// (undocumented)
disconnectedCallback(): void;
// (undocumented)
setItems(): void;
}

// @public (undocumented)
Expand Down Expand Up @@ -3486,6 +3487,10 @@ export const RadioDefinition: FASTElementDefinition<typeof Radio>;

// @public
export class RadioGroup extends BaseRadioGroup {
// (undocumented)
disconnectedCallback(): void;
// (undocumented)
radiosChanged(prev: Radio[] | undefined, next: Radio[] | undefined): void;
}

// @public
Expand Down Expand Up @@ -3955,6 +3960,8 @@ export class Tab extends FASTElement {
// (undocumented)
connectedCallback(): void;
disabled: boolean;
// (undocumented)
protected disabledChanged(prev: boolean, next: boolean): void;
// @internal
elementInternals: ElementInternals;
}
Expand All @@ -3970,9 +3977,11 @@ export const TabDefinition: FASTElementDefinition<typeof Tab>;

// @public
export class Tablist extends BaseTablist {
activeidChanged(oldValue: string, newValue: string): void;
appearance?: TablistAppearance;
// (undocumented)
disconnectedCallback(): void;
size?: TablistSize;
// (undocumented)
tabsChanged(prev: Tab[] | undefined, next: Tab[] | undefined): void;
}

Expand Down Expand Up @@ -4369,6 +4378,10 @@ export class Tree extends BaseTree {
protected appearanceChanged(): void;
// @internal
childTreeItemsChanged(): void;
// (undocumented)
disconnectedCallback(): void;
// @internal (undocumented)
itemToggleHandler(): void;
size: TreeItemSize;
// (undocumented)
protected sizeChanged(): void;
Expand Down
4 changes: 3 additions & 1 deletion packages/web-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"devDependencies": {
"@custom-elements-manifest/analyzer": "0.10.10",
"@microsoft/fast-element": "2.0.0",
"@microsoft/focusgroup-polyfill": "^1.3.0",
"@tensile-perf/web-components": "~0.2.2",
"@storybook/html": "9.1.17",
"@storybook/html-vite": "9.1.17",
Expand All @@ -93,7 +94,8 @@
"tslib": "^2.1.0"
},
"peerDependencies": {
"@microsoft/fast-element": "^2.0.0"
"@microsoft/fast-element": "^2.0.0",
"@microsoft/focusgroup-polyfill": "^1.3.0"
},
"beachball": {
"disallowedChangeTypes": [
Expand Down
14 changes: 14 additions & 0 deletions packages/web-components/src/_docs/developer/polyfilling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,17 @@ if (!CSS.supports('anchor-name: --foo')) {
import { default as applyPolyfill } from '@oddbird/css-anchor-positioning/fn';
window.CSS_ANCHOR_POLYFILL = applyPolyfill;
```

## HTML Focusgroup

For components that require directional navigations (moving focus between focusable elements within a component with arrow keys instead of tab), Fluent Web Components have adopted [HTML Focusgroup](https://open-ui.org/components/scoped-focusgroup.explainer/). The components that currently use focusgroup include MenuList/MenuItem, RadioGroup/Radio, Tablist/Tab, and Tree/TreeItem.

For browsers that don’t yet support focusgroup, we use [`@microsoft/focusgroup-polyfill`](https://github.com/microsoft/polyfills/tree/main/packages/focusgroup) and automatically polyfill the components when they are connected to the DOM.

If you want to opt out of the polyfill, you can use the base classes of these components, e.g.

```js
import { BaseTablist } from '@fluentui/web-components/tablist/base.js';

export class MyTablist extends BaseTablist {}
```
3 changes: 2 additions & 1 deletion packages/web-components/src/menu-item/menu-item.template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ const chevronRight16Filled = html.partial(
export function menuItemTemplate<T extends MenuItem>(options: MenuItemOptions = {}): ElementViewTemplate<T> {
return html<T>`
<template
tabindex="0"
@keydown="${(x, c) => x.handleMenuItemKeyDown(c.event as KeyboardEvent)}"
@click="${(x, c) => x.handleMenuItemClick(c.event as MouseEvent)}"
@mouseover="${(x, c) => x.handleMouseOver(c.event as MouseEvent)}"
@mouseout="${(x, c) => x.handleMouseOut(c.event as MouseEvent)}"
@toggle="${(x, c) => x.toggleHandler(c.event as ToggleEvent)}"
@toggle="${(x, c) => x.handleToggle(c.event as ToggleEvent)}"
>
<slot name="indicator"> ${staticallyCompose(options.indicator)} </slot>
${startSlotTemplate(options)}
Expand Down
30 changes: 23 additions & 7 deletions packages/web-components/src/menu-item/menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,15 @@ export class MenuItem extends FASTElement {
* @internal
*/
protected slottedSubmenuChanged(prev: HTMLElement[] | undefined, next: HTMLElement[]) {
this.submenu?.removeEventListener('toggle', this.toggleHandler);
this.submenu?.removeEventListener('toggle', this.handleToggle);
this.submenu?.removeEventListener('focusout', this.handleSubmenuFocusOut);

if (next.length) {
this.submenu = next[0];
this.submenu.toggleAttribute('popover', true);
this.submenu.addEventListener('toggle', this.toggleHandler);
this.submenu.setAttribute('focusgroup', 'none');
this.submenu.addEventListener('toggle', this.handleToggle);
this.submenu.addEventListener('focusout', this.handleSubmenuFocusOut);
this.elementInternals.ariaHasPopup = 'menu';
toggleState(this.elementInternals, 'submenu', true);
} else {
Expand Down Expand Up @@ -236,16 +239,29 @@ export class MenuItem extends FASTElement {
* Setup required ARIA on open/close
* @internal
*/
public toggleHandler = (e: Event): void => {
if (e instanceof ToggleEvent && e.newState === 'open') {
this.setAttribute('tabindex', '-1');
public handleToggle = (e: Event): void => {
if (!(e instanceof ToggleEvent)) {
return;
}

if (e.newState === 'open') {
this.elementInternals.ariaExpanded = 'true';
this.setSubmenuPosition();
}
if (e instanceof ToggleEvent && e.newState === 'closed') {
if (e.newState === 'closed') {
this.elementInternals.ariaExpanded = 'false';
this.setAttribute('tabindex', '0');
}

this.submenu?.setAttribute('focusgroup', e.newState === 'open' ? 'menu' : 'none');
};

/** @internal */
public handleSubmenuFocusOut = (e: FocusEvent) => {
if (e.relatedTarget && this.submenu?.contains(e.relatedTarget as Node)) {
return;
}

this.submenu?.togglePopover(false);
};

/**
Expand Down
Loading
Loading