0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor(console): use-position in tooltip (#802)

This commit is contained in:
Xiao Yijun 2022-05-13 11:54:10 +08:00 committed by GitHub
parent bc19a298f8
commit c6b44eea87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 313 additions and 126 deletions

View file

@ -47,11 +47,6 @@
}
}
div.successTooltip {
background: #008a71;
color: #fff;
&::after {
border-top-color: #008a71;
}
.successfulTooltip {
background-color: var(--color-success-60);
}

View file

@ -56,9 +56,9 @@ const CopyToClipboard = ({ value, className, variant = 'contained' }: Props) =>
{variant === 'icon' ? null : value}
<CopyIcon ref={copyIconReference} onClick={copy} />
<Tooltip
className={classNames(copyState === 'copied' && styles.successTooltip)}
domRef={copyIconReference}
anchorRef={copyIconReference}
content={t(copyState)}
className={classNames(copyState === 'copied' && styles.successfulTooltip)}
/>
</div>
</div>

View file

@ -2,8 +2,9 @@ import classNames from 'classnames';
import React, { ReactNode, RefObject, useRef } from 'react';
import ReactModal from 'react-modal';
import usePosition, { HorizontalAlignment } from '@/hooks/use-position';
import * as styles from './index.module.scss';
import usePosition, { HorizontalAlignment } from './use-position';
export { default as DropdownItem } from './DropdownItem';
@ -28,26 +29,32 @@ const Dropdown = ({
isFullWidth,
className,
titleClassName,
horizontalAlign,
horizontalAlign = 'end',
}: Props) => {
const overlayRef = useRef<HTMLDivElement>(null);
const { position, mutate } = usePosition(anchorRef, overlayRef, horizontalAlign);
const { position, positionState, mutate } = usePosition({
verticalAlign: 'bottom',
horizontalAlign,
offset: { vertical: 4, horizontal: 0 },
anchorRef,
overlayRef,
});
return (
<ReactModal
shouldCloseOnOverlayClick
isOpen={isOpen}
style={{
content: position
? {
left: `${position.left}px`,
top: `${position.top}px`,
width: isFullWidth ? `${position.width}px` : undefined,
}
: { visibility: 'hidden' },
content: {
width:
isFullWidth && anchorRef.current
? anchorRef.current.getBoundingClientRect().width
: undefined,
...position,
},
}}
className={classNames(styles.content, position?.isOnTop && styles.onTop)}
className={classNames(styles.content, positionState.verticalAlign === 'top' && styles.onTop)}
overlayClassName={styles.overlay}
onRequestClose={onClose}
onAfterOpen={mutate}

View file

@ -1,49 +0,0 @@
import { RefObject, useCallback, useLayoutEffect, useState } from 'react';
type Position = {
left: number;
top: number;
width: number;
isOnTop?: boolean;
};
export type HorizontalAlignment = 'left' | 'right';
// Leave space for box-shadow effect.
const safePadding = 12;
// The distance to anchor
const distance = 4;
export default function usePosition(
anchorRef: RefObject<HTMLElement>,
overlayRef: RefObject<HTMLElement>,
horizontalAlign: HorizontalAlignment = 'left'
) {
const [position, setPosition] = useState<Position>();
const isRightAligned = horizontalAlign === 'right';
const updatePosition = useCallback(() => {
if (anchorRef.current && overlayRef.current) {
const anchor = anchorRef.current.getBoundingClientRect();
const overlay = overlayRef.current.getBoundingClientRect();
const isOnTop = anchor.y + anchor.height + overlay.height > window.innerHeight - safePadding;
const isOnLeft = anchor.x + overlay.width > window.innerWidth - safePadding;
const left = isOnLeft || isRightAligned ? anchor.x + anchor.width - overlay.width : anchor.x;
const top = isOnTop
? anchor.y - overlay.height - distance
: anchor.y + anchor.height + distance;
setPosition({ left, top, width: anchor.width, isOnTop });
}
}, [anchorRef, isRightAligned, overlayRef]);
useLayoutEffect(() => {
updatePosition();
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('resize', updatePosition);
};
}, [updatePosition]);
return { position, mutate: updatePosition };
}

View file

@ -29,7 +29,7 @@ const FormField = ({ title, children, isRequired, className, tooltip }: Props) =
{tooltip && (
<div ref={tipRef} className={styles.icon}>
<Tip />
<Tooltip domRef={tipRef} content={t(tooltip)} />
<Tooltip anchorRef={tipRef} content={t(tooltip)} />
</div>
)}
<Spacer />

View file

@ -1,12 +1,11 @@
@use '@/scss/underscore' as _;
.container {
.tooltip {
position: absolute;
transform: translate(-50%, -100%);
border-radius: 8px;
background: #191c1d;
color: #e0e3e3;
box-shadow: var(--light-shadow-s1);
background: var(--color-on-background);
color: var(--color-background);
box-shadow: var(--shadow-1);
padding: _.unit(2) _.unit(3);
font: var(--font-body-medium);
@ -14,12 +13,16 @@
content: '';
display: block;
position: absolute;
border-width: 4px;
border-style: solid;
border-color: #191c1d transparent transparent;
left: 50%;
width: 10px;
height: 10px;
top: 100%;
transform: translateX(-50%);
box-shadow: var(--light-shadow-s1);
left: 50%;
background-color: inherit;
border-radius: _.unit(0.5) 0 _.unit(0.5);
transform: translate(-50%, -50%) rotate(45deg);
}
&.arrowUp::after {
top: 0%;
}
}

View file

@ -1,57 +1,67 @@
import { Nullable } from '@silverhand/essentials';
import classNames from 'classnames';
import React, { ReactNode, RefObject, useEffect, useState } from 'react';
import React, { ReactNode, RefObject, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import usePosition from '@/hooks/use-position';
import * as styles from './index.module.scss';
type Props<T> = {
type Props = {
content: ReactNode;
domRef: RefObject<Nullable<T>>;
anchorRef: RefObject<Element>;
className?: string;
behavior?: 'visibleOnHover' | 'visibleByDefault';
isKeepOpen?: boolean;
};
type Position = {
top: number;
left: number;
};
const Tooltip = <T extends Element>({
content,
domRef,
className,
behavior = 'visibleOnHover',
}: Props<T>) => {
const Tooltip = ({ content, anchorRef, className, isKeepOpen = false }: Props) => {
const [tooltipDom, setTooltipDom] = useState<HTMLDivElement>();
const [position, setPosition] = useState<Position>();
const positionCaculated = position !== undefined;
const tooltipRef = useRef<HTMLDivElement>(null);
const { position, positionState, mutate } = usePosition({
verticalAlign: 'top',
horizontalAlign: 'center',
offset: { vertical: 12, horizontal: 0 },
anchorRef,
overlayRef: tooltipRef,
});
const [showUp, setShowUp] = useState(false);
useEffect(() => {
if (!domRef.current) {
if (!showUp) {
return;
}
const dom = domRef.current;
const mutateAnimationFrame = requestAnimationFrame(() => {
mutate();
});
if (behavior === 'visibleByDefault') {
const { top, left, width } = domRef.current.getBoundingClientRect();
const { scrollTop, scrollLeft } = document.documentElement;
setPosition({ top: top + scrollTop - 12, left: left + scrollLeft + width / 2 });
return () => {
cancelAnimationFrame(mutateAnimationFrame);
};
}, [showUp, mutate]);
useEffect(() => {
if (!anchorRef.current) {
return;
}
if (isKeepOpen) {
setShowUp(true);
return;
}
const dom = anchorRef.current;
const enterHandler = () => {
if (domRef.current) {
const { top, left, width } = domRef.current.getBoundingClientRect();
const { scrollTop, scrollLeft } = document.documentElement;
setPosition({ top: top + scrollTop - 12, left: left + scrollLeft + width / 2 });
if (!showUp) {
setShowUp(true);
}
};
const leaveHandler = () => {
setPosition(undefined);
setShowUp(false);
};
dom.addEventListener('mouseenter', enterHandler);
@ -61,10 +71,10 @@ const Tooltip = <T extends Element>({
dom.removeEventListener('mouseenter', enterHandler);
dom.removeEventListener('mouseleave', leaveHandler);
};
}, [domRef, behavior]);
}, [anchorRef, showUp, isKeepOpen]);
useEffect(() => {
if (!positionCaculated) {
if (!showUp) {
if (tooltipDom) {
tooltipDom.remove();
setTooltipDom(undefined);
@ -80,14 +90,24 @@ const Tooltip = <T extends Element>({
}
return () => tooltipDom?.remove();
}, [positionCaculated, tooltipDom]);
}, [showUp, tooltipDom]);
if (!tooltipDom || !position) {
useLayoutEffect(() => {
mutate();
}, [content, mutate]);
if (!tooltipDom) {
return null;
}
const isArrowUp = positionState.verticalAlign === 'bottom';
return createPortal(
<div className={classNames(styles.container, className)} style={{ ...position }}>
<div
ref={tooltipRef}
className={classNames(styles.tooltip, isArrowUp && styles.arrowUp, className)}
style={{ ...position }}
>
{content}
</div>,
tooltipDom

View file

@ -0,0 +1,216 @@
import { RefObject, useCallback, useEffect, useState } from 'react';
export type VerticalAlignment = 'top' | 'bottom';
export type HorizontalAlignment = 'start' | 'center' | 'end';
type Offset = {
vertical: number;
horizontal: number;
};
type Props = {
verticalAlign: VerticalAlignment;
horizontalAlign: HorizontalAlignment;
offset: Offset;
anchorRef: RefObject<Element>;
overlayRef: RefObject<Element>;
};
type Position = {
top: number;
left: number;
};
// Leave space for box-shadow effect.
const windowSafePadding = 12;
const selectVerticalAlignment = ({
verticalAlign,
verticalTop,
verticalBottom,
overlayHeight,
}: {
verticalAlign: VerticalAlignment;
verticalTop: number;
verticalBottom: number;
overlayHeight: number;
}) => {
const minY = windowSafePadding;
const maxY = window.innerHeight - windowSafePadding;
const isTopAllowed = verticalTop >= minY;
const isBottomAllowed = verticalBottom + overlayHeight <= maxY;
if (verticalAlign === 'top') {
if (isTopAllowed) {
return 'top';
}
return isBottomAllowed ? 'bottom' : 'top';
}
// Mark: VerticalAlign === 'bottom'
if (isBottomAllowed) {
return 'bottom';
}
return isTopAllowed ? 'top' : 'bottom';
};
const selectHorizontalAlignment = ({
horizontalAlign,
horizontalStart,
horizontalCenter,
horizontalEnd,
overlayWidth,
}: {
horizontalAlign: HorizontalAlignment;
horizontalStart: number;
horizontalCenter: number;
horizontalEnd: number;
overlayWidth: number;
}) => {
const minX = windowSafePadding;
const maxX = window.innerWidth - windowSafePadding;
const isStartAllowed = horizontalStart + overlayWidth <= maxX;
const isCenterAllowed =
horizontalCenter - overlayWidth / 2 >= minX && horizontalCenter + overlayWidth / 2 <= maxX;
const isEndAllowed = horizontalEnd >= minX;
switch (horizontalAlign) {
case 'start': {
if (isStartAllowed) {
return 'start';
}
if (isEndAllowed) {
return 'end';
}
if (isCenterAllowed) {
return 'center';
}
return horizontalAlign;
}
case 'center': {
if (isCenterAllowed) {
return 'center';
}
if (isStartAllowed) {
return 'start';
}
if (isEndAllowed) {
return 'end';
}
return horizontalAlign;
}
case 'end': {
if (isEndAllowed) {
return 'end';
}
if (isStartAllowed) {
return 'start';
}
if (isCenterAllowed) {
return 'center';
}
return horizontalAlign;
}
default: {
return horizontalAlign;
}
}
};
export default function usePosition({
verticalAlign,
horizontalAlign,
offset,
anchorRef,
overlayRef,
}: Props) {
const [position, setPosition] = useState<Position>();
const [currentVerticalAlign, setCurrentVerticalAlign] = useState(verticalAlign);
const [currentHorizontalAlign, setCurrentHorizontalAlign] = useState(horizontalAlign);
const updatePosition = useCallback(() => {
if (!anchorRef.current || !overlayRef.current) {
return;
}
const anchorRect = anchorRef.current.getBoundingClientRect();
const overlayRect = overlayRef.current.getBoundingClientRect();
const { scrollTop, scrollLeft } = document.documentElement;
const verticalTop = anchorRect.y - overlayRect.height + scrollTop - offset.vertical;
const verticalBottom = anchorRect.y + anchorRect.height + scrollTop + offset.vertical;
const verticalPositionMap = { top: verticalTop, bottom: verticalBottom };
const horizontalStart = anchorRect.x + scrollLeft + offset.horizontal;
const horizontalCenter =
anchorRect.x + anchorRect.width / 2 - overlayRect.width / 2 + scrollLeft + offset.horizontal;
const horizontalEnd =
anchorRect.x + anchorRect.width - overlayRect.width + scrollLeft + offset.horizontal;
const horizontalPositionMap = {
start: horizontalStart,
center: horizontalCenter,
end: horizontalEnd,
};
const selectedVerticalAlign = selectVerticalAlignment({
verticalAlign,
verticalTop,
verticalBottom,
overlayHeight: overlayRect.height,
});
const selectedHorizontalAlign = selectHorizontalAlignment({
horizontalAlign,
horizontalStart,
horizontalCenter,
horizontalEnd,
overlayWidth: overlayRect.width,
});
setCurrentVerticalAlign(selectedVerticalAlign);
setCurrentHorizontalAlign(selectedHorizontalAlign);
setPosition({
top: verticalPositionMap[selectedVerticalAlign],
left: horizontalPositionMap[selectedHorizontalAlign],
});
}, [anchorRef, horizontalAlign, offset.vertical, offset.horizontal, overlayRef, verticalAlign]);
useEffect(() => {
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition);
};
}, [updatePosition]);
return {
position,
mutate: updatePosition,
positionState: {
verticalAlign: currentVerticalAlign,
horizontalAlign: currentHorizontalAlign,
},
};
}

View file

@ -25,11 +25,6 @@
color: var(--color-caption);
}
div.successTooltip {
background: #008a71;
color: #fff;
&::after {
border-top-color: #008a71;
}
.successfulTooltip {
background-color: var(--color-success-60);
}

View file

@ -102,9 +102,9 @@ const SenderTester = ({ connectorType }: Props) => {
</div>
{showTooltip && (
<Tooltip
behavior="visibleByDefault"
domRef={buttonPosReference}
className={styles.successTooltip}
isKeepOpen
className={styles.successfulTooltip}
anchorRef={buttonPosReference}
content={t('connector_details.test_message_sent')}
/>
)}

View file

@ -42,7 +42,7 @@ const GetStartedProgress = () => {
anchorRef={anchorRef}
className={styles.dropdown}
isOpen={showDropDown}
horizontalAlign="right"
horizontalAlign="end"
title={t('get_started.progress_dropdown_title')}
titleClassName={styles.dropdownTitle}
onClose={() => {