diff --git a/.gitignore b/.gitignore index 27b997ae..2bd81678 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ tsconfig.tsbuildinfo /SpaceKitProvider /Table /Tooltip +/Menu* /typography # Generated icons stored in src directory diff --git a/rollup.config.js b/rollup.config.js index 8b8cf567..a38f7942 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -40,6 +40,7 @@ function CJS() { path.join("src", "illustrations", "scripts"), path.join("src", "shared"), path.join("src", "AbstractTooltip"), + path.join("src", "MenuIconSize"), ].some(excludedPathname => filename.includes(excludedPathname)) ), external: [ diff --git a/src/Menu/Menu.story.mdx b/src/Menu/Menu.story.mdx new file mode 100644 index 00000000..e006ea2d --- /dev/null +++ b/src/Menu/Menu.story.mdx @@ -0,0 +1,231 @@ +import { Button } from "../Button"; +import { colors } from "../colors"; +import { Menu } from "../Menu"; +import { MenuItem } from "../MenuItem"; +import { MenuDivider } from "../MenuDivider"; +import { MenuHeading } from "../MenuHeading"; +import { + Description, + Meta, + Story, + Props, + Preview, +} from "@storybook/addon-docs/blocks"; +import { IconAstronaut1 } from "../icons/IconAstronaut1"; +import { IconAstronaut2 } from "../icons/IconAstronaut2"; +import { IconAstronaut3 } from "../icons/IconAstronaut3"; +import { IconLander } from "../icons/IconLander"; +import { IconPlanet3 } from "../icons/IconPlanet3"; +import { IconShip3 } from "../icons/IconShip3"; +import { IconCheck } from "../icons/IconCheck"; + + + +# Menu + + + +## Simple Menu + +The simple menu provides a list of options for a user to choose from and updates the button or select button with the choice the user has selected. + + + + + Root + Objects + Inputs + Interfaces + Unions + Scalars and Enums + + } + forceVisibleForTestingOnly + > + + + + + +## Segmented Menu + +The segmented menu provides a way to group related options. This can be achieved +by a divider and in other cases, headers can be used to group similar +information and provide a nice visual grouping. + + + + + All Types + + Root + Objects + Inputs + Interfaces + Unions + Scalars and Enums + + Deprecations + + } + forceVisibleForTestingOnly + > + + + + + +## Header + + + + + Filter by + Root + Objects + Inputs + Interfaces + Unions + Scalars and Enums + + } + forceVisibleForTestingOnly + > + + + + + +## Icons + + + + + + } + > + Cones + + + } + > + Rhomboid + + + } + > + Cubes + + + } + > + Diamonds + + + } + > + Cylinders + + + } + > + Pyramids + + + } + forceVisibleForTestingOnly + > + + + + + +## Icons with custom sizes + + + + + mdg-private-graphs + apollo-day-nyc + + engine + + galaxy-eu-west + + } + > + galaxy-ties + + space-explorer + z-end-to-end-ingestion-20 + + } + forceVisibleForTestingOnly + > + + + + + +## Props + +### `Menu` + + + +### `MenuItem` + + diff --git a/src/Menu/index.tsx b/src/Menu/index.tsx new file mode 100644 index 00000000..c078ca84 --- /dev/null +++ b/src/Menu/index.tsx @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/no-empty-interface */ +import React from "react"; +import { AbstractTooltip } from "../AbstractTooltip"; +import { TippyMenuStyles } from "./menu/TippyMenuStyles"; +import { + IconSize, + MenuIconSizeProvider, + useMenuIconSize, +} from "../MenuIconSize"; +import { Instance, ReferenceElement } from "tippy.js"; + +interface Props extends React.ComponentProps { + // extends Pick< + // React.ComponentProps, + // "content" | "children" | "trigger" | "maxWidth" + // > { + style?: React.CSSProperties; + + /** + * Optionally set the icon size to use for all `MenuItem` descendents. This + * value will automatically be passed down via context and can be overriden by + * child `Menu` components + * + * Is inherited by antecedent definitions, or defaulted to `normal` if there + * are none + */ + iconSize?: IconSize; +} + +function calculateMaxHeight(instance: Instance) { + const parentHeight = + instance.props.appendTo === "parent" + ? (instance.reference as any).offsetParent.offsetHeight + : document.body.clientHeight; + + const { + height: referenceHeight, + top: referenceTop, + } = instance.reference.getBoundingClientRect(); + + const { + height: arrowHeight, + } = instance.popperChildren.arrow?.getBoundingClientRect() ?? { height: 0 }; + + const { distance } = instance.props; + + return ( + parentHeight - + referenceTop - + referenceHeight - + arrowHeight - + parseFloat(getComputedStyle(instance.popperChildren.tooltip).paddingTop) - + parseFloat( + getComputedStyle(instance.popperChildren.tooltip).paddingBottom + ) - + (typeof distance === "number" ? distance : 0) - + // Margin from bottom of the page + 5 + ); +} + +function isReferenceObject(reference: any): reference is ReferenceElement { + return typeof (reference as ReferenceElement)._tippy !== "undefined"; +} + +export const Menu: React.FC = ({ children, iconSize, ...props }) => { + const inheritedIconSize = useMenuIconSize(); + + return ( + + + { + const reference = data.instance.reference; + if (isReferenceObject(reference) && reference._tippy) { + const tippy = reference._tippy; + const calculatedMaxHeight = calculateMaxHeight(tippy); + tippy.popperChildren.content.style.maxHeight = `${calculatedMaxHeight}px`; + } + return data; + }, + }, + }, + }} + {...props} + interactive + > + {children} + + + ); +}; diff --git a/src/Menu/menu/TippyMenuStyles.tsx b/src/Menu/menu/TippyMenuStyles.tsx new file mode 100644 index 00000000..8679439d --- /dev/null +++ b/src/Menu/menu/TippyMenuStyles.tsx @@ -0,0 +1,29 @@ +import "../../../node_modules/tippy.js/dist/tippy.css"; +import React from "react"; +import { base } from "../../typography"; +import { colors } from "../../colors"; +import { Global, css } from "@emotion/core"; + +export const TippyMenuStyles: React.FC = () => ( + +); diff --git a/src/MenuDivider/index.tsx b/src/MenuDivider/index.tsx new file mode 100644 index 00000000..6a0db194 --- /dev/null +++ b/src/MenuDivider/index.tsx @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/no-empty-interface */ +/** @jsx jsx */ +import React from "react"; +import { css, jsx } from "@emotion/core"; +import { colors } from "../colors"; + +export const MenuDivider: React.FC = () => { + return ( +
+ ); +}; diff --git a/src/MenuHeading/index.tsx b/src/MenuHeading/index.tsx new file mode 100644 index 00000000..f59295ac --- /dev/null +++ b/src/MenuHeading/index.tsx @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-empty-interface */ +/** @jsx jsx */ +import React from "react"; +import { css, jsx } from "@emotion/core"; +import { colors } from "../colors"; +import { MenuItem } from "../MenuItem"; + +interface Props extends React.ComponentProps { + count?: React.ReactNode; +} + +export const MenuHeading = React.forwardRef< + HTMLHeadingElement, + React.PropsWithChildren +>(({ children, count, ...props }, ref) => { + return ( + +

+ {children} + {count && ( + + {count} + + )} +

+
+ ); +}); diff --git a/src/MenuIconSize/index.tsx b/src/MenuIconSize/index.tsx new file mode 100644 index 00000000..87f03213 --- /dev/null +++ b/src/MenuIconSize/index.tsx @@ -0,0 +1,71 @@ +import React from "react"; + +/** + * Enumeration of all icon sizes + * + * This uses string literals instead of a TypeScript union so we can use + * strings, like `"small"`, for props instead of `IconSize.small` + */ +export type IconSize = "small" | "normal"; + +const MenuIconSizeContext = React.createContext( + undefined +); + +interface Props { + /** + * Icon size to use for all descendents + */ + iconSize: IconSize; +} + +/** + * Provider to set `IconSize` on the context + * + * This value is immutable; it's defined when instantiated + */ +export const MenuIconSizeProvider: React.FC = ({ + children, + iconSize, +}) => { + return ( + + {children} + + ); +}; + +interface withMenuIconSizeProps { + /** + * Render prop function. Calls the callback in the shape of `{ iconSize: + * IconSize }` + */ + children: (renderPropValues: { iconSize: IconSize }) => ReturnType; +} + +/** + * Extract `IconSize` from context. + * + * *Avoid this component.* This is intended to be used _only_ in the case where + * you can't use hooks because you're rendering something under another render + * prop component. + */ +export const WithMenuIconSize: React.FC = ({ + children, +}) => ( + + {iconSize => children({ iconSize: iconSize || "normal" })} + +); + +/** + * Extract `IconSize` from context + * + * Uses a reasonable default as we don't require any consumer to use + * `IconSizeProvider` + */ +export function useMenuIconSize(): IconSize { + const iconSize = React.useContext(MenuIconSizeContext); + + return iconSize ?? "normal"; +} diff --git a/src/MenuItem/index.tsx b/src/MenuItem/index.tsx new file mode 100644 index 00000000..677aa335 --- /dev/null +++ b/src/MenuItem/index.tsx @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/no-empty-interface */ +/** @jsx jsx */ +import * as CSS from "csstype"; +import React from "react"; +import { css, jsx } from "@emotion/core"; +import { colors } from "../colors"; +import { useMenuIconSize } from "../MenuIconSize"; + +/* istanbul ignore next */ +function assertUnreachable(value: never): never { + throw new TypeError(`Unreachable value reached ${value}`); +} + +function getIconHorizontalPadding( + iconSize: ReturnType +): CSS.PaddingProperty { + switch (iconSize) { + case "normal": + return 12; + case "small": + return 8; + default: + assertUnreachable(iconSize); + } +} + +function getIconSize( + iconSize: ReturnType +): CSS.WidthProperty { + switch (iconSize) { + case "normal": + return 16; + case "small": + return 10; + default: + assertUnreachable(iconSize); + } +} + +function getIconMarginLeft( + iconSize: ReturnType +): CSS.MarginLeftProperty { + switch (iconSize) { + case "normal": + return "initial"; + case "small": + return -7; + default: + assertUnreachable(iconSize); + } +} + +interface Props + extends Pick< + React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + >, + "onClick" + > { + className?: string; + /** Icon to display to the left of the menu item. + * + * This element will always be rendered unless the value is `undefined`. If + * you want an empty node, a spacer for example, use `null` + */ + icon?: React.ReactNode; + selected?: boolean; + /** + * Indicates if this menu item can be itneracted with. Defaults to `true`. If + * set to `false`, there will be no hover effects. + * + * This is _not_ the same as `disabled` + */ + interactive?: boolean; +} + +export const MenuItem = React.forwardRef< + HTMLDivElement, + React.PropsWithChildren +>(({ children, interactive = true, icon, selected = false, ...props }, ref) => { + const iconSize = useMenuIconSize(); + + const selectedStyles = interactive && { + backgroundColor: colors.blue.base, + color: colors.white, + }; + + return ( +
+ {typeof icon !== "undefined" && ( +
+ {icon} +
+ )} + {children} +
+ ); +});