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:
parent
bc19a298f8
commit
c6b44eea87
11 changed files with 313 additions and 126 deletions
|
@ -47,11 +47,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
div.successTooltip {
|
||||
background: #008a71;
|
||||
color: #fff;
|
||||
|
||||
&::after {
|
||||
border-top-color: #008a71;
|
||||
}
|
||||
.successfulTooltip {
|
||||
background-color: var(--color-success-60);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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 />
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
216
packages/console/src/hooks/use-position.ts
Normal file
216
packages/console/src/hooks/use-position.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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={() => {
|
||||
|
|
Loading…
Add table
Reference in a new issue