Merge remote-tracking branch 'soapbox/develop' into mastodon-groups
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
@@ -2,9 +2,12 @@ import classNames from 'clsx';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { HStack, Icon, Text } from 'soapbox/components/ui';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
|
||||
|
||||
import HStack from '../hstack/hstack';
|
||||
import Icon from '../icon/icon';
|
||||
import Text from '../text/text';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown-menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
||||
@@ -49,6 +49,8 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
|
||||
className,
|
||||
} = props;
|
||||
|
||||
const body = text || children;
|
||||
|
||||
const themeClass = useButtonStyles({
|
||||
theme,
|
||||
block,
|
||||
@@ -61,10 +63,10 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Icon src={icon} className='mr-2 w-4 h-4' />;
|
||||
return <Icon src={icon} className='w-4 h-4' />;
|
||||
};
|
||||
|
||||
const handleClick = React.useCallback((event) => {
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = React.useCallback((event) => {
|
||||
if (onClick && !disabled) {
|
||||
onClick(event);
|
||||
}
|
||||
@@ -72,7 +74,7 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
|
||||
|
||||
const renderButton = () => (
|
||||
<button
|
||||
className={classNames(themeClass, className)}
|
||||
className={classNames('space-x-2 rtl:space-x-reverse', themeClass, className)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
ref={ref}
|
||||
@@ -80,7 +82,10 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
|
||||
data-testid='button'
|
||||
>
|
||||
{renderIcon()}
|
||||
{text || children}
|
||||
|
||||
{body && (
|
||||
<span>{body}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ interface ICardHeader {
|
||||
backHref?: string,
|
||||
onBackClick?: (event: React.MouseEvent) => void
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +65,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
|
||||
|
||||
return (
|
||||
<Comp {...backAttributes} className='text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2' aria-label={intl.formatMessage(messages.back)}>
|
||||
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6' />
|
||||
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' />
|
||||
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
|
||||
</Comp>
|
||||
);
|
||||
@@ -91,6 +92,8 @@ const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
|
||||
interface ICardBody {
|
||||
/** Classnames for the <div> element. */
|
||||
className?: string
|
||||
/** Children to appear inside the card. */
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** A card's body. */
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface IColumn {
|
||||
className?: string,
|
||||
/** Ref forwarded to column. */
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
/** Children to display in the column. */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/** A backdrop for the main section of the UI. */
|
||||
|
||||
@@ -20,7 +20,7 @@ const Datepicker = ({ onChange }: IDatepicker) => {
|
||||
|
||||
const [month, setMonth] = useState<number>(new Date().getMonth());
|
||||
const [day, setDay] = useState<number>(new Date().getDate());
|
||||
const [year, setYear] = useState<number>(2022);
|
||||
const [year, setYear] = useState<number>(new Date().getFullYear());
|
||||
|
||||
const numberOfDays = useMemo(() => {
|
||||
return getDaysInMonth(month, year);
|
||||
|
||||
@@ -2,8 +2,12 @@ import React from 'react';
|
||||
|
||||
import HStack from '../hstack/hstack';
|
||||
|
||||
interface IFormActions {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Container element to house form actions. */
|
||||
const FormActions: React.FC = ({ children }) => (
|
||||
const FormActions: React.FC<IFormActions> = ({ children }) => (
|
||||
<HStack space={2} justifyContent='end'>
|
||||
{children}
|
||||
</HStack>
|
||||
|
||||
@@ -3,8 +3,6 @@ import React from 'react';
|
||||
import { render, screen } from '../../../../jest/test-helpers';
|
||||
import FormGroup from '../form-group';
|
||||
|
||||
jest.mock('uuid', () => jest.requireActual('uuid'));
|
||||
|
||||
describe('<FormGroup />', () => {
|
||||
it('connects the label and input', () => {
|
||||
render(
|
||||
|
||||
@@ -14,6 +14,8 @@ interface IFormGroup {
|
||||
hintText?: React.ReactNode,
|
||||
/** Input errors. */
|
||||
errors?: string[]
|
||||
/** Elements to display within the FormGroup. */
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Input container with label. Renders the child. */
|
||||
@@ -27,7 +29,7 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
|
||||
if (React.isValidElement(inputChildren[0])) {
|
||||
firstChild = React.cloneElement(
|
||||
inputChildren[0],
|
||||
{ id: formFieldId, hasError },
|
||||
{ id: formFieldId },
|
||||
);
|
||||
}
|
||||
const isCheckboxFormGroup = firstChild?.type === Checkbox;
|
||||
|
||||
@@ -5,11 +5,13 @@ interface IForm {
|
||||
onSubmit?: (event: React.FormEvent) => void,
|
||||
/** Class name override for the <form> element. */
|
||||
className?: string,
|
||||
/** Elements to display within the Form. */
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
/** Form element with custom styles. */
|
||||
const Form: React.FC<IForm> = ({ onSubmit, children, ...filteredProps }) => {
|
||||
const handleSubmit = React.useCallback((event) => {
|
||||
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (onSubmit) {
|
||||
|
||||
@@ -49,6 +49,7 @@ export { default as Tabs } from './tabs/tabs';
|
||||
export { default as TagInput } from './tag-input/tag-input';
|
||||
export { default as Text } from './text/text';
|
||||
export { default as Textarea } from './textarea/textarea';
|
||||
export { default as Toast } from './toast/toast';
|
||||
export { default as Toggle } from './toggle/toggle';
|
||||
export { default as Tooltip } from './tooltip/tooltip';
|
||||
export { default as Widget } from './widget/widget';
|
||||
|
||||
@@ -33,8 +33,6 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
|
||||
value?: string | number,
|
||||
/** Change event handler for the input. */
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
||||
/** Whether to display the input in red. */
|
||||
hasError?: boolean,
|
||||
/** An element to display as prefix to input. Cannot be used with icon. */
|
||||
prepend?: React.ReactElement,
|
||||
/** An element to display as suffix to input. Cannot be used with password type. */
|
||||
@@ -48,7 +46,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
||||
(props, ref) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { type = 'text', icon, className, outerClassName, hasError, append, prepend, theme = 'normal', ...filteredProps } = props;
|
||||
const { type = 'text', icon, className, outerClassName, append, prepend, theme = 'normal', ...filteredProps } = props;
|
||||
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
|
||||
@@ -91,7 +89,6 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
||||
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
|
||||
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
|
||||
'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append,
|
||||
'text-red-600 border-red-600': hasError,
|
||||
'pl-8': typeof icon !== 'undefined',
|
||||
'pl-16': typeof prepend !== 'undefined',
|
||||
}, className)}
|
||||
|
||||
@@ -2,10 +2,21 @@ import classNames from 'clsx';
|
||||
import React from 'react';
|
||||
import StickyBox from 'react-sticky-box';
|
||||
|
||||
interface LayoutComponent extends React.FC {
|
||||
Sidebar: React.FC,
|
||||
interface ISidebar {
|
||||
children: React.ReactNode
|
||||
}
|
||||
interface IAside {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
interface ILayout {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface LayoutComponent extends React.FC<ILayout> {
|
||||
Sidebar: React.FC<ISidebar>,
|
||||
Main: React.FC<React.HTMLAttributes<HTMLDivElement>>,
|
||||
Aside: React.FC,
|
||||
Aside: React.FC<IAside>,
|
||||
}
|
||||
|
||||
/** Layout container, to hold Sidebar, Main, and Aside. */
|
||||
@@ -18,7 +29,7 @@ const Layout: LayoutComponent = ({ children }) => (
|
||||
);
|
||||
|
||||
/** Left sidebar container in the UI. */
|
||||
const Sidebar: React.FC = ({ children }) => (
|
||||
const Sidebar: React.FC<ISidebar> = ({ children }) => (
|
||||
<div className='hidden lg:block lg:col-span-3'>
|
||||
<StickyBox offsetTop={80} className='pb-4'>
|
||||
{children}
|
||||
@@ -38,7 +49,7 @@ const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, classN
|
||||
);
|
||||
|
||||
/** Right sidebar container in the UI. */
|
||||
const Aside: React.FC = ({ children }) => (
|
||||
const Aside: React.FC<IAside> = ({ children }) => (
|
||||
<aside className='hidden xl:block xl:col-span-3'>
|
||||
<StickyBox offsetTop={80} className='space-y-6 pb-12'>
|
||||
{children}
|
||||
|
||||
@@ -54,6 +54,7 @@ interface IModal {
|
||||
/** Title text for the modal. */
|
||||
title?: React.ReactNode,
|
||||
width?: keyof typeof widths,
|
||||
children?: React.ReactNode,
|
||||
}
|
||||
|
||||
/** Displays a modal dialog box. */
|
||||
@@ -104,7 +105,7 @@ const Modal: React.FC<IModal> = ({
|
||||
src={closeIcon}
|
||||
title={intl.formatMessage(messages.close)}
|
||||
onClick={onClose}
|
||||
className='text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200'
|
||||
className='text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200 rtl:rotate-180'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const Select = React.forwardRef<HTMLSelectElement, ISelect>((props, ref) => {
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
className={`w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-800 focus:outline-none focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:focus:ring-primary-500 dark:focus:border-primary-500 sm:text-sm rounded-md disabled:opacity-50 ${className}`}
|
||||
className={`w-full pl-3 pr-10 py-2 text-base truncate border-gray-300 dark:border-gray-800 focus:outline-none focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:focus:ring-primary-500 dark:focus:border-primary-500 sm:text-sm rounded-md disabled:opacity-50 ${className}`}
|
||||
{...filteredProps}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface IAnimatedInterface {
|
||||
onChange(index: number): void,
|
||||
/** Default tab index. */
|
||||
defaultIndex: number
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Tabs with a sliding active state. */
|
||||
|
||||
145
app/soapbox/components/ui/toast/toast.tsx
Normal file
145
app/soapbox/components/ui/toast/toast.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import classNames from 'clsx';
|
||||
import React from 'react';
|
||||
import toast, { Toast as RHToast } from 'react-hot-toast';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { ToastText, ToastType } from 'soapbox/toast';
|
||||
|
||||
import HStack from '../hstack/hstack';
|
||||
import Icon from '../icon/icon';
|
||||
|
||||
const renderText = (text: ToastText) => {
|
||||
if (typeof text === 'string') {
|
||||
return text;
|
||||
} else {
|
||||
return <FormattedMessage {...text} />;
|
||||
}
|
||||
};
|
||||
|
||||
interface IToast {
|
||||
t: RHToast
|
||||
message: ToastText
|
||||
type: ToastType
|
||||
action?(): void
|
||||
actionLink?: string
|
||||
actionLabel?: ToastText
|
||||
}
|
||||
|
||||
/**
|
||||
* Customizable Toasts for in-app notifications.
|
||||
*/
|
||||
const Toast = (props: IToast) => {
|
||||
const { t, message, type, action, actionLink, actionLabel } = props;
|
||||
|
||||
const dismissToast = () => toast.dismiss(t.id);
|
||||
|
||||
const renderIcon = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return (
|
||||
<Icon
|
||||
src={require('@tabler/icons/circle-check.svg')}
|
||||
className='w-6 h-6 text-success-500 dark:text-success-400'
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
case 'info':
|
||||
return (
|
||||
<Icon
|
||||
src={require('@tabler/icons/info-circle.svg')}
|
||||
className='w-6 h-6 text-primary-600 dark:text-accent-blue'
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Icon
|
||||
src={require('@tabler/icons/alert-circle.svg')}
|
||||
className='w-6 h-6 text-danger-600'
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderAction = () => {
|
||||
const classNames = 'mt-0.5 flex-shrink-0 rounded-full text-sm font-medium text-primary-600 dark:text-accent-blue hover:underline focus:outline-none';
|
||||
|
||||
if (action && actionLabel) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames}
|
||||
onClick={() => {
|
||||
dismissToast();
|
||||
action();
|
||||
}}
|
||||
data-testid='toast-action'
|
||||
>
|
||||
{renderText(actionLabel)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (actionLink && actionLabel) {
|
||||
return (
|
||||
<Link
|
||||
to={actionLink}
|
||||
onClick={dismissToast}
|
||||
className={classNames}
|
||||
data-testid='toast-action-link'
|
||||
>
|
||||
{renderText(actionLabel)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid='toast'
|
||||
className={
|
||||
classNames({
|
||||
'p-4 pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white dark:bg-gray-900 shadow-lg dark:ring-2 dark:ring-gray-800': true,
|
||||
'animate-enter': t.visible,
|
||||
'animate-leave': !t.visible,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HStack space={4} alignItems='start'>
|
||||
<HStack space={3} justifyContent='between' alignItems='start' className='w-0 flex-1'>
|
||||
<HStack space={3} alignItems='start' className='w-0 flex-1'>
|
||||
<div className='flex-shrink-0'>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
|
||||
<p className='pt-0.5 text-sm text-gray-900 dark:text-gray-100' data-testid='toast-message'>
|
||||
{renderText(message)}
|
||||
</p>
|
||||
</HStack>
|
||||
|
||||
{/* Action */}
|
||||
{renderAction()}
|
||||
</HStack>
|
||||
|
||||
{/* Dismiss Button */}
|
||||
<div className='flex flex-shrink-0 pt-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
className='inline-flex rounded-md text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
|
||||
onClick={dismissToast}
|
||||
data-testid='toast-dismiss'
|
||||
>
|
||||
<span className='sr-only'>Close</span>
|
||||
<Icon src={require('@tabler/icons/x.svg')} className='w-5 h-5' />
|
||||
</button>
|
||||
</div>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
@@ -7,6 +7,8 @@ import './tooltip.css';
|
||||
interface ITooltip {
|
||||
/** Text to display in the tooltip. */
|
||||
text: string,
|
||||
/** Element to display the tooltip around. */
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
const centered = (triggerRect: any, tooltipRect: any) => {
|
||||
|
||||
@@ -12,8 +12,12 @@ const WidgetTitle = ({ title }: IWidgetTitle): JSX.Element => (
|
||||
<Text size='xl' weight='bold' tag='h1'>{title}</Text>
|
||||
);
|
||||
|
||||
interface IWidgetBody {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Body of a widget. */
|
||||
const WidgetBody: React.FC = ({ children }): JSX.Element => (
|
||||
const WidgetBody: React.FC<IWidgetBody> = ({ children }): JSX.Element => (
|
||||
<Stack space={3}>{children}</Stack>
|
||||
);
|
||||
|
||||
@@ -27,6 +31,7 @@ interface IWidget {
|
||||
/** Text for the action. */
|
||||
actionTitle?: string,
|
||||
action?: JSX.Element,
|
||||
children?: React.ReactNode,
|
||||
}
|
||||
|
||||
/** Sidebar widget. */
|
||||
@@ -44,7 +49,7 @@ const Widget: React.FC<IWidget> = ({
|
||||
<WidgetTitle title={title} />
|
||||
{action || (onActionClick && (
|
||||
<IconButton
|
||||
className='w-6 h-6 ml-2 text-black dark:text-white'
|
||||
className='w-6 h-6 ml-2 text-black dark:text-white rtl:rotate-180'
|
||||
src={actionIcon}
|
||||
onClick={onActionClick}
|
||||
title={actionTitle}
|
||||
|
||||
Reference in New Issue
Block a user