pl-fe: replace react-overlays with @floating-ui/react

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2024-09-27 20:35:59 +02:00
parent 62895adb02
commit bc3f02aee6
9 changed files with 105 additions and 289 deletions

View File

@ -3,12 +3,14 @@ import {
autoPlacement,
FloatingArrow,
offset,
shift,
useClick,
useDismiss,
useFloating,
useHover,
useInteractions,
useTransitionStyles,
type OffsetOptions,
} from '@floating-ui/react';
import clsx from 'clsx';
import React, { useRef, useState } from 'react';
@ -25,6 +27,7 @@ interface IPopover {
interaction?: 'click' | 'hover';
/** Add a class to the reference (trigger) element */
referenceElementClassName?: string;
offsetOptions?: OffsetOptions;
}
/**
@ -33,14 +36,12 @@ interface IPopover {
* Similar to tooltip, but requires a click and is used for larger blocks
* of information.
*/
const Popover: React.FC<IPopover> = (props) => {
const { children, content, referenceElementClassName, interaction = 'hover', isFlush = false } = props;
const Popover: React.FC<IPopover> = ({ children, content, referenceElementClassName, interaction = 'hover', isFlush = false, offsetOptions = 10 }) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const arrowRef = useRef<SVGSVGElement>(null);
const { x, y, strategy, refs, context } = useFloating({
const { x, y, strategy, refs, context, placement } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'top',
@ -48,7 +49,10 @@ const Popover: React.FC<IPopover> = (props) => {
autoPlacement({
allowedPlacements: ['top', 'bottom'],
}),
offset(10),
offset(offsetOptions),
shift({
padding: 8,
}),
arrow({
element: arrowRef,
}),
@ -59,10 +63,11 @@ const Popover: React.FC<IPopover> = (props) => {
initial: {
opacity: 0,
transform: 'scale(0.8)',
transformOrigin: placement === 'bottom' ? 'top' : 'bottom',
},
duration: {
open: 200,
close: 200,
open: 100,
close: 100,
},
});
@ -82,6 +87,7 @@ const Popover: React.FC<IPopover> = (props) => {
ref: refs.setReference,
...getReferenceProps(),
className: clsx(children.props.className, referenceElementClassName),
'aria-expanded': isOpen,
})}
{(isMounted) && (
@ -94,20 +100,23 @@ const Popover: React.FC<IPopover> = (props) => {
left: x ?? 0,
...styles,
}}
className={
clsx({
'z-40 rounded-lg bg-white shadow-2xl dark:bg-gray-900 dark:ring-2 dark:ring-primary-700': true,
'p-6': !isFlush,
})
}
{...getFloatingProps()}
>
{content}
<div
className={
clsx(
'z-40 overflow-hidden rounded-lg bg-white shadow-2xl dark:bg-gray-900 dark:ring-2 dark:ring-primary-700',
{ 'p-6': !isFlush },
)
}
{...getFloatingProps()}
>
{content}
</div>
<FloatingArrow
ref={arrowRef}
context={context}
className='-ml-2 fill-white dark:hidden' /** -ml-2 to fix offcenter arrow */
className='fill-white dark:fill-primary-700'
tipRadius={3}
/>
</div>

View File

@ -1,49 +1,31 @@
import { supportsPassiveEvents } from 'detect-passive-events';
import React, { useEffect, useRef } from 'react';
import { SketchPicker, ColorChangeHandler } from 'react-color';
import React from 'react';
import { SketchPicker, type ColorChangeHandler } from 'react-color';
import { isMobile } from 'pl-fe/is-mobile';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
import { Popover } from 'pl-fe/components/ui';
interface IColorPicker {
style?: React.CSSProperties;
value: string;
onChange: ColorChangeHandler;
onClose: () => void;
className?: string;
}
const ColorPicker: React.FC<IColorPicker> = ({ style, value, onClose, onChange }) => {
const node = useRef<HTMLDivElement>(null);
const handleDocumentClick = (e: MouseEvent | TouchEvent) => {
if (node.current && !node.current.contains(e.target as HTMLElement)) {
onClose();
}
};
useEffect(() => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick);
};
}, []);
const pickerStyle: React.CSSProperties = {
...style,
marginLeft: isMobile(window.innerWidth) ? '20px' : '12px',
position: 'absolute',
zIndex: 1000,
};
return (
<div id='SketchPickerContainer' ref={node} style={pickerStyle}>
<SketchPicker color={value} disableAlpha onChange={onChange} />
</div>
);
};
const ColorPicker: React.FC<IColorPicker> = ({ value, onChange, className }) => (
<div className={className}>
<Popover
interaction='click'
content={
<SketchPicker color={value} disableAlpha onChange={onChange} />
}
isFlush
>
<div
className='size-full'
role='presentation'
style={{ background: value }}
title={value}
/>
</Popover>
</div>
);
export { ColorPicker as default };

View File

@ -1,59 +0,0 @@
import React, { useState, useRef } from 'react';
// @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions
import Overlay from 'react-overlays/lib/Overlay';
import { isMobile } from 'pl-fe/is-mobile';
import ColorPicker from './color-picker';
import type { ColorChangeHandler } from 'react-color';
interface IColorWithPicker {
value: string;
onChange: ColorChangeHandler;
className?: string;
}
const ColorWithPicker: React.FC<IColorWithPicker> = ({ value, onChange, className }) => {
const node = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(false);
const [placement, setPlacement] = useState<string | null>(null);
const hidePicker = () => {
setActive(false);
};
const showPicker = () => {
setActive(true);
setPlacement(isMobile(window.innerWidth) ? 'bottom' : 'right');
};
const onToggle: React.MouseEventHandler = (e) => {
if (active) {
hidePicker();
} else {
showPicker();
}
e.stopPropagation();
};
return (
<div className={className}>
<div
ref={node}
className='size-full'
role='presentation'
style={{ background: value }}
title={value}
onClick={onToggle}
/>
<Overlay show={active} placement={placement} target={node.current}>
<ColorPicker value={value} onChange={onChange} onClose={hidePicker} />
</Overlay>
</div>
);
};
export { ColorWithPicker as default };

View File

@ -1,9 +1,8 @@
import React, { useRef, useState } from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
// @ts-ignore
import Overlay from 'react-overlays/lib/Overlay';
import ForkAwesomeIcon from 'pl-fe/components/fork-awesome-icon';
import { Popover } from 'pl-fe/components/ui';
import IconPickerMenu from './icon-picker-menu';
@ -19,65 +18,32 @@ interface IIconPickerDropdown {
const IconPickerDropdown: React.FC<IIconPickerDropdown> = ({ value, onPickIcon }) => {
const intl = useIntl();
const [active, setActive] = useState(false);
const [placement, setPlacement] = useState<'bottom' | 'top'>();
const target = useRef(null);
const onShowDropdown: React.KeyboardEventHandler<HTMLDivElement> = ({ target }) => {
setActive(true);
const { top } = (target as any).getBoundingClientRect();
setPlacement(top * 2 < innerHeight ? 'bottom' : 'top');
};
const onHideDropdown = () => {
setActive(false);
};
const onToggle: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
e.stopPropagation();
if (!e.key || e.key === 'Enter') {
if (active) {
onHideDropdown();
} else {
onShowDropdown(e);
}
}
};
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === 'Escape') {
onHideDropdown();
}
};
const title = intl.formatMessage(messages.emoji);
const forkAwesomeIcons = require('../forkawesome.json');
return (
<div onKeyDown={handleKeyDown}>
<div
ref={target}
className='flex size-[38px] cursor-pointer items-center justify-center text-lg'
title={title}
aria-label={title}
aria-expanded={active}
role='button'
onClick={onToggle as any as React.MouseEventHandler<HTMLDivElement>}
onKeyDown={onToggle}
tabIndex={0}
<div>
<Popover
interaction='click'
content={
<IconPickerMenu
icons={forkAwesomeIcons}
onPick={onPickIcon}
/>
}
isFlush
>
<ForkAwesomeIcon id={value} />
</div>
<div
className='flex size-[38px] cursor-pointer items-center justify-center text-lg'
title={title}
aria-label={title}
role='button'
tabIndex={0}
>
<ForkAwesomeIcon id={value} />
</div>
<Overlay show={active} placement={placement} target={target.current}>
<IconPickerMenu
icons={forkAwesomeIcons}
onClose={onHideDropdown}
onPick={onPickIcon}
/>
</Overlay>
</Popover>
</div>
);
};

View File

@ -1,6 +1,5 @@
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React, { useCallback, useEffect, useRef } from 'react';
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { Text } from 'pl-fe/components/ui';
@ -9,40 +8,16 @@ const messages = defineMessages({
emoji: { id: 'icon_button.label', defaultMessage: 'Select icon' },
});
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
interface IIconPickerMenu {
icons: Record<string, Array<string>>;
onClose: () => void;
onPick: (icon: string) => void;
style?: React.CSSProperties;
}
const IconPickerMenu: React.FC<IIconPickerMenu> = ({ icons, onClose, onPick, style }) => {
const IconPickerMenu: React.FC<IIconPickerMenu> = ({ icons, onPick, style }) => {
const intl = useIntl();
const node = useRef<HTMLDivElement | null>(null);
const handleDocumentClick = useCallback((e: MouseEvent | TouchEvent) => {
if (node.current && !node.current.contains(e.target as Node)) {
onClose();
}
}, []);
useEffect(() => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any);
};
}, []);
const setRef = (c: HTMLDivElement) => {
node.current = c;
if (!c) return;
// Nice and dirty hack to display the icons
@ -54,7 +29,6 @@ const IconPickerMenu: React.FC<IIconPickerMenu> = ({ icons, onClose, onPick, sty
};
const handleClick = (icon: string) => {
onClose();
onPick(icon);
};
@ -79,16 +53,14 @@ const IconPickerMenu: React.FC<IIconPickerMenu> = ({ icons, onClose, onPick, sty
return (
<div
className='absolute z-[101] -my-0.5'
style={{ transform: 'translateX(calc(-1 * env(safe-area-inset-right)))', ...style }}
className='h-[270px] overflow-x-hidden overflow-y-scroll rounded bg-white p-1.5 text-gray-900 dark:bg-primary-900 dark:text-gray-100'
aria-label={title}
ref={setRef}
>
<div className='h-[270px] overflow-x-hidden overflow-y-scroll rounded bg-white p-1.5 text-gray-900 dark:bg-primary-900 dark:text-gray-100' aria-label={title}>
<Text className='px-1.5 py-1'><FormattedMessage id='icon_button.icons' defaultMessage='Icons' /></Text>
<ul className='grid grid-cols-8'>
{Object.values(icons).flat().map(icon => renderIcon(icon))}
</ul>
</div>
<Text className='px-1.5 py-1'><FormattedMessage id='icon_button.icons' defaultMessage='Icons' /></Text>
<ul className='grid grid-cols-8'>
{Object.values(icons).flat().map(icon => renderIcon(icon))}
</ul>
</div>
);
};

View File

@ -1,6 +1,6 @@
import React from 'react';
import ColorWithPicker from 'pl-fe/features/pl-fe-config/components/color-with-picker';
import ColorPicker from 'pl-fe/features/pl-fe-config/components/color-picker';
import type { ColorChangeHandler } from 'react-color';
@ -17,7 +17,7 @@ const Color: React.FC<IColor> = ({ color, onChange }) => {
};
return (
<ColorWithPicker
<ColorPicker
className='size-full'
value={color}
onChange={handleChange}

View File

@ -8,7 +8,7 @@ import { fetchPlFeConfig } from 'pl-fe/actions/pl-fe';
import DropdownMenu from 'pl-fe/components/dropdown-menu';
import List, { ListItem } from 'pl-fe/components/list';
import { Button, Column, Form, FormActions } from 'pl-fe/components/ui';
import ColorWithPicker from 'pl-fe/features/pl-fe-config/components/color-with-picker';
import ColorPicker from 'pl-fe/features/pl-fe-config/components/color-picker';
import { useAppDispatch, useAppSelector, usePlFeConfig } from 'pl-fe/hooks';
import { normalizePlFeConfig } from 'pl-fe/normalizers';
import toast from 'pl-fe/toast';
@ -265,7 +265,7 @@ const ColorListItem: React.FC<IColorListItem> = ({ label, value, onChange }) =>
return (
<ListItem label={label}>
<ColorWithPicker
<ColorPicker
value={value}
onChange={handleChange}
className='h-8 w-10 overflow-hidden rounded-md'