diff options
author | Sebastian <sebasjm@gmail.com> | 2022-08-15 21:36:53 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2022-08-15 21:36:53 -0300 |
commit | 0798aa5cedad2a599829f2b13d4573c64a081c29 (patch) | |
tree | 191ce51f0569c34e124dd20a4a23acecc5f9eae7 | |
parent | cdc8e9afdfb93bd8a90d1e6cf0ea9aa20159e43a (diff) |
modal, popover and portal for select input
8 files changed, 782 insertions, 26 deletions
diff --git a/packages/taler-wallet-webextension/src/mui/Modal.tsx b/packages/taler-wallet-webextension/src/mui/Modal.tsx new file mode 100644 index 000000000..5a30bcf26 --- /dev/null +++ b/packages/taler-wallet-webextension/src/mui/Modal.tsx @@ -0,0 +1,132 @@ +import { css } from "@linaria/core"; +import { h, JSX, VNode, ComponentChildren } from "preact"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +// eslint-disable-next-line import/extensions +import { alpha } from "./colors/manipulation"; +import { ModalManager } from "./ModalManager"; +import { Portal } from "./Portal.js"; +// eslint-disable-next-line import/extensions +import { theme } from "./style"; + +const baseStyle = css` + position: fixed; + z-index: ${theme.zIndex.modal}; + right: 0px; + bottom: 0px; + top: 0px; + left: 0px; +`; + +interface Props { + class: string; + children: ComponentChildren; + open?: boolean; + exited?: boolean; + container?: HTMLElement; +} + +const defaultManager = new ModalManager(); +const manager = defaultManager; + +export function Modal({ + open, + // exited, + class: _class, + children, + + container, + ...rest +}: Props): VNode { + const [exited, setExited] = useState(true); + const mountNodeRef = useRef<HTMLElement | undefined>(undefined); + + const isTopModal = useCallback( + () => manager.isTopModal(getModal()), + [manager], + ); + + const handlePortalRef = useEventCallback<HTMLElement[], void>((node) => { + mountNodeRef.current = node; + + if (!node) { + return; + } + + if (open && isTopModal()) { + handleMounted(); + } else { + ariaHidden(modalRef.current, true); + } + }); + + return ( + <Portal + ref={handlePortalRef} + container={container} + disablePortal={disablePortal} + > + <div + class={[_class, baseStyle].join(" ")} + style={{ + visibility: !open && exited ? "hidden" : "visible", + }} + > + {children} + </div> + </Portal> + ); +} + +function getOffsetTop(rect: any, vertical: any): number { + let offset = 0; + + if (typeof vertical === "number") { + offset = vertical; + } else if (vertical === "center") { + offset = rect.height / 2; + } else if (vertical === "bottom") { + offset = rect.height; + } + + return offset; +} + +function getOffsetLeft(rect: any, horizontal: any): number { + let offset = 0; + + if (typeof horizontal === "number") { + offset = horizontal; + } else if (horizontal === "center") { + offset = rect.width / 2; + } else if (horizontal === "right") { + offset = rect.width; + } + + return offset; +} + +function getTransformOriginValue(transformOrigin): string { + return [transformOrigin.horizontal, transformOrigin.vertical] + .map((n) => (typeof n === "number" ? `${n}px` : n)) + .join(" "); +} + +function resolveAnchorEl(anchorEl: any): any { + return typeof anchorEl === "function" ? anchorEl() : anchorEl; +} + +function useEventCallback<Args extends unknown[], Return>( + fn: (...args: Args) => Return, +): (...args: Args) => Return { + const ref = useRef(fn); + useEffect(() => { + ref.current = fn; + }); + return useCallback( + (...args: Args) => + // @ts-expect-error hide `this` + // tslint:disable-next-line:ban-comma-operator + (0, ref.current!)(...args), + [], + ); +} diff --git a/packages/taler-wallet-webextension/src/mui/ModalManager.ts b/packages/taler-wallet-webextension/src/mui/ModalManager.ts new file mode 100644 index 000000000..2894ffa7a --- /dev/null +++ b/packages/taler-wallet-webextension/src/mui/ModalManager.ts @@ -0,0 +1,310 @@ +//////////////////// +function ownerDocument(node: Node | null | undefined): Document { + return (node && node.ownerDocument) || document; +} +function ownerWindow(node: Node | undefined): Window { + const doc = ownerDocument(node); + return doc.defaultView || window; +} +// A change of the browser zoom change the scrollbar size. +// Credit https://github.com/twbs/bootstrap/blob/488fd8afc535ca3a6ad4dc581f5e89217b6a36ac/js/src/util/scrollbar.js#L14-L18 +function getScrollbarSize(doc: Document): number { + // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes + const documentWidth = doc.documentElement.clientWidth; + return Math.abs(window.innerWidth - documentWidth); +} + +///////////////////// + +export interface ManagedModalProps { + disableScrollLock?: boolean; +} + +// Is a vertical scrollbar displayed? +function isOverflowing(container: Element): boolean { + const doc = ownerDocument(container); + + if (doc.body === container) { + return ownerWindow(container).innerWidth > doc.documentElement.clientWidth; + } + + return container.scrollHeight > container.clientHeight; +} + +export function ariaHidden(element: Element, show: boolean): void { + if (show) { + element.setAttribute("aria-hidden", "true"); + } else { + element.removeAttribute("aria-hidden"); + } +} + +function getPaddingRight(element: Element): number { + return ( + parseInt(ownerWindow(element).getComputedStyle(element).paddingRight, 10) || + 0 + ); +} + +function ariaHiddenSiblings( + container: Element, + mountElement: Element, + currentElement: Element, + elementsToExclude: readonly Element[] = [], + show: boolean, +): void { + const blacklist = [mountElement, currentElement, ...elementsToExclude]; + const blacklistTagNames = ["TEMPLATE", "SCRIPT", "STYLE"]; + + [].forEach.call(container.children, (element: Element) => { + if ( + blacklist.indexOf(element) === -1 && + blacklistTagNames.indexOf(element.tagName) === -1 + ) { + ariaHidden(element, show); + } + }); +} + +function findIndexOf<T>( + items: readonly T[], + callback: (item: T) => boolean, +): number { + let idx = -1; + items.some((item, index) => { + if (callback(item)) { + idx = index; + return true; + } + return false; + }); + return idx; +} + +function handleContainer(containerInfo: Container, props: ManagedModalProps) { + const restoreStyle: Array<{ + /** + * CSS property name (HYPHEN CASE) to be modified. + */ + property: string; + el: HTMLElement | SVGElement; + value: string; + }> = []; + const container = containerInfo.container; + + if (!props.disableScrollLock) { + if (isOverflowing(container)) { + // Compute the size before applying overflow hidden to avoid any scroll jumps. + const scrollbarSize = getScrollbarSize(ownerDocument(container)); + + restoreStyle.push({ + value: container.style.paddingRight, + property: "padding-right", + el: container, + }); + // Use computed style, here to get the real padding to add our scrollbar width. + container.style.paddingRight = `${getPaddingRight(container) + scrollbarSize + }px`; + + // .mui-fixed is a global helper. + const fixedElements = + ownerDocument(container).querySelectorAll(".mui-fixed"); + [].forEach.call(fixedElements, (element: HTMLElement | SVGElement) => { + restoreStyle.push({ + value: element.style.paddingRight, + property: "padding-right", + el: element, + }); + element.style.paddingRight = `${getPaddingRight(element) + scrollbarSize + }px`; + }); + } + + // Improve Gatsby support + // https://css-tricks.com/snippets/css/force-vertical-scrollbar/ + const parent = container.parentElement; + const containerWindow = ownerWindow(container); + const scrollContainer = + parent?.nodeName === "HTML" && + containerWindow.getComputedStyle(parent).overflowY === "scroll" + ? parent + : container; + + // Block the scroll even if no scrollbar is visible to account for mobile keyboard + // screensize shrink. + restoreStyle.push( + { + value: scrollContainer.style.overflow, + property: "overflow", + el: scrollContainer, + }, + { + value: scrollContainer.style.overflowX, + property: "overflow-x", + el: scrollContainer, + }, + { + value: scrollContainer.style.overflowY, + property: "overflow-y", + el: scrollContainer, + }, + ); + + scrollContainer.style.overflow = "hidden"; + } + + const restore = () => { + restoreStyle.forEach(({ value, el, property }) => { + if (value) { + el.style.setProperty(property, value); + } else { + el.style.removeProperty(property); + } + }); + }; + + return restore; +} + +function getHiddenSiblings(container: Element) { + const hiddenSiblings: Element[] = []; + [].forEach.call(container.children, (element: Element) => { + if (element.getAttribute("aria-hidden") === "true") { + hiddenSiblings.push(element); + } + }); + return hiddenSiblings; +} + +interface Modal { + mount: Element; + modalRef: Element; +} + +interface Container { + container: HTMLElement; + hiddenSiblings: Element[]; + modals: Modal[]; + restore: null | (() => void); +} + +export class ModalManager { + private containers: Container[]; + + private modals: Modal[]; + + constructor() { + this.modals = []; + this.containers = []; + } + + add(modal: Modal, container: HTMLElement): number { + let modalIndex = this.modals.indexOf(modal); + if (modalIndex !== -1) { + return modalIndex; + } + + modalIndex = this.modals.length; + this.modals.push(modal); + + // If the modal we are adding is already in the DOM. + if (modal.modalRef) { + ariaHidden(modal.modalRef, false); + } + + const hiddenSiblings = getHiddenSiblings(container); + ariaHiddenSiblings( + container, + modal.mount, + modal.modalRef, + hiddenSiblings, + true, + ); + + const containerIndex = findIndexOf( + this.containers, + (item) => item.container === container, + ); + if (containerIndex !== -1) { + this.containers[containerIndex].modals.push(modal); + return modalIndex; + } + + this.containers.push({ + modals: [modal], + container, + restore: null, + hiddenSiblings, + }); + + return modalIndex; + } + + mount(modal: Modal, props: ManagedModalProps): void { + const containerIndex = findIndexOf( + this.containers, + (item) => item.modals.indexOf(modal) !== -1, + ); + const containerInfo = this.containers[containerIndex]; + + if (!containerInfo.restore) { + containerInfo.restore = handleContainer(containerInfo, props); + } + } + + remove(modal: Modal): number { + const modalIndex = this.modals.indexOf(modal); + + if (modalIndex === -1) { + return modalIndex; + } + + const containerIndex = findIndexOf( + this.containers, + (item) => item.modals.indexOf(modal) !== -1, + ); + const containerInfo = this.containers[containerIndex]; + + containerInfo.modals.splice(containerInfo.modals.indexOf(modal), 1); + this.modals.splice(modalIndex, 1); + + // If that was the last modal in a container, clean up the container. + if (containerInfo.modals.length === 0) { + // The modal might be closed before it had the chance to be mounted in the DOM. + if (containerInfo.restore) { + containerInfo.restore(); + } + + if (modal.modalRef) { + // In case the modal wasn't in the DOM yet. + ariaHidden(modal.modalRef, true); + } + + ariaHiddenSiblings( + containerInfo.container, + modal.mount, + modal.modalRef, + containerInfo.hiddenSiblings, + false, + ); + this.containers.splice(containerIndex, 1); + } else { + // Otherwise make sure the next top modal is visible to a screen reader. + const nextTop = containerInfo.modals[containerInfo.modals.length - 1]; + // as soon as a modal is adding its modalRef is undefined. it can't set + // aria-hidden because the dom element doesn't exist either + // when modal was unmounted before modalRef gets null + if (nextTop.modalRef) { + ariaHidden(nextTop.modalRef, false); + } + } + + return modalIndex; + } + + isTopModal(modal: Modal): boolean { + return ( + this.modals.length > 0 && this.modals[this.modals.length - 1] === modal + ); + } +} diff --git a/packages/taler-wallet-webextension/src/mui/Popover.tsx b/packages/taler-wallet-webextension/src/mui/Popover.tsx new file mode 100644 index 000000000..408f87987 --- /dev/null +++ b/packages/taler-wallet-webextension/src/mui/Popover.tsx @@ -0,0 +1,59 @@ +import { css } from "@linaria/core"; +import { h, JSX, VNode, ComponentChildren } from "preact"; +// eslint-disable-next-line import/extensions +import { alpha } from "./colors/manipulation"; +// eslint-disable-next-line import/extensions +import { theme } from "./style"; + +const baseStyle = css``; + +interface Props { + class: string; + children: ComponentChildren; +} + +export function Popover({ class: _class, children, ...rest }: Props): VNode { + return ( + <div class={[_class, baseStyle].join(" ")} style={{}} {...rest}> + {children} + </div> + ); +} + +function getOffsetTop(rect: any, vertical: any): number { + let offset = 0; + + if (typeof vertical === "number") { + offset = vertical; + } else if (vertical === "center") { + offset = rect.height / 2; + } else if (vertical === "bottom") { + offset = rect.height; + } + + return offset; +} + +function getOffsetLeft(rect: any, horizontal: any): number { + let offset = 0; + + if (typeof horizontal === "number") { + offset = horizontal; + } else if (horizontal === "center") { + offset = rect.width / 2; + } else if (horizontal === "right") { + offset = rect.width; + } + + return offset; +} + +function getTransformOriginValue(transformOrigin): string { + return [transformOrigin.horizontal, transformOrigin.vertical] + .map((n) => (typeof n === "number" ? `${n}px` : n)) + .join(" "); +} + +function resolveAnchorEl(anchorEl: any): any { + return typeof anchorEl === "function" ? anchorEl() : anchorEl; +} diff --git a/packages/taler-wallet-webextension/src/mui/Portal.tsx b/packages/taler-wallet-webextension/src/mui/Portal.tsx new file mode 100644 index 000000000..828a574fd --- /dev/null +++ b/packages/taler-wallet-webextension/src/mui/Portal.tsx @@ -0,0 +1,113 @@ +import { css } from "@linaria/core"; +import { createPortal, forwardRef } from "preact/compat"; +import { + h, + JSX, + VNode, + ComponentChildren, + RefObject, + isValidElement, + cloneElement, + Fragment, +} from "preact"; +import { Ref, useEffect, useMemo, useState } from "preact/hooks"; +// eslint-disable-next-line import/extensions +import { alpha } from "./colors/manipulation"; +// eslint-disable-next-line import/extensions +import { theme } from "./style"; + +const baseStyle = css` + position: fixed; + z-index: ${theme.zIndex.modal}; + right: 0px; + bottom: 0px; + top: 0px; + left: 0px; +`; + +interface Props { + class: string; + children: ComponentChildren; + disablePortal?: boolean; + container?: VNode; +} + +export const Portal = forwardRef(function Portal( + { container, disablePortal, children }: Props, + ref: Ref<any>, +): VNode { + const [mountNode, setMountNode] = useState<HTMLElement | undefined>( + undefined, + ); + const handleRef = useForkRef( + isValidElement(children) ? children.ref : null, + ref, + ); + + useEffect(() => { + if (!disablePortal) { + setMountNode(getContainer(container) || document.body); + } + }, [container, disablePortal]); + + useEffect(() => { + if (mountNode && !disablePortal) { + setRef(ref, mountNode); + return () => { + setRef(ref, null); + }; + } + + return undefined; + }, [ref, mountNode, disablePortal]); + + if (disablePortal) { + if (isValidElement(children)) { + return cloneElement(children, { + ref: handleRef, + }); + } + return <Fragment>{children}</Fragment>; + } + + return mountNode ? ( + createPortal(<Fragment>{children}</Fragment>, mountNode) + ) : ( + <Fragment /> + ); +}); + +function getContainer(container: any): any { + return typeof container === "function" ? container() : container; +} + +function useForkRef<Instance>( + refA: React.Ref<Instance> | null | undefined, + refB: React.Ref<Instance> | null | undefined, +): React.Ref<Instance> | null { + /** + * This will create a new function if the ref props change and are defined. + * This means react will call the old forkRef with `null` and the new forkRef + * with the ref. Cleanup naturally emerges from this behavior. + */ + return useMemo(() => { + if (refA == null && refB == null) { + return null; + } + return (refValue) => { + setRef(refA, refValue); + setRef(refB, refValue); + }; + }, [refA, refB]); +} + +function setRef<T>( + ref: RefObject<T | null> | ((instance: T | null) => void) | null | undefined, + value: T | null, +): void { + if (typeof ref === "function") { + ref(value); + } else if (ref) { + ref.current = value; + } +} diff --git a/packages/taler-wallet-webextension/src/mui/TextField.stories.tsx b/packages/taler-wallet-webextension/src/mui/TextField.stories.tsx index fb044acbc..a409f09f0 100644 --- a/packages/taler-wallet-webextension/src/mui/TextField.stories.tsx +++ b/packages/taler-wallet-webextension/src/mui/TextField.stories.tsx @@ -118,20 +118,20 @@ const Multiline = (variant: Props["variant"]): VNode => { label="Multiline" variant={variant} multiline + maxRows={4} /> <TextField {...{ value, onChange }} label="Max row 4" variant={variant} multiline - maxRows={4} + rows={10} /> <TextField {...{ value, onChange }} label="Row 10" variant={variant} multiline - rows={10} /> </Container> ); @@ -145,19 +145,7 @@ export const Select = (): VNode => { <Container> <TextField {...{ value, onChange }} - label="Multiline" - variant="standard" - select - /> - <TextField - {...{ value, onChange }} - label="Max row 4" - variant="standard" - select - /> - <TextField - {...{ value, onChange }} - label="Row 10" + label="select" variant="standard" select /> diff --git a/packages/taler-wallet-webextension/src/mui/input/SelectStandard.tsx b/packages/taler-wallet-webextension/src/mui/input/SelectStandard.tsx index 6fb2823c7..b0474a80b 100644 --- a/packages/taler-wallet-webextension/src/mui/input/SelectStandard.tsx +++ b/packages/taler-wallet-webextension/src/mui/input/SelectStandard.tsx @@ -15,6 +15,12 @@ */ import { css } from "@linaria/core"; import { h, VNode, Fragment } from "preact"; +import { useRef } from "preact/hooks"; +import { Paper } from "../Paper.js"; + +function hasValue(value: any): boolean { + return value != null && !(Array.isArray(value) && value.length === 0); +} const SelectSelect = css` height: "auto"; @@ -36,23 +42,159 @@ const SelectNativeInput = css` box-sizing: border-box; `; -export function SelectStandard({ value }: any): VNode { +// export function SelectStandard({ value }: any): VNode { +// return ( +// <Fragment> +// <div class={SelectSelect} role="button"> +// {!value ? ( +// // notranslate needed while Google Translate will not fix zero-width space issue +// <span className="notranslate">​</span> +// ) : ( +// value +// )} +// <input +// class={SelectNativeInput} +// aria-hidden +// tabIndex={-1} +// value={Array.isArray(value) ? value.join(",") : value} +// /> +// </div> +// </Fragment> +// ); +// } +function isFilled(obj: any, SSR = false): boolean { + return ( + obj && + ((hasValue(obj.value) && obj.value !== "") || + (SSR && hasValue(obj.defaultValue) && obj.defaultValue !== "")) + ); +} +function isEmpty(display: any): boolean { + return display == null || (typeof display === "string" && !display.trim()); +} + +export function SelectStandard({ + value, + multiple, + displayEmpty, + onBlur, + onChange, + onClose, + onFocus, + onOpen, + renderValue, + menuMinWidthState, +}: any): VNode { + const inputRef = useRef(null); + const displayRef = useRef(null); + + let display; + let computeDisplay = false; + let foundMatch = false; + let displaySingle; + const displayMultiple: any[] = []; + if (isFilled({ value }) || displayEmpty) { + if (renderValue) { + display = renderValue(value); + } else { + computeDisplay = true; + } + } + if (computeDisplay) { + if (multiple) { + if (displayMultiple.length === 0) { + display = null; + } else { + display = displayMultiple.reduce((output, child, index) => { + output.push(child); + if (index < displayMultiple.length - 1) { + output.push(", "); + } + return output; + }, []); + } + } else { + display = displaySingle; + } + } + + // Avoid performing a layout computation in the render method. + let menuMinWidth = menuMinWidthState; + + // if (!autoWidth && isOpenControlled && displayNode) { + // menuMinWidth = displayNode.clientWidth; + // } + + // let tabIndex; + // if (typeof tabIndexProp !== "undefined") { + // tabIndex = tabIndexProp; + // } else { + // tabIndex = disabled ? null : 0; + // } + const update = (open: any, event: any) => { + if (open) { + if (onOpen) { + onOpen(event); + } + } else if (onClose) { + onClose(event); + } + + // if (!isOpenControlled) { + // setMenuMinWidthState(autoWidth ? null : displayNode.clientWidth); + // setOpenState(open); + // } + }; + + const handleMouseDown = (event: any) => { + // Ignore everything but left-click + if (event.button !== 0) { + return; + } + // Hijack the default focus behavior. + event.preventDefault(); + // displayRef.current.focus(); + + update(true, event); + }; return ( <Fragment> - <div class={SelectSelect} role="button"> - {!value ? ( + <div + class={css` + height: auto; + min-height: 14375em; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + `} + > + {isEmpty(display) ? ( // notranslate needed while Google Translate will not fix zero-width space issue - <span className="notranslate">​</span> + <span class="notranslate">​</span> ) : ( - value + display )} - <input - class={SelectNativeInput} - aria-hidden - tabIndex={-1} - value={Array.isArray(value) ? value.join(",") : value} - /> </div> + <input + class={css` + bottom: 0px; + left: 0px; + position: "absolute"; + opacity: 0; + pointer-events: none; + width: 100%; + box-sizing: border-box; + `} + /> + <svg /> </Fragment> ); } + +// function Popover(): VNode { +// return; +// } + +// function Menu(): VNode { +// return <Paper></Paper>; +// } diff --git a/packages/taler-wallet-webextension/src/mui/style.tsx b/packages/taler-wallet-webextension/src/mui/style.tsx index 32fa412e5..c3071b314 100644 --- a/packages/taler-wallet-webextension/src/mui/style.tsx +++ b/packages/taler-wallet-webextension/src/mui/style.tsx @@ -58,6 +58,16 @@ export interface Spacing { export const theme = createTheme(); +const zIndex = { + mobileStepper: 1000, + speedDial: 1050, + appBar: 1100, + drawer: 1200, + modal: 1300, + snackbar: 1400, + tooltip: 1500, +}; + export const ripple = css` background-position: center; @@ -859,5 +869,6 @@ function createTheme() { breakpoints, spacing, pxToRem, + zIndex, }; } diff --git a/packages/taler-wallet-webextension/src/stories.tsx b/packages/taler-wallet-webextension/src/stories.tsx index 4a090e52e..b4c209a5f 100644 --- a/packages/taler-wallet-webextension/src/stories.tsx +++ b/packages/taler-wallet-webextension/src/stories.tsx @@ -223,6 +223,7 @@ function ExampleList({ e.preventDefault(); location.hash = `#${eId}`; onSelectStory(r, eId); + history.pushState({}, "", `#${eId}`); }} > {r.name} |