Skip to content

Commit

Permalink
use floating-ui for menu-item submenus
Browse files Browse the repository at this point in the history
  • Loading branch information
radium-v committed Oct 25, 2022
1 parent 016cbf8 commit c27ce9d
Show file tree
Hide file tree
Showing 9 changed files with 151 additions and 112 deletions.
22 changes: 12 additions & 10 deletions packages/web-components/fast-foundation/docs/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -1349,16 +1349,15 @@ export class FASTMenuItem extends FASTElement {
checked: boolean;
// (undocumented)
protected checkedChanged(oldValue: boolean, newValue: boolean): void;
cleanup: () => void;
// @internal (undocumented)
connectedCallback(): void;
// @internal
currentDirection: Direction;
disabled: boolean;
// @internal (undocumented)
disconnectedCallback(): void;
expanded: boolean;
// (undocumented)
protected expandedChanged(oldValue: boolean): void;
protected expandedChanged(prev: boolean | undefined, next: boolean): void;
// @internal (undocumented)
handleMenuItemClick: (e: MouseEvent) => boolean;
// @internal (undocumented)
Expand All @@ -1368,17 +1367,22 @@ export class FASTMenuItem extends FASTElement {
// @internal (undocumented)
handleMouseOver: (e: MouseEvent) => boolean;
// @internal (undocumented)
hasSubmenu: boolean;
get hasSubmenu(): boolean;
hidden: boolean;
role: MenuItemRole;
// @internal
slottedSubmenu: HTMLElement[];
// @internal
protected slottedSubmenuChanged(prev: HTMLElement[] | undefined, next: HTMLElement[]): void;
// @deprecated (undocumented)
startColumnCount: MenuItemColumnCount;
// @internal (undocumented)
submenu: Element | undefined;
submenu: HTMLElement | undefined;
// @internal
submenuContainer: HTMLDivElement;
// @internal (undocumented)
submenuLoaded: () => void;
// @internal
submenuRegion: FASTAnchoredRegion;
updateSubmenu(): void;
}

// @internal
Expand Down Expand Up @@ -2387,7 +2391,6 @@ export type MenuItemOptions = StartEndOptions & {
checkboxIndicator?: string | SyntheticViewTemplate;
expandCollapseGlyph?: string | SyntheticViewTemplate;
radioIndicator?: string | SyntheticViewTemplate;
anchoredRegion: TemplateElementDependency;
};

// @public
Expand All @@ -2401,7 +2404,7 @@ export const MenuItemRole: {
export type MenuItemRole = typeof MenuItemRole[keyof typeof MenuItemRole];

// @public
export function menuItemTemplate<T extends FASTMenuItem>(options: MenuItemOptions): ElementViewTemplate<T>;
export function menuItemTemplate<T extends FASTMenuItem>(options?: MenuItemOptions): ElementViewTemplate<T>;

// @beta
export const MenuPlacement: {
Expand Down Expand Up @@ -2818,7 +2821,6 @@ export type YearFormat = typeof YearFormat[keyof typeof YearFormat];
// dist/dts/calendar/calendar.d.ts:51:5 - (ae-incompatible-release-tags) The symbol "dataGrid" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta
// dist/dts/data-grid/data-grid-row.template.d.ts:9:5 - (ae-incompatible-release-tags) The symbol "dataGridCell" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta
// dist/dts/data-grid/data-grid.template.d.ts:9:5 - (ae-incompatible-release-tags) The symbol "dataGridRow" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta
// dist/dts/menu-item/menu-item.d.ts:21:5 - (ae-incompatible-release-tags) The symbol "anchoredRegion" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta
// dist/dts/picker/picker.template.d.ts:9:5 - (ae-incompatible-release-tags) The symbol "anchoredRegion" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta
// dist/dts/picker/picker.template.d.ts:10:5 - (ae-incompatible-release-tags) The symbol "pickerMenu" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta
// dist/dts/picker/picker.template.d.ts:11:5 - (ae-incompatible-release-tags) The symbol "pickerMenuOption" is marked as @public, but its signature references "TemplateElementDependency" which is marked as @beta
Expand Down
1 change: 1 addition & 0 deletions packages/web-components/fast-foundation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"wait-on": "^6.0.1"
},
"dependencies": {
"@floating-ui/dom": "^1.0.3",
"@microsoft/fast-element": "^2.0.0-beta.14",
"@microsoft/fast-web-utilities": "^6.0.0",
"tabbable": "^5.2.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { ElementViewTemplate, html, ref, when } from "@microsoft/fast-element";
import { endSlotTemplate, startSlotTemplate, tagFor } from "../patterns/index.js";
import { MenuItemRole } from "./menu-item.js";
import {
elements,
ElementViewTemplate,
html,
ref,
slotted,
when,
} from "@microsoft/fast-element";
import { endSlotTemplate, startSlotTemplate } from "../patterns/index.js";
import type { FASTMenuItem, MenuItemOptions } from "./menu-item.js";
import { MenuItemRole } from "./menu-item.options.js";

/**
* Generates a template for the {@link @microsoft/fast-foundation#(FASTMenuItem:class)} component using
Expand All @@ -10,9 +17,8 @@ import type { FASTMenuItem, MenuItemOptions } from "./menu-item.js";
* @public
*/
export function menuItemTemplate<T extends FASTMenuItem>(
options: MenuItemOptions
options: MenuItemOptions = {}
): ElementViewTemplate<T> {
const anchoredRegionTag = tagFor(options.anchoredRegion);
return html<T>`
<template
aria-haspopup="${x => (x.hasSubmenu ? "menu" : void 0)}"
Expand Down Expand Up @@ -69,26 +75,17 @@ export function menuItemTemplate<T extends FASTMenuItem>(
</div>
`
)}
${when(
x => x.expanded,
html<T>`
<${anchoredRegionTag}
:anchorElement="${x => x}"
vertical-positioning-mode="dynamic"
vertical-default-position="bottom"
vertical-inset="true"
horizontal-positioning-mode="dynamic"
horizontal-default-position="end"
class="submenu-region"
dir="${x => x.currentDirection}"
@loaded="${x => x.submenuLoaded()}"
${ref("submenuRegion")}
part="submenu-region"
>
<slot name="submenu"></slot>
</${anchoredRegionTag}>
`
)}
<span
?hidden="${x => !x.expanded}"
class="submenu-container"
part="submenu-container"
${ref("submenuContainer")}
>
<slot name="submenu" ${slotted({
property: "slottedSubmenu",
filter: elements("[role='menu']"),
})}></slot>
</span>
</template>
`;
}
136 changes: 79 additions & 57 deletions packages/web-components/fast-foundation/src/menu-item/menu-item.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Placement } from "@floating-ui/dom";
import { autoUpdate, computePosition, flip, shift, size } from "@floating-ui/dom";
import {
attr,
FASTElement,
Expand All @@ -6,22 +8,15 @@ import {
Updates,
} from "@microsoft/fast-element";
import {
Direction,
keyArrowLeft,
keyArrowRight,
keyEnter,
keyEscape,
keySpace,
} from "@microsoft/fast-web-utilities";
import type { FASTAnchoredRegion } from "../anchored-region/anchored-region.js";
import type { FASTMenu } from "../menu/menu.js";
import {
StartEnd,
StartEndOptions,
TemplateElementDependency,
} from "../patterns/index.js";
import type { StartEndOptions } from "../patterns/start-end.js";
import { StartEnd } from "../patterns/start-end.js";
import { applyMixins } from "../utilities/apply-mixins.js";
import { getDirection } from "../utilities/direction.js";
import { MenuItemRole, roleForMenuItem } from "./menu-item.options.js";

export { MenuItemRole, roleForMenuItem };
Expand All @@ -41,7 +36,6 @@ export type MenuItemOptions = StartEndOptions & {
checkboxIndicator?: string | SyntheticViewTemplate;
expandCollapseGlyph?: string | SyntheticViewTemplate;
radioIndicator?: string | SyntheticViewTemplate;
anchoredRegion: TemplateElementDependency;
};

/**
Expand Down Expand Up @@ -87,16 +81,12 @@ export class FASTMenuItem extends FASTElement {
*/
@attr({ mode: "boolean" })
public expanded: boolean;
protected expandedChanged(oldValue: boolean): void {
protected expandedChanged(prev: boolean | undefined, next: boolean): void {
if (this.$fastController.isConnected) {
if (this.submenu === undefined) {
return;
}
if (this.expanded === false) {
(this.submenu as FASTMenu).collapseExpandedItem();
} else {
this.currentDirection = getDirection(this);
if (next && this.submenu) {
this.updateSubmenu();
}

this.$emit("expanded-change", this, { bubbles: false });
}
}
Expand All @@ -118,6 +108,13 @@ export class FASTMenuItem extends FASTElement {
@attr
public role: MenuItemRole = MenuItemRole.menuitem;

/**
* Cleanup function for the submenu positioner.
*
* @public
*/
public cleanup: () => void;

/**
* The checked value of the element.
*
Expand All @@ -144,63 +141,67 @@ export class FASTMenuItem extends FASTElement {
public hidden: boolean;

/**
* reference to the anchored region
* The submenu slotted content.
*
* @internal
*/
@observable
public submenuRegion: FASTAnchoredRegion;
public slottedSubmenu: HTMLElement[];

/**
* @internal
*/
@observable
public hasSubmenu: boolean = false;
public get hasSubmenu(): boolean {
return !!this.submenu;
}

/**
* Track current direction to pass to the anchored region
* Sets the submenu and updates its position.
*
* @internal
*/
@observable
public currentDirection: Direction = Direction.ltr;
protected slottedSubmenuChanged(
prev: HTMLElement[] | undefined,
next: HTMLElement[]
) {
if (next.length) {
this.submenu = next[0];
this.updateSubmenu();
}
}

/**
* The container for the submenu.
*
* @internal
*/
public submenuContainer: HTMLDivElement;

/**
* @internal
*/
@observable
public submenu: Element | undefined;
public submenu: HTMLElement | undefined;

private focusSubmenuOnLoad: boolean = false;

private observer: MutationObserver | undefined;

/**
* @internal
*/
public connectedCallback(): void {
super.connectedCallback();
Updates.enqueue(() => {
this.updateSubmenu();
});

if (!this.startColumnCount) {
this.startColumnCount = 1;
}

this.observer = new MutationObserver(this.updateSubmenu);
}

/**
* @internal
*/
public disconnectedCallback(): void {
this.cleanup?.();
super.disconnectedCallback();
this.submenu = undefined;
if (this.observer !== undefined) {
this.observer.disconnect();
this.observer = undefined;
}
}

/**
Expand Down Expand Up @@ -261,8 +262,8 @@ export class FASTMenuItem extends FASTElement {
return;
}
this.focusSubmenuOnLoad = false;
if (this.hasSubmenu) {
(this.submenu as HTMLElement).focus();
if (this.submenu) {
this.submenu.focus();
this.setAttribute("tabindex", "-1");
}
};
Expand Down Expand Up @@ -327,13 +328,12 @@ export class FASTMenuItem extends FASTElement {
break;

case MenuItemRole.menuitem:
// update submenu
this.updateSubmenu();
if (this.hasSubmenu) {
this.expandAndFocus();
} else {
this.$emit("change");
break;
}

this.$emit("change");
break;

case MenuItemRole.menuitemradio:
Expand All @@ -345,23 +345,45 @@ export class FASTMenuItem extends FASTElement {
};

/**
* Gets the submenu element if any
* Calculate and apply submenu positioning.
*
* @internal
* @public
*/
private updateSubmenu = (): void => {
this.submenu = this.domChildren().find((element: Element) => {
return element.getAttribute("role") === "menu";
});
public updateSubmenu() {
this.cleanup?.();

this.hasSubmenu = this.submenu === undefined ? false : true;
};
if (!this.submenu || !this.expanded) {
return;
}

/**
* get an array of valid DOM children
*/
private domChildren(): Element[] {
return Array.from(this.children).filter(child => !child.hasAttribute("hidden"));
Updates.enqueue(() => {
this.cleanup = autoUpdate(this, this.submenuContainer, async () => {
const fallbackPlacements: Placement[] = ["left-start", "right-start"];
const { x, y } = await computePosition(this, this.submenuContainer, {
middleware: [
shift(),
size({
apply: ({ availableWidth, rects }) => {
if (availableWidth < rects.floating.width) {
fallbackPlacements.push("bottom-end", "top-end");
}
},
}),
flip({ fallbackPlacements }),
],
placement: "right-start",
strategy: "fixed",
});

Object.assign(this.submenuContainer.style, {
left: `${x}px`,
position: "fixed",
top: `${y}px`,
});

this.submenuLoaded();
});
});
}
}

Expand Down
Loading

0 comments on commit c27ce9d

Please sign in to comment.