Skip to content

Commit

Permalink
menu with floating UI lib
Browse files Browse the repository at this point in the history
  • Loading branch information
panaC committed Sep 26, 2023
1 parent 4d5e7da commit 257d68d
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 107 deletions.
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@
}
},
"dependencies": {
"@floating-ui/dom": "^1.5.3",
"@xmldom/xmldom": "^0.8.10",
"classnames": "^2.3.2",
"commonmark": "^0.30.0",
Expand Down
126 changes: 19 additions & 107 deletions src/renderer/common/components/menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@
// ==LICENSE-END==

import * as React from "react";
import * as ReactDOM from "react-dom";
import { v4 as uuidv4 } from "uuid";

import AccessibleMenu from "./AccessibleMenu";
import { v4 as uuidv4 } from "uuid";
import { FocusContext } from "readium-desktop/renderer/common/focus";
import MenuContent from "./MenuContent";

// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface IBaseProps {
button: React.ReactElement;
dir: string; // Direction of menu: right or left
dir: string; // Direction of menu: right or left // FIXME unused ?
}

// IProps may typically extend:
Expand All @@ -27,18 +26,13 @@ interface IProps extends React.PropsWithChildren<IBaseProps> {
}

interface IState {
contentStyle: React.CSSProperties;
menuOpen: boolean;
}

class Menu extends React.Component<IProps, IState> {

private backFocusMenuButtonRef: React.RefObject<HTMLButtonElement>;
private accessibleMenuContentRef: React.RefObject<HTMLDivElement>;
private menuId: string;
private appElement: HTMLElement;
private appOverlayElement: HTMLElement;
private rootElement: HTMLElement;

declare context: React.ContextType<typeof FocusContext>;
static contextType = FocusContext;
Expand All @@ -47,134 +41,52 @@ class Menu extends React.Component<IProps, IState> {
super(props);

this.backFocusMenuButtonRef = React.createRef<HTMLButtonElement>();
this.accessibleMenuContentRef = React.createRef<HTMLDivElement>();

this.state = {
contentStyle: {},
menuOpen: false,
};
this.menuId = "menu-" + uuidv4();
this.doBackFocusMenuButton = this.doBackFocusMenuButton.bind(this);
this.toggleOpenMenu = this.toggleOpenMenu.bind(this);

this.appElement = document.getElementById("app");
this.appOverlayElement = document.getElementById("app-overlay");
this.rootElement = document.createElement("div");
}

public componentDidMount() {
this.appElement.setAttribute("aria-hidden", "true");
this.appOverlayElement.appendChild(this.rootElement);
}

public componentWillUnmount() {
this.appElement.setAttribute("aria-hidden", "false");
this.appOverlayElement.removeChild(this.rootElement);

this.context.clearFocusRef(this.backFocusMenuButtonRef);
}

public componentDidUpdate(_oldProps: IProps, oldState: IState) {
public async componentDidUpdate(_oldProps: IProps, oldState: IState) {

if (oldState.menuOpen === false && this.state.menuOpen === true) {
this.accessibleMenuContentRef?.current?.querySelector("button")?.focus(); // focus first button
this.context.setFocusRef(this.backFocusMenuButtonRef);
this.refreshStyle();
}
}

public render(): React.ReactElement<{}> {

let MenuContentRendered = <></>;
if (this.state.menuOpen) {
MenuContentRendered = <>
<MenuContent
id={this.menuId}
closeMenu={() => this.setState({ menuOpen: false })}
menuButtonRef={this.backFocusMenuButtonRef}
>
{this.props.children}
</MenuContent>
</>;
}

return (
<>
<button
aria-expanded={this.state.menuOpen}
aria-controls={this.menuId}
onClick={this.toggleOpenMenu}
onClick={() => this.setState({menuOpen: true})}
ref={this.backFocusMenuButtonRef}
>
{this.props.button}
</button>
{
this.state.menuOpen ?
ReactDOM.createPortal(
(
<AccessibleMenu
doBackFocusMenuButton={this.doBackFocusMenuButton}
visible={this.state.menuOpen}
toggleMenu={this.toggleOpenMenu}
>
<div
style={this.state.contentStyle}
id={this.menuId}
aria-hidden={!this.state.menuOpen}
role="menu"
aria-expanded={this.state.menuOpen}
ref={this.accessibleMenuContentRef}
>
<span onClick={() => setTimeout(this.toggleOpenMenu, 1)}>
{this.props.children}
</span>
</div>
</AccessibleMenu>
), this.rootElement)
: <></>
}
{MenuContentRendered}
</>
);
}

private toggleOpenMenu() {
this.setState({ menuOpen: !this.state.menuOpen });
}

private offset(el: HTMLElement) {
const rect = el.getBoundingClientRect();
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const right = window.innerWidth - (rect.right + 19) - scrollLeft;
return {
top: rect.top + scrollTop,
left: rect.left + scrollLeft,
right,
};
}

private refreshStyle() {
if (!this.backFocusMenuButtonRef?.current || !this.accessibleMenuContentRef) {
return;
}
const contentStyle: React.CSSProperties = {
position: "absolute",
};

// calculate vertical position of the menu
const button = this.backFocusMenuButtonRef.current;
const buttonRect = button.getBoundingClientRect();
const bottomPos = window.innerHeight - buttonRect.bottom;
const contentElement = ReactDOM.findDOMNode(this.accessibleMenuContentRef.current) as HTMLElement;
const contentHeight = contentElement.getBoundingClientRect().height;

if (bottomPos < contentHeight) {
contentStyle.top = Math.round(this.offset(button).top - contentHeight) + "px";
} else {
contentStyle.top = Math.round(this.offset(button).top + buttonRect.height) + "px";
}

if (this.props.dir === "right") {
contentStyle.right = Math.round(this.offset(button).right) + "px";
} else {
contentStyle.left = Math.round(this.offset(button).left) + "px";
}

this.setState({ contentStyle });
}

private doBackFocusMenuButton() {
if (this.backFocusMenuButtonRef?.current) {
this.backFocusMenuButtonRef.current.focus();
}
}
}

export default (Menu);
121 changes: 121 additions & 0 deletions src/renderer/common/components/menu/MenuContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// ==LICENSE-BEGIN==
// Copyright 2017 European Digital Reading Lab. All rights reserved.
// Licensed to the Readium Foundation under one or more contributor license agreements.
// Use of this source code is governed by a BSD-style license
// that can be found in the LICENSE file exposed on Github (readium) in the project repository.
// ==LICENSE-END==

import * as React from "react";
import * as ReactDOM from "react-dom";

import AccessibleMenu from "./AccessibleMenu";
import { autoUpdate, computePosition, flip } from "@floating-ui/dom";

// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface IBaseProps {
id: string;
closeMenu: () => void,
menuButtonRef: React.RefObject<HTMLButtonElement>;
}

// IProps may typically extend:
// RouteComponentProps
// ReturnType<typeof mapStateToProps>
// ReturnType<typeof mapDispatchToProps>
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface IProps extends IBaseProps {
}

interface IState {
menuStyle: React.CSSProperties;
}

export default class MenuContent extends React.Component<IProps, IState> {
private appElement: HTMLElement;
private appOverlayElement: HTMLElement;
private rootElement: HTMLElement;
private accessibleMenuContentRef: React.RefObject<HTMLDivElement>;
private cleanupFloatingUITracker: () => void;

constructor(props: IProps) {
super(props);

this.state = {
menuStyle: {},
};
this.appElement = document.getElementById("app");
this.appOverlayElement = document.getElementById("app-overlay");
this.rootElement = document.createElement("div");

this.accessibleMenuContentRef = React.createRef<HTMLDivElement>();
this.loadContentStyles = this.loadContentStyles.bind(this);
this.cleanupFloatingUITracker = () => {};
}

public componentDidMount() {
this.appElement.setAttribute("aria-hidden", "true");
this.appOverlayElement.appendChild(this.rootElement);

setTimeout(() => this.accessibleMenuContentRef?.current?.querySelector("button")?.focus(), 1);
this.cleanupFloatingUITracker = this.loadContentStyles();
}

public componentWillUnmount() {
this.appElement.setAttribute("aria-hidden", "false");
this.appOverlayElement.removeChild(this.rootElement);

this.cleanupFloatingUITracker();
}

public render() {

console.log("RENDER");
return ReactDOM.createPortal(
(
<AccessibleMenu
doBackFocusMenuButton={() => this.props.menuButtonRef.current?.focus()}
visible={true}
toggleMenu={this.props.closeMenu}
>
<div
style={this.state.menuStyle}
id={this.props.id}
aria-hidden={false}
role="menu"
aria-expanded={true}
ref={this.accessibleMenuContentRef}
>
{this.props.children}
</div>
</AccessibleMenu>
),
this.rootElement,
);
}

private loadContentStyles() {

const cleanup = autoUpdate(
this.props.menuButtonRef.current,
this.accessibleMenuContentRef.current,
() => computePosition(this.props.menuButtonRef.current, this.accessibleMenuContentRef.current, {
placement: "bottom",
middleware: [flip()],
})
.then(({ x, y }) => {
this.setState({
menuStyle: {
position: "absolute",
left: `${x}px`,
top: `${y}px`,
},
});
})
.catch((e) => {
// nothing
console.error("floatingUI err", e);
}),
);
return cleanup;
}
}

0 comments on commit 257d68d

Please sign in to comment.