modal, popover and portal for select input

This commit is contained in:
Sebastian 2022-08-15 21:36:53 -03:00
parent cdc8e9afdf
commit 0798aa5ced
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
8 changed files with 782 additions and 26 deletions

View File

@ -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),
[],
);
}

View File

@ -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
);
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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
/>

View File

@ -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">&#8203;</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">&#8203;</span>
<span class="notranslate">&#8203;</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>;
// }

View File

@ -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,
};
}

View File

@ -223,6 +223,7 @@ function ExampleList({
e.preventDefault();
location.hash = `#${eId}`;
onSelectStory(r, eId);
history.pushState({}, "", `#${eId}`);
}}
>
{r.name}