Merge remote-tracking branch 'soapbox/develop' into cleanup

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2023-02-12 22:26:55 +01:00
71 changed files with 1700 additions and 940 deletions

View File

@ -1,15 +1,16 @@
import { Placement } from '@popperjs/core';
import clsx from 'clsx';
import React, { useRef } from 'react';
import React, { useEffect, useState } from 'react';
import { usePopper } from 'react-popper';
import { Emoji, HStack } from 'soapbox/components/ui';
import { useSoapboxConfig } from 'soapbox/hooks';
interface IEmojiButton {
/** Unicode emoji character. */
emoji: string,
/** Event handler when the emoji is clicked. */
onClick: React.EventHandler<React.MouseEvent>,
/** Keyboard event handler. */
onKeyDown?: React.EventHandler<React.KeyboardEvent>,
onClick(emoji: string): void
/** Extra class name on the <button> element. */
className?: string,
/** Tab order of the button. */
@ -17,104 +18,104 @@ interface IEmojiButton {
}
/** Clickable emoji button that scales when hovered. */
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, onKeyDown, tabIndex }): JSX.Element => {
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
const handleClick: React.EventHandler<React.MouseEvent> = (event) => {
event.preventDefault();
event.stopPropagation();
onClick(emoji);
};
return (
<button className={clsx(className)} onClick={onClick} tabIndex={tabIndex}>
<Emoji className='h-8 w-8 duration-100' emoji={emoji} />
<button className={clsx(className)} onClick={handleClick} tabIndex={tabIndex}>
<Emoji className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} />
</button>
);
};
interface IEmojiSelector {
/** List of Unicode emoji characters. */
emojis: Iterable<string>,
onClose?(): void
/** Event handler when an emoji is clicked. */
onReact: (emoji: string) => void,
/** Event handler when selector is escaped. */
onUnfocus: React.KeyboardEventHandler<HTMLDivElement>,
onReact(emoji: string): void
/** Element that triggers the EmojiSelector Popper */
referenceElement: HTMLElement | null
placement?: Placement
/** Whether the selector should be visible. */
visible?: boolean,
/** Whether the selector should be focused. */
focused?: boolean,
visible?: boolean
}
/** Panel with a row of emoji buttons. */
const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, onUnfocus, visible = false, focused = false }): JSX.Element => {
const emojiList = Array.from(emojis);
const node = useRef<HTMLDivElement>(null);
const EmojiSelector: React.FC<IEmojiSelector> = ({
referenceElement,
onClose,
onReact,
placement = 'top',
visible = false,
}): JSX.Element => {
const soapboxConfig = useSoapboxConfig();
const handleReact = (emoji: string): React.EventHandler<React.MouseEvent> => {
return (e) => {
onReact(emoji);
e.preventDefault();
e.stopPropagation();
// `useRef` won't trigger a re-render, while `useState` does.
// https://popper.js.org/react-popper/v2/
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const handleClickOutside = (event: MouseEvent) => {
if (referenceElement?.contains(event.target as Node) || popperElement?.contains(event.target as Node)) {
return;
}
if (onClose) {
onClose();
}
};
const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
placement,
modifiers: [
{
name: 'offset',
options: {
offset: [-10, 0],
},
},
],
});
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
};
}, [referenceElement]);
const selectPreviousEmoji = (i: number): void => {
if (!node.current) return;
if (i !== 0) {
const button: HTMLButtonElement | null = node.current.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`);
button?.focus();
} else {
const button: HTMLButtonElement | null = node.current.querySelector('.emoji-react-selector__emoji:last-child');
button?.focus();
useEffect(() => {
if (visible && update) {
update();
}
};
const selectNextEmoji = (i: number) => {
if (!node.current) return;
if (i !== emojiList.length - 1) {
const button: HTMLButtonElement | null = node.current.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`);
button?.focus();
} else {
const button: HTMLButtonElement | null = node.current.querySelector('.emoji-react-selector__emoji:first-child');
button?.focus();
}
};
const handleKeyDown = (i: number): React.KeyboardEventHandler<HTMLDivElement> => e => {
switch (e.key) {
case 'Enter':
handleReact(emojiList[i])(e as any);
break;
case 'Tab':
e.preventDefault();
if (e.shiftKey) selectPreviousEmoji(i);
else selectNextEmoji(i);
break;
case 'Left':
case 'ArrowLeft':
selectPreviousEmoji(i);
break;
case 'Right':
case 'ArrowRight':
selectNextEmoji(i);
break;
case 'Escape':
onUnfocus(e);
break;
}
};
}, [visible, update]);
return (
<HStack
className={clsx('emoji-react-selector z-[999] w-max max-w-[100vw] flex-wrap gap-2 rounded-full bg-white p-3 shadow-md dark:bg-gray-900')}
ref={node}
<div
className={clsx('z-50 transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible,
})}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{emojiList.map((emoji, i) => (
<EmojiButton
className='emoji-react-selector__emoji hover:scale-125 focus:scale-125'
key={i}
emoji={emoji}
onClick={handleReact(emoji)}
onKeyDown={handleKeyDown(i)}
tabIndex={(visible || focused) ? 0 : -1}
/>
))}
</HStack>
<HStack
className={clsx('z-[999] flex w-max max-w-[100vw] flex-wrap space-x-3 rounded-full bg-white px-3 py-2.5 shadow-lg focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700')}
>
{Array.from(soapboxConfig.allowedEmoji).map((emoji, i) => (
<EmojiButton
key={i}
emoji={emoji}
onClick={onReact}
tabIndex={visible ? 0 : -1}
/>
))}
</HStack>
</div>
);
};

View File

@ -15,6 +15,8 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
transparent?: boolean,
/** Predefined styles to display for the button. */
theme?: 'seamless' | 'outlined',
/** Override the data-testid */
'data-testid'?: string
}
/** A clickable icon. */
@ -31,7 +33,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef
'opacity-50': filteredProps.disabled,
}, className)}
{...filteredProps}
data-testid='icon-button'
data-testid={filteredProps['data-testid'] || 'icon-button'}
>
<SvgIcon src={src} className={iconClassName} />

View File

@ -1,13 +1,32 @@
import clsx from 'clsx';
import React from 'react';
import { spring } from 'react-motion';
import Motion from 'soapbox/features/ui/util/optional-motion';
interface IProgressBar {
progress: number,
/** Number between 0 and 1 to represent the percentage complete. */
progress: number
/** Height of the progress bar. */
size?: 'sm' | 'md'
}
/** A horizontal meter filled to the given percentage. */
const ProgressBar: React.FC<IProgressBar> = ({ progress }) => (
<div className='h-2.5 w-full overflow-hidden rounded-full bg-gray-300 dark:bg-primary-800'>
<div className='h-full bg-secondary-500' style={{ width: `${Math.floor(progress * 100)}%` }} />
const ProgressBar: React.FC<IProgressBar> = ({ progress, size = 'md' }) => (
<div
className={clsx('h-2.5 w-full overflow-hidden rounded-lg bg-gray-300 dark:bg-primary-800', {
'h-2.5': size === 'md',
'h-[6px]': size === 'sm',
})}
>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress * 100) }}>
{({ width }) => (
<div
className='h-full bg-secondary-500'
style={{ width: `${width}%` }}
/>
)}
</Motion>
</div>
);

View File

@ -46,7 +46,7 @@ const AnimatedTabs: React.FC<IAnimatedInterface> = ({ children, ...rest }) => {
ref={ref}
>
<div
className='absolute h-[3px] w-full bg-primary-200 dark:bg-primary-700'
className='absolute h-[3px] w-full bg-primary-200 dark:bg-gray-800'
style={{ top }}
/>
<div

View File

@ -26,6 +26,8 @@ interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElemen
hasError?: boolean,
/** Whether or not you can resize the teztarea */
isResizeable?: boolean,
/** Textarea theme. */
theme?: 'default' | 'transparent',
}
/** Textarea with custom styles. */
@ -37,6 +39,7 @@ const Textarea = React.forwardRef(({
autoGrow = false,
maxRows = 10,
minRows = 1,
theme = 'default',
...props
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
@ -72,9 +75,10 @@ const Textarea = React.forwardRef(({
ref={ref}
rows={rows}
onChange={handleChange}
className={clsx({
'bg-white dark:bg-transparent shadow-sm block w-full sm:text-sm rounded-md text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
true,
className={clsx('block w-full rounded-md text-gray-900 placeholder:text-gray-600 dark:text-gray-100 dark:placeholder:text-gray-600 sm:text-sm', {
'bg-white dark:bg-transparent shadow-sm border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
theme === 'default',
'bg-transparent border-0 focus:border-0 focus:ring-0': theme === 'transparent',
'font-mono': isCodeEditor,
'text-red-600 border-red-600': hasError,
'resize-none': !isResizeable,