Skip to content

Commit

Permalink
✨ [bento-mega-menu] Implemented bento mega menu (ampproject#38197)
Browse files Browse the repository at this point in the history
* feature(bento-mega-menu): initial output from `make-extension`

* feature(bento-mega-menu): implemented menu toggling

* feature(bento-mega-menu): split up MegaMenu into multiple React components

* feature(bento-mega-menu): improved styling of menu

* feature(bento-mega-menu): added Aria attributes to open modal

* feature(bento-mega-menu): ensure mask blocks background

* feature(bento-mega-menu): added bento test

* feature(bento-mega-menu): added DOM mappings

* feature(bento-mega-menu): improved code, eliminated complex ref, by using `useEvent`

* feature(bento-mega-menu): implemented a shim-based approach to bento-mode, similar to accordion

* feature(bento-mega-menu): use `single:false` to pass children with multiple slots

* feature(bento-mega-menu): implemented bento API by using a shim wrapper

* feature(bento-mega-menu): added memo to Shim

* feature(bento-mega-menu): extracted Shim

* feature(bento-mega-menu): updated bento-mode CSS

* feature(bento-mega-menu): use mutation observer to update state based on `expanded` props

* feature(bento-mega-menu): ignore setting classes in the lightDOM

* feature(bento-mega-menu): improved demo fixture

* feature(bento-mega-menu): improved types

* feature(bento-mega-menu): improved Shim types

* feature(bento-mega-menu): removed dead code

* feature(bento-mega-menu): removed dead code

* feature(bento-mega-menu): removed dead code

* feature(bento-mega-menu): moved useMutationObserver to common location

* feature(bento-mega-menu): improved useMutationObserver docs

* feature(bento-mega-menu): improved Shim docs

* feature(bento-mega-menu): moved useClickOutside to common

* feature(bento-mega-menu): not sure if the world is ready for `createProviderFromHook`

* feature(bento-mega-menu): added simple storybook

* feature(bento-mega-menu): added test coverage

* feature(bento-mega-menu): fixed broken import

* feature(bento-mega-menu): export an explicit API

* feature(bento-mega-menu): improved bento test coverage

* feature(bento-mega-menu): added detailed story

* feature(bento-mega-menu): don't rename `children` as `slot`

* feature(bento-mega-menu): updated jsdoc types to use TypeScript types

* feature(bento-mega-menu): lint fix

* feature(bento-mega-menu): added `memo` implementation

* feature(bento-mega-menu): lazy-initialize the MutationObserver

* feature(bento-mega-menu): removed unnecessary `type === 'attributes'` check

* feature(bento-mega-menu): deep-compare `config` to ensure mutation observer doesn't disconnect

* feature(bento-mega-menu): added unit tests for `memo` since it wasn't included in regular tests

* feature(bento-mega-menu): added simpler implementation of `objectsEqualDeep`

* feature(bento-mega-menu): added tests for `objectsEqualDeep`

* feature(bento-mega-menu): disable codecov for preact code

* feature(bento-mega-menu): improved types for objectsEqualDeep

* feature(bento-mega-menu): generated new zindex

Co-authored-by: scottrippey <[email protected]>
  • Loading branch information
scottrippey and scottrippey authored May 23, 2022
1 parent df93c34 commit ed0c674
Show file tree
Hide file tree
Showing 32 changed files with 1,784 additions and 3 deletions.
9 changes: 9 additions & 0 deletions build-system/compile/bundles.config.bento.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@
"npm": true
}
},
{
"name": "bento-mega-menu",
"version": "1.0",
"latestVersion": "0.1",
"options": {
"hasCss": true,
"npm": true
}
},
{
"name": "bento-mustache",
"version": "1.0",
Expand Down
3 changes: 3 additions & 0 deletions css/Z_INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
| `amp-mega-menu` | 1000 | [extensions/amp-mega-menu/0.1/amp-mega-menu.css](/extensions/amp-mega-menu/0.1/amp-mega-menu.css) |
| `amp-user-notification` | 1000 | [extensions/amp-user-notification/0.1/amp-user-notification.css](/extensions/amp-user-notification/0.1/amp-user-notification.css) |
| `wrapper` | 1000 | [src/bento/components/bento-lightbox/1.0/component.jss.js](/src/bento/components/bento-lightbox/1.0/component.jss.js) |
| `bento-mega-menu` | 1000 | [src/bento/components/bento-mega-menu/1.0/bento-mega-menu.css](/src/bento/components/bento-mega-menu/1.0/bento-mega-menu.css) |
| `mainNav` | 1000 | [src/bento/components/bento-mega-menu/1.0/component.jss.js](/src/bento/components/bento-mega-menu/1.0/component.jss.js) |
| `mask` | 999 | [src/bento/components/bento-mega-menu/1.0/component.jss.js](/src/bento/components/bento-mega-menu/1.0/component.jss.js) |
| `i-amphtml-app-banner-top-padding` | 15 | [extensions/amp-app-banner/0.1/amp-app-banner.css](/extensions/amp-app-banner/0.1/amp-app-banner.css) |
| `bannerPadding` | 15 | [extensions/amp-app-banner/1.0/component.jss.js](/extensions/amp-app-banner/1.0/component.jss.js) |
| `.amp-app-banner-dismiss-button` | 14 | [extensions/amp-app-banner/0.1/amp-app-banner.css](/extensions/amp-app-banner/0.1/amp-app-banner.css) |
Expand Down
30 changes: 30 additions & 0 deletions src/bento/components/bento-mega-menu/1.0/base-element.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {PreactBaseElement} from '#preact/base-element';

import {BentoMegaMenu} from './component';
import {CSS as COMPONENT_CSS} from './component.jss';
import {BentoItem} from './component/BentoItem';

export class BaseElement extends PreactBaseElement {
/** @override */
init() {
super.init();
return {ItemWrapper: BentoItem};
}
}

/** @override */
BaseElement['Component'] = BentoMegaMenu;

/** @override */
BaseElement['props'] = {
'children': {
selector: '*',
single: false,
},
};

/** @override */
BaseElement['usesShadowDom'] = true;

/** @override */
BaseElement['shadowCss'] = COMPONENT_CSS;
42 changes: 42 additions & 0 deletions src/bento/components/bento-mega-menu/1.0/bento-mega-menu.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Pre-upgrade:
* - display:block element
* - contain:layout element
*/
bento-mega-menu {
background: white;
position: relative;
z-index: 1000;

/* Before built, display the contents correctly: */
display: flex;
flex-wrap: wrap;
gap: 0.5em;
}

bento-mega-menu.i-amphtml-built {
display: block;
}

bento-mega-menu > section > :first-child {
/* Title */
cursor: pointer;
}
bento-mega-menu > section > :last-child {
/* Content */
position: absolute;
left: 0;
width: 100%;
background: white;
opacity: 0;
visibility: hidden;
transition: opacity 200ms, visibility 0ms 200ms;
transition-timing-function: ease-in;
}
bento-mega-menu > section[expanded] > :last-child {
/* Content (expanded) */
opacity: 1;
visibility: visible;
transition-delay: 0ms;
transition-timing-function: ease-out;
}
1 change: 1 addition & 0 deletions src/bento/components/bento-mega-menu/1.0/component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './component/BentoMegaMenu';
73 changes: 73 additions & 0 deletions src/bento/components/bento-mega-menu/1.0/component.jss.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {createUseStyles} from 'react-jss';

const mainNav = {
whiteSpace: 'nowrap',
background: 'white',
position: 'relative',
zIndex: 1000,

'& > ul': {
display: 'flex',
listStyleType: 'none',
padding: 0,
margin: 0,
flexWrap: 'wrap',

gap: '0.5em',
},
};

const title = {
cursor: 'pointer',
};
const content = {
position: 'absolute',
left: '0',
width: '100vw',
background: 'white',
opacity: 0,
visibility: 'hidden',
transition: 'opacity 200ms, visibility 0s 200ms',
transitionTimingFunction: 'ease-in',

'&.open': {
opacity: 1,
visibility: 'visible',
transitionDelay: 0,
transitionTimingFunction: 'ease-out',
},
};

const mask = {
position: 'fixed',
zIndex: 999,
visibility: 'hidden',
opacity: 0,

top: 0,
bottom: 0,
left: 0,
right: 0,

background: 'black',
transition: 'opacity 200ms, visibility 0s 200ms',
transitionTimingFunction: 'ease-in',

'&.open': {
opacity: 0.5,
visibility: 'visible',
transitionDelay: 0,
transitionTimingFunction: 'ease-out',
},
};

const JSS = {
mainNav,
title,
content,
mask,
};

// useStyles gets replaced for AMP builds via `babel-plugin-transform-jss`.
// eslint-disable-next-line local/no-export-side-effect
export const useStyles = createUseStyles(JSS);
10 changes: 10 additions & 0 deletions src/bento/components/bento-mega-menu/1.0/component.type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @externs */

/** @const */
var BentoMegaMenuDef = {};

/** @type {BentoMegaMenuProps} */
BentoMegaMenuDef.Props;

/** @type {BentoMegaMenuApi} */
BentoMegaMenuDef.BentoMegaMenuApi = class {};
63 changes: 63 additions & 0 deletions src/bento/components/bento-mega-menu/1.0/component/BentoItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {Shim} from '#bento/components/bento-mega-menu/1.0/component/Shim';

import * as Preact from '#preact';
import {useLayoutEffect, useRef} from '#preact';
import {useAttributeObserver} from '#preact/hooks/useMutationObserver';
import {FC} from '#preact/types';

import {Content} from './Content';
import {Item, useMegaMenuItem} from './Item';
import {Title} from './Title';

export const BentoItem: FC = ({children}) => {
return (
<Item>
<SlottedDomWrapper>{children}</SlottedDomWrapper>
</Item>
);
};

/**
* Renders the slot, and uses a bunch of shims to control the slotted (light DOM) elements
*/
const SlottedDomWrapper: FC = ({children}) => {
const {actions, isOpen} = useMegaMenuItem();

const ref = useRef<HTMLDivElement>(null);

// Capture all the Light DOM elements:
const sectionRef = useRef<HTMLElement>(null);
const titleRef = useRef<HTMLElement>(null);
const contentsRef = useRef<HTMLElement>(null);
useLayoutEffect(() => {
const slot = ref.current!.querySelector('slot')!;
const section = slot.assignedElements()[0];
const {firstElementChild: header, lastElementChild: contents} = section;
sectionRef.current = section as HTMLElement;
contentsRef.current = contents as HTMLElement;
titleRef.current = header as HTMLElement;
}, []);

// Watch the section's 'expanded' attribute:
useAttributeObserver(sectionRef, 'expanded', (attrValue) => {
const expanded = attrValue !== null;
const shouldToggle = (expanded && !isOpen) || (!expanded && isOpen);
if (shouldToggle) {
actions.toggle();
}
});

const sectionAttributes = {
expanded: isOpen,
};
// Render the slot, Title, and Content elements.
// Render these using Shims, to control the Light DOM elements
return (
<div ref={ref}>
{children}
<Shim elementRef={sectionRef} {...sectionAttributes} />
<Title as={Shim} elementRef={titleRef} />
<Content as={Shim} elementRef={contentsRef} />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import objStr from 'obj-str';

import * as Preact from '#preact';
import {useImperativeHandle, useRef} from '#preact';
import {Children, forwardRef} from '#preact/compat';
import {ContainWrapper} from '#preact/component';
import {useClickOutside} from '#preact/hooks/useClickOutside';
import type {ComponentChildren, ComponentType, Ref} from '#preact/types';

import {Content} from './Content';
import {Item} from './Item';
import {Title} from './Title';
import {MegaMenuContext, useMegaMenu} from './useMegaMenu';

import {useStyles} from '../component.jss';

export type BentoMegaMenuProps = {
children?: ComponentChildren;
/**
* Used for Bento web-component mode
*/
ItemWrapper?: ComponentType;
};

export type BentoMegaMenuApi = ReturnType<typeof useMegaMenu>;

/**
* @return {PreactDef.Renderable}
*/
function BentoMegaMenuWithRef(
{ItemWrapper, children, ...rest}: BentoMegaMenuProps,
ref: Ref<BentoMegaMenuApi>
) {
const megaMenu = useMegaMenu();

const isAnyOpen = megaMenu.openId !== null;

const navRef = useRef<HTMLDivElement>(null);
useClickOutside(navRef, (ev: MouseEvent) => {
if (megaMenu.isAnyOpen) {
megaMenu.actions.closeMenu();

ev.preventDefault();
ev.stopPropagation();
}
});

useImperativeHandle(ref, () => megaMenu, [megaMenu]);
const classes = useStyles();
return (
<ContainWrapper {...rest}>
<MegaMenuContext.Provider value={megaMenu}>
<nav class={classes.mainNav} ref={navRef}>
<ul>
{Children.map(children, (child, index) => {
if (ItemWrapper) {
child = <ItemWrapper>{child}</ItemWrapper>;
}
return <li key={index}>{child}</li>;
})}
</ul>
</nav>
<div
class={objStr({
[classes.mask]: true,
'open': isAnyOpen,
})}
aria-hidden
/>
</MegaMenuContext.Provider>
</ContainWrapper>
);
}

const BentoMegaMenuFwd = forwardRef(BentoMegaMenuWithRef);
const BentoMegaMenu = Object.assign(BentoMegaMenuFwd, {
Title,
Content,
Item,
});
export {BentoMegaMenu};
55 changes: 55 additions & 0 deletions src/bento/components/bento-mega-menu/1.0/component/Content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import objStr from 'obj-str';

import * as Preact from '#preact';
import {useLayoutEffect} from '#preact';
import {ComponentChildren} from '#preact/types';
import {propName} from '#preact/utils';

import {useMegaMenuItem} from './Item';
import {AriaAttributes, AsComponent, AsProps} from './types';

import {useStyles} from '../component.jss';

type ContentProps<TAs extends AsComponent> = AsProps<TAs> & {
children?: ComponentChildren;
class?: string;
id?: string;
};

export function Content<TAs extends AsComponent = 'div'>({
as: As = 'div',
children,
id: idProp,
[propName('class')]: className,
...rest
}: ContentProps<TAs>) {
const classes = useStyles();
const {actions, isOpen, itemId} = useMegaMenuItem();

// If this ID is set, pass it to the parent:
useLayoutEffect(() => {
if (idProp) {
actions.overrideItemId(idProp);
}
}, [actions, idProp]);

const ariaAttrs: AriaAttributes = {
'aria-modal': isOpen,
};

return (
<As
role="dialog"
id={itemId}
{...ariaAttrs}
{...rest}
class={objStr({
[className!]: !!className,
[classes.content]: true,
['open']: isOpen,
})}
>
{children}
</As>
);
}
Loading

0 comments on commit ed0c674

Please sign in to comment.