Switch to workspace

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2024-08-28 12:46:03 +02:00
parent 694abcb489
commit 4d5690d0c1
1318 changed files with 12005 additions and 11618 deletions

View File

@@ -0,0 +1,95 @@
import clsx from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import DropdownMenu from 'soapbox/components/dropdown-menu';
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({
collapse: { id: 'accordion.collapse', defaultMessage: 'Collapse' },
expand: { id: 'accordion.expand', defaultMessage: 'Expand' },
});
interface IAccordion {
headline: React.ReactNode;
children?: React.ReactNode;
menu?: Menu;
expanded?: boolean;
onToggle?: (value: boolean) => void;
action?: () => void;
actionIcon?: string;
actionLabel?: string;
}
/**
* Accordion
* An accordion is a vertically stacked group of collapsible sections.
*/
const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded = false, onToggle = () => {}, action, actionIcon, actionLabel }) => {
const intl = useIntl();
const handleToggle = (e: React.MouseEvent<HTMLButtonElement>) => {
onToggle(!expanded);
e.preventDefault();
};
const handleAction = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!action) return;
action();
e.preventDefault();
};
return (
<div className='rounded-lg bg-white text-gray-900 shadow dark:bg-primary-800 dark:text-gray-100 dark:shadow-none'>
<button
type='button'
onClick={handleToggle}
title={intl.formatMessage(expanded ? messages.collapse : messages.expand)}
aria-expanded={expanded}
className='flex w-full items-center justify-between px-4 py-3 font-semibold'
>
<span>{headline}</span>
<HStack alignItems='center' space={2}>
{menu && (
<DropdownMenu
items={menu}
src={require('@tabler/icons/outline/dots-vertical.svg')}
/>
)}
{action && actionIcon && (
<button onClick={handleAction} title={actionLabel}>
<Icon
src={actionIcon}
className='h-5 w-5 text-gray-700 dark:text-gray-600'
/>
</button>
)}
<Icon
src={expanded ? require('@tabler/icons/outline/chevron-up.svg') : require('@tabler/icons/outline/chevron-down.svg')}
className='h-5 w-5 text-gray-700 dark:text-gray-600'
/>
</HStack>
</button>
<div
className={
clsx({
'p-4 rounded-b-lg border-t border-solid border-gray-100 dark:border-primary-900 black:border-black': true,
'h-0 hidden': !expanded,
})
}
>
<Text>{children}</Text>
</div>
</div>
);
};
export { Accordion as default };

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import Avatar from './avatar';
const src = '/static/alice.jpg';
describe('<Avatar />', () => {
it('renders', () => {
render(<Avatar src={src} />);
expect(screen.getByRole('img')).toBeInTheDocument();
});
it('handles size props', () => {
render(<Avatar src={src} size={50} />);
expect(screen.getByTestId('still-image-container').getAttribute('style')).toMatch(/50px/i);
});
});

View File

@@ -0,0 +1,63 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import StillImage, { IStillImage } from 'soapbox/components/still-image';
import Icon from '../icon/icon';
const AVATAR_SIZE = 42;
const messages = defineMessages({
avatar: { id: 'account.avatar.alt', defaultMessage: 'Avatar' },
});
interface IAvatar extends Pick<IStillImage, 'alt' | 'src' | 'onError' | 'className'> {
/** Width and height of the avatar in pixels. */
size?: number;
}
/** Round profile avatar for accounts. */
const Avatar = (props: IAvatar) => {
const intl = useIntl();
const { alt, src, size = AVATAR_SIZE, className } = props;
const [isAvatarMissing, setIsAvatarMissing] = useState<boolean>(false);
const handleLoadFailure = () => setIsAvatarMissing(true);
const style: React.CSSProperties = React.useMemo(() => ({
width: size,
height: size,
}), [size]);
if (isAvatarMissing) {
return (
<div
style={{
width: size,
height: size,
}}
className={clsx('flex items-center justify-center rounded-full bg-gray-200 dark:bg-gray-900', className)}
>
<Icon
src={require('@tabler/icons/outline/photo-off.svg')}
className='h-4 w-4 text-gray-500 dark:text-gray-700'
/>
</div>
);
}
return (
<StillImage
className={clsx('rounded-full', className)}
style={style}
src={src}
alt={alt || intl.formatMessage(messages.avatar)}
onError={handleLoadFailure}
/>
);
};
export { Avatar as default, AVATAR_SIZE };

View File

@@ -0,0 +1,25 @@
import clsx from 'clsx';
import React from 'react';
interface IBanner {
theme: 'frosted' | 'opaque';
children: React.ReactNode;
className?: string;
}
/** Displays a sticky full-width banner at the bottom of the screen. */
const Banner: React.FC<IBanner> = ({ theme, children, className }) => (
<div
data-testid='banner'
className={clsx('fixed inset-x-0 bottom-0 z-50 py-8', {
'backdrop-blur bg-primary-800/80 dark:bg-primary-900/80': theme === 'frosted',
'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-3xl dark:shadow-inset': theme === 'opaque',
}, className)}
>
<div className='mx-auto max-w-4xl px-4'>
{children}
</div>
</div>
);
export { Banner as default };

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { fireEvent, render, screen } from 'soapbox/jest/test-helpers';
import Button from './button';
describe('<Button />', () => {
it('renders the given text', () => {
const text = 'foo';
render(<Button text={text} />);
expect(screen.getByRole('button')).toHaveTextContent(text);
});
it('renders the children', () => {
render(<Button><p>children</p></Button>);
expect(screen.getByRole('button')).toHaveTextContent('children');
});
it('renders the props.text instead of children', () => {
const text = 'foo';
const children = <p>children</p>;
render(<Button text={text}>{children}</Button>);
expect(screen.getByRole('button')).toHaveTextContent('foo');
expect(screen.getByRole('button')).not.toHaveTextContent('children');
});
it('handles click events using the given handler', () => {
const handler = vi.fn();
render(<Button onClick={handler} />);
fireEvent.click(screen.getByRole('button'));
expect(handler.mock.calls.length).toEqual(1);
});
it('does not handle click events if props.disabled given', () => {
const handler = vi.fn();
render(<Button onClick={handler} disabled />);
fireEvent.click(screen.getByRole('button'));
expect(handler.mock.calls.length).toEqual(0);
});
it('renders a disabled attribute if props.disabled given', () => {
render(<Button disabled />);
expect(screen.getByRole('button')).toBeDisabled();
});
it('render full-width button if block prop given', () => {
render(<Button block />);
expect(screen.getByRole('button')).toHaveClass('w-full');
});
it('handles Theme properly', () => {
render(<Button theme='tertiary' />);
expect(screen.getByRole('button')).toHaveClass('bg-transparent border-gray-400');
});
describe('to prop', () => {
it('renders a link', () => {
render(<Button to='/'>link</Button>);
expect(screen.getByRole('link')).toBeInTheDocument();
});
it('does not render a link', () => {
render(<Button>link</Button>);
expect(screen.queryAllByRole('link')).toHaveLength(0);
});
});
describe('icon prop', () => {
it('renders an icon', () => {
render(<Button icon='icon.png'>button</Button>);
expect(screen.getByTestId('icon')).toBeInTheDocument();
});
it('does not render an icon', () => {
render(<Button>button</Button>);
expect(screen.queryAllByTestId('icon')).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,96 @@
import clsx from 'clsx';
import React from 'react';
import { Link } from 'react-router-dom';
import Icon from '../icon/icon';
import { useButtonStyles } from './useButtonStyles';
import type { ButtonSizes, ButtonThemes } from './useButtonStyles';
interface IButton extends Pick<
React.ComponentProps<'button'>,
'children' | 'className' | 'disabled' | 'onClick' | 'onMouseDown' | 'onKeyDown' | 'onKeyPress' | 'title' | 'type'
> {
/** Whether this button expands the width of its container. */
block?: boolean;
/** URL to an SVG icon to render inside the button. */
icon?: string;
/** URL to an SVG icon to render inside the button next to the text. */
secondaryIcon?: string;
/** A predefined button size. */
size?: ButtonSizes;
/** Text inside the button. Takes precedence over `children`. */
text?: React.ReactNode;
/** Makes the button into a navlink, if provided. */
to?: string;
/** Styles the button visually with a predefined theme. */
theme?: ButtonThemes;
}
/** Customizable button element with various themes. */
const Button = React.forwardRef<HTMLButtonElement, IButton>(({
block = false,
children,
disabled = false,
icon,
secondaryIcon,
onClick,
size = 'md',
text,
theme = 'secondary',
to,
type = 'button',
className,
...props
}, ref): JSX.Element => {
const body = text || children;
const themeClass = useButtonStyles({
theme,
block,
disabled,
size,
});
const handleClick: React.MouseEventHandler<HTMLButtonElement> = React.useCallback((event) => {
if (onClick && !disabled) {
onClick(event);
}
}, [onClick, disabled]);
const renderButton = () => (
<button
{...props}
className={clsx('rtl:space-x-reverse', themeClass, className)}
disabled={disabled}
onClick={handleClick}
ref={ref}
type={type}
data-testid='button'
>
{icon ? <Icon src={icon} className='h-4 w-4' /> : null}
{body && (
<span>{body}</span>
)}
{secondaryIcon ? <Icon src={secondaryIcon} className='h-4 w-4' /> : null}
</button>
);
if (to) {
return (
<Link to={to} tabIndex={-1} className='inline-flex'>
{renderButton()}
</Link>
);
}
return renderButton();
});
export {
Button as default,
Button,
};

View File

@@ -0,0 +1,52 @@
import clsx from 'clsx';
const themes = {
primary:
'bg-primary-500 hover:bg-primary-400 dark:hover:bg-primary-600 border-transparent focus:bg-primary-500 text-gray-100 focus:ring-primary-300',
secondary:
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200',
tertiary:
'bg-white dark:bg-primary-900 border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300',
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:ring-danger-500',
transparent: 'border-transparent bg-transparent text-primary-600 dark:text-accent-blue dark:bg-transparent hover:bg-gray-200 dark:hover:bg-gray-800/50',
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
muted: 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-800 dark:text-gray-100 focus:ring-primary-500',
};
const sizes = {
xs: 'gap-x-1.5 px-2 py-1 text-xs',
sm: 'gap-x-2 px-3 py-1.5 text-xs leading-4',
md: 'gap-x-2 px-4 py-2 text-sm',
lg: 'gap-x-2 px-6 py-3 text-base',
};
type ButtonSizes = keyof typeof sizes
type ButtonThemes = keyof typeof themes
type IButtonStyles = {
theme: ButtonThemes;
block: boolean;
disabled: boolean;
size: ButtonSizes;
}
/** Provides class names for the <Button> component. */
const useButtonStyles = ({
theme,
block,
disabled,
size,
}: IButtonStyles) => {
const buttonStyle = clsx({
'inline-flex items-center place-content-center border font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 appearance-none transition-all': true,
'select-none disabled:opacity-75 disabled:cursor-default': disabled,
[`${themes[theme]}`]: true,
[`${sizes[size]}`]: true,
'flex w-full justify-center': block,
});
return buttonStyle;
};
export { useButtonStyles, ButtonSizes, ButtonThemes };

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import { Card, CardBody, CardHeader, CardTitle } from './card';
describe('<Card />', () => {
it('renders the CardTitle and CardBody', () => {
render(
<Card>
<CardHeader>
<CardTitle title='Card Title' />
</CardHeader>
<CardBody>
Card Body
</CardBody>
</Card>,
);
expect(screen.getByTestId('card-title')).toHaveTextContent('Card Title');
expect(screen.getByTestId('card-body')).toHaveTextContent('Card Body');
expect(screen.queryByTestId('back-button')).not.toBeInTheDocument();
});
it('renders the Back Button', () => {
render(
<Card>
<CardHeader backHref='/'>
<CardTitle title='Card Title' />
</CardHeader>
</Card>,
);
expect(screen.getByTestId('back-button')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,111 @@
import clsx from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import HStack from '../hstack/hstack';
import Text from '../text/text';
const sizes = {
md: 'p-4 sm:rounded-xl',
lg: 'p-4 sm:p-6 sm:rounded-xl',
xl: 'p-4 sm:p-10 sm:rounded-3xl',
};
const messages = defineMessages({
back: { id: 'card.back.label', defaultMessage: 'Back' },
});
type CardSizes = keyof typeof sizes
interface ICard {
/** The type of card. */
variant?: 'default' | 'rounded' | 'slim';
/** Card size preset. */
size?: CardSizes;
/** Extra classnames for the <div> element. */
className?: string;
/** Elements inside the card. */
children: React.ReactNode;
tabIndex?: number;
}
/** An opaque backdrop to hold a collection of related elements. */
const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant = 'default', size = 'md', className, ...filteredProps }, ref): JSX.Element => (
<div
ref={ref}
{...filteredProps}
className={clsx({
'bg-white dark:bg-primary-900 black:bg-black text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none': variant === 'rounded',
[sizes[size]]: variant === 'rounded',
'py-4': variant === 'slim',
'black:rounded-none': size !== 'xl',
}, className)}
>
{children}
</div>
));
interface ICardHeader {
backHref?: string;
onBackClick?: (event: React.MouseEvent) => void;
className?: string;
children?: React.ReactNode;
}
/**
* Card header container with back button.
* Typically holds a CardTitle.
*/
const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBackClick }): JSX.Element => {
const intl = useIntl();
const renderBackButton = () => {
if (!backHref && !onBackClick) {
return null;
}
const Comp: React.ElementType = backHref ? Link : 'button';
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
return (
<Comp {...backAttributes} className='rounded-full text-gray-900 focus:ring-2 focus:ring-primary-500 dark:text-gray-100' aria-label={intl.formatMessage(messages.back)}>
<SvgIcon src={require('@tabler/icons/outline/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>
);
};
return (
<HStack alignItems='center' space={2} className={className}>
{renderBackButton()}
{children}
</HStack>
);
};
interface ICardTitle {
title: React.ReactNode;
}
/** A card's title. */
const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
<Text size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
);
interface ICardBody {
/** Classnames for the <div> element. */
className?: string;
/** Children to appear inside the card. */
children: React.ReactNode;
}
/** A card's body. */
const CardBody: React.FC<ICardBody> = ({ className, children }): JSX.Element => (
<div data-testid='card-body' className={className}>{children}</div>
);
export { type CardSizes, Card, CardHeader, CardTitle, CardBody };

View File

@@ -0,0 +1,112 @@
import React, { useEffect, useState } from 'react';
import { useDimensions } from 'soapbox/hooks';
import HStack from '../hstack/hstack';
import Icon from '../icon/icon';
interface ICarousel {
children: any;
/** Optional height to force on controls */
controlsHeight?: number;
/** How many items in the carousel */
itemCount: number;
/** The minimum width per item */
itemWidth: number;
/** Should the controls be disabled? */
isDisabled?: boolean;
}
/**
* Carousel
*/
const Carousel: React.FC<ICarousel> = (props): JSX.Element => {
const { children, controlsHeight, isDisabled, itemCount, itemWidth } = props;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [ref, setContainerRef, { width: finalContainerWidth }] = useDimensions();
const containerWidth = finalContainerWidth || ref?.clientWidth;
const [pageSize, setPageSize] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1);
const numberOfPages = Math.ceil(itemCount / pageSize);
const width = containerWidth / (Math.floor(containerWidth / itemWidth));
const hasNextPage = currentPage < numberOfPages && numberOfPages > 1;
const hasPrevPage = currentPage > 1 && numberOfPages > 1;
const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1);
const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1);
const renderChildren = () => {
if (typeof children === 'function') {
return children({ width: width || 'auto' });
}
return children;
};
useEffect(() => {
if (containerWidth) {
setPageSize(Math.round(containerWidth / width));
}
}, [containerWidth, width]);
return (
<HStack alignItems='stretch'>
<div
className='z-10 flex w-5 items-center justify-center self-stretch rounded-l-xl bg-white dark:bg-primary-900'
style={{
height: controlsHeight || 'auto',
}}
>
<button
data-testid='prev-page'
onClick={handlePrevPage}
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
disabled={!hasPrevPage || isDisabled}
>
<Icon
src={require('@tabler/icons/outline/chevron-left.svg')}
className='h-5 w-5 text-black dark:text-white'
/>
</button>
</div>
<div className='relative w-full overflow-hidden'>
<HStack
alignItems='center'
style={{
transform: `translateX(-${(currentPage - 1) * 100}%)`,
}}
className='transition-all duration-500 ease-out'
ref={setContainerRef}
>
{renderChildren()}
</HStack>
</div>
<div
className='z-10 flex w-5 items-center justify-center self-stretch rounded-r-xl bg-white dark:bg-primary-900'
style={{
height: controlsHeight || 'auto',
}}
>
<button
data-testid='next-page'
onClick={handleNextPage}
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
disabled={!hasNextPage || isDisabled}
>
<Icon
src={require('@tabler/icons/outline/chevron-right.svg')}
className='h-5 w-5 text-black dark:text-white'
/>
</button>
</div>
</HStack>
);
};
export { Carousel as default };

View File

@@ -0,0 +1,15 @@
import React from 'react';
interface ICheckbox extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'disabled' | 'id' | 'name' | 'onChange' | 'checked' | 'required'> { }
/** A pretty checkbox input. */
const Checkbox = React.forwardRef<HTMLInputElement, ICheckbox>((props, ref) => (
<input
{...props}
ref={ref}
type='checkbox'
className='h-4 w-4 rounded border-2 border-gray-300 text-primary-600 focus:ring-primary-500 black:bg-black dark:border-gray-800 dark:bg-gray-900'
/>
));
export { Checkbox as default };

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import { Column } from './column';
describe('<Column />', () => {
it('renders correctly with minimal props', () => {
render(<Column />);
expect(screen.getByRole('button')).toHaveTextContent('Back');
});
});

View File

@@ -0,0 +1,126 @@
import clsx from 'clsx';
import throttle from 'lodash/throttle';
import React, { useCallback, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import Helmet from 'soapbox/components/helmet';
import { useSoapboxConfig } from 'soapbox/hooks';
import { Card, CardBody, CardHeader, CardTitle, type CardSizes } from '../card/card';
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' | 'className' | 'action'>;
/** Contains the column title with optional back button. */
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className, action }) => {
const history = useHistory();
const handleBackClick = () => {
if (backHref) {
history.push(backHref);
return;
}
if (history.length === 1) {
history.push('/');
} else {
history.goBack();
}
};
return (
<CardHeader className={className} onBackClick={handleBackClick}>
<CardTitle title={label} />
{action && (
<div className='flex grow justify-end'>
{action}
</div>
)}
</CardHeader>
);
};
interface IColumn {
/** Route the back button goes to. */
backHref?: string;
/** Column title text. */
label?: string;
/** Whether this column should have a transparent background. */
transparent?: boolean;
/** Whether this column should have a title and back button. */
withHeader?: boolean;
/** Extra class name for top <div> element. */
className?: string;
/** Extra class name for the <CardBody> element. */
bodyClassName?: string;
/** Ref forwarded to column. */
ref?: React.Ref<HTMLDivElement>;
/** Children to display in the column. */
children?: React.ReactNode;
/** Action for the ColumnHeader, displayed at the end. */
action?: React.ReactNode;
/** Column size, inherited from Card. */
size?: CardSizes;
}
/** A backdrop for the main section of the UI. */
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
const { backHref, children, label, transparent = false, withHeader = true, className, bodyClassName, action, size } = props;
const soapboxConfig = useSoapboxConfig();
const [isScrolled, setIsScrolled] = useState(false);
const handleScroll = useCallback(throttle(() => {
setIsScrolled(window.pageYOffset > 32);
}, 50), []);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
return (
<div role='region' className='relative' ref={ref} aria-label={label} column-type={transparent ? 'transparent' : 'filled'}>
<Helmet>
<title>{label}</title>
{soapboxConfig.appleAppId && (
<meta
data-react-helmet='true'
name='apple-itunes-app'
content={`app-id=${soapboxConfig.appleAppId}, app-argument=${location.href}`}
/>
)}
</Helmet>
<Card size={size} variant={transparent ? undefined : 'rounded'} className={className}>
{withHeader && (
<ColumnHeader
label={label}
backHref={backHref}
className={clsx({
'rounded-t-3xl': !isScrolled && !transparent,
'sticky top-0 z-10 bg-white/90 dark:bg-primary-900/90 black:bg-black/80 backdrop-blur': !transparent,
'p-4 sm:p-0 sm:pb-4 black:p-4': transparent,
'-mt-4 -mx-4 p-4': size !== 'lg' && !transparent,
'-mt-4 -mx-4 p-4 sm:-mt-6 sm:-mx-6 sm:p-6': size === 'lg' && !transparent,
})}
action={action}
/>
)}
<CardBody className={bodyClassName}>
{children}
</CardBody>
</Card>
</div>
);
});
export {
type IColumn,
Column,
ColumnHeader,
};

View File

@@ -0,0 +1,31 @@
:root {
--reach-combobox: 1;
}
[data-reach-combobox-popover] {
@apply rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700 z-[100];
}
[data-reach-combobox-list] {
@apply list-none m-0 py-1 px-0 select-none;
}
[data-reach-combobox-option] {
@apply block px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 cursor-pointer;
}
[data-reach-combobox-option][aria-selected="true"] {
@apply bg-gray-100 dark:bg-gray-800;
}
[data-reach-combobox-option]:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
[data-reach-combobox-option][aria-selected="true"]:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
[data-suggested-value] {
@apply font-bold;
}

View File

@@ -0,0 +1,10 @@
import './combobox.css';
export {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption,
ComboboxOptionText,
} from '@reach/combobox';

View File

@@ -0,0 +1,19 @@
import React from 'react';
import AnimatedNumber from 'soapbox/components/animated-number';
interface ICounter {
/** Number this counter should display. */
count: number;
/** Optional max number (ie: N+) */
countMax?: number;
}
/** A simple counter for notifications, etc. */
const Counter: React.FC<ICounter> = ({ count, countMax }) => (
<span className='flex h-5 min-w-[20px] max-w-[26px] items-center justify-center rounded-full bg-secondary-500 text-xs font-medium text-white ring-2 ring-white black:ring-black dark:ring-gray-800'>
<AnimatedNumber value={count} max={countMax} />
</span>
);
export { Counter as default };

View File

@@ -0,0 +1,99 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { queryAllByRole, render, screen } from 'soapbox/jest/test-helpers';
import Datepicker from './datepicker';
describe('<Datepicker />', () => {
it('defaults to the current date', () => {
const handler = vi.fn();
render(<Datepicker onChange={handler} />);
const today = new Date();
expect(screen.getByTestId('datepicker-month')).toHaveValue(String(today.getMonth()));
expect(screen.getByTestId('datepicker-day')).toHaveValue(String(today.getDate()));
expect(screen.getByTestId('datepicker-year')).toHaveValue(String(today.getFullYear()));
});
it('changes number of days based on selected month and year', async() => {
const handler = vi.fn();
render(<Datepicker onChange={handler} />);
await userEvent.selectOptions(
screen.getByTestId('datepicker-month'),
screen.getByRole('option', { name: 'February' }),
);
await userEvent.selectOptions(
screen.getByTestId('datepicker-year'),
screen.getByRole('option', { name: '2020' }),
);
let daySelect: HTMLElement;
daySelect = document.querySelector('[data-testid="datepicker-day"]') as HTMLElement;
expect(queryAllByRole(daySelect, 'option')).toHaveLength(29);
await userEvent.selectOptions(
screen.getByTestId('datepicker-year'),
screen.getByRole('option', { name: '2021' }),
);
daySelect = document.querySelector('[data-testid="datepicker-day"]') as HTMLElement;
expect(queryAllByRole(daySelect, 'option')).toHaveLength(28);
});
it('ranges from the current year to 120 years ago', () => {
const handler = vi.fn();
render(<Datepicker onChange={handler} />);
const today = new Date();
const yearSelect = document.querySelector('[data-testid="datepicker-year"]') as HTMLElement;
expect(queryAllByRole(yearSelect, 'option')).toHaveLength(121);
expect(queryAllByRole(yearSelect, 'option')[0]).toHaveValue(String(today.getFullYear()));
expect(queryAllByRole(yearSelect, 'option')[120]).toHaveValue(String(today.getFullYear() - 120));
});
it('calls the onChange function when the inputs change', async() => {
const handler = vi.fn();
render(<Datepicker onChange={handler} />);
const today = new Date();
/**
* A date with a different day, month, and year than today
* so this test will always pass!
*/
const notToday = new Date(
today.getFullYear() - 1, // last year
(today.getMonth() + 2) % 11, // two months from now (mod 11 because it's 0-indexed)
(today.getDate() + 2) % 28, // 2 days from now (for timezone stuff)
);
const month = notToday.toLocaleString('en-us', { month: 'long' });
const year = String(notToday.getFullYear());
const day = String(notToday.getDate());
expect(handler.mock.calls.length).toEqual(1);
await userEvent.selectOptions(
screen.getByTestId('datepicker-month'),
screen.getByRole('option', { name: month }),
);
expect(handler.mock.calls.length).toEqual(2);
await userEvent.selectOptions(
screen.getByTestId('datepicker-year'),
screen.getByRole('option', { name: year }),
);
expect(handler.mock.calls.length).toEqual(3);
await userEvent.selectOptions(
screen.getByTestId('datepicker-day'),
screen.getByRole('option', { name: day }),
);
expect(handler.mock.calls.length).toEqual(4);
});
});

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useMemo, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import Select from '../select/select';
import Stack from '../stack/stack';
import Text from '../text/text';
const getDaysInMonth = (month: number, year: number) => new Date(year, month + 1, 0).getDate();
const currentYear = new Date().getFullYear();
interface IDatepicker {
onChange(date: Date): void;
}
/**
* Datepicker that allows a user to select month, day, and year.
*/
const Datepicker = ({ onChange }: IDatepicker) => {
const intl = useIntl();
const [month, setMonth] = useState<number>(new Date().getMonth());
const [day, setDay] = useState<number>(new Date().getDate());
const [year, setYear] = useState<number>(new Date().getFullYear());
const numberOfDays = useMemo(() => getDaysInMonth(month, year), [month, year]);
useEffect(() => {
onChange(new Date(year, month, day));
}, [month, day, year]);
return (
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
<div className='sm:col-span-1'>
<Stack>
<Text size='sm' weight='medium' theme='muted'>
<FormattedMessage id='datepicker.month' defaultMessage='Month' />
</Text>
<Select
value={month}
onChange={(event) => setMonth(Number(event.target.value))}
data-testid='datepicker-month'
>
{[...Array(12)].map((_, idx) => (
<option key={idx} value={idx}>
{intl.formatDate(new Date(year, idx, 1), { month: 'long' })}
</option>
))}
</Select>
</Stack>
</div>
<div className='sm:col-span-1'>
<Stack>
<Text size='sm' weight='medium' theme='muted'>
<FormattedMessage id='datepicker.day' defaultMessage='Day' />
</Text>
<Select
value={day}
onChange={(event) => setDay(Number(event.target.value))}
data-testid='datepicker-day'
>
{[...Array(numberOfDays)].map((_, idx) => (
<option key={idx} value={idx + 1}>{idx + 1}</option>
))}
</Select>
</Stack>
</div>
<div className='sm:col-span-1'>
<Stack>
<Text size='sm' weight='medium' theme='muted'>
<FormattedMessage id='datepicker.year' defaultMessage='Year' />
</Text>
<Select
value={year}
onChange={(event) => setYear(Number(event.target.value))}
data-testid='datepicker-year'
>
{[...Array(121)].map((_, idx) => (
<option key={idx} value={currentYear - idx}>{currentYear - idx}</option>
))}
</Select>
</Stack>
</div>
</div>
);
};
export { Datepicker as default };

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import Divider from './divider';
describe('<Divider />', () => {
it('renders without text', () => {
render(<Divider />);
expect(screen.queryAllByTestId('divider-text')).toHaveLength(0);
});
it('renders text', () => {
const text = 'Hello';
render(<Divider text={text} />);
expect(screen.getByTestId('divider-text')).toHaveTextContent(text);
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import Text from '../text/text';
import type { Sizes as TextSizes } from '../text/text';
interface IDivider {
text?: string;
textSize?: TextSizes;
}
/** Divider */
const Divider = ({ text, textSize = 'md' }: IDivider) => (
<div className='relative' data-testid='divider'>
<div className='absolute inset-0 flex items-center' aria-hidden='true'>
<div className='w-full border-t-2 border-solid border-gray-100 black:border-t dark:border-gray-800' />
</div>
{text && (
<div className='relative flex justify-center'>
<span className='bg-white px-2 text-gray-700 black:bg-black dark:bg-gray-900 dark:text-gray-600' data-testid='divider-text'>
<Text size={textSize} tag='span' theme='inherit'>{text}</Text>
</span>
</div>
)}
</div>
);
export { Divider as default };

View File

@@ -0,0 +1,147 @@
import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating-ui/react';
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import EmojiComponent from 'soapbox/components/ui/emoji/emoji';
import HStack from 'soapbox/components/ui/hstack/hstack';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown';
import { useClickOutside, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
import type { Emoji } from 'soapbox/features/emoji';
interface IEmojiButton {
/** Unicode emoji character. */
emoji: string;
/** Event handler when the emoji is clicked. */
onClick(emoji: string): void;
/** Extra class name on the <button> element. */
className?: string;
/** Tab order of the button. */
tabIndex?: number;
}
/** Clickable emoji button that scales when hovered. */
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={handleClick} tabIndex={tabIndex}>
<EmojiComponent className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} />
</button>
);
};
interface IEmojiSelector {
onClose?(): void;
/** Event handler when an emoji is clicked. */
onReact(emoji: string, custom?: string): void;
/** Element that triggers the EmojiSelector Popper */
referenceElement: HTMLElement | null;
placement?: Placement;
/** Whether the selector should be visible. */
visible?: boolean;
offsetOptions?: OffsetOptions;
/** Whether to allow any emoji to be chosen. */
all?: boolean;
}
/** Panel with a row of emoji buttons. */
const EmojiSelector: React.FC<IEmojiSelector> = ({
referenceElement,
onClose,
onReact,
placement = 'top',
visible = false,
offsetOptions,
all = true,
}): JSX.Element => {
const soapboxConfig = useSoapboxConfig();
const { customEmojiReacts } = useFeatures();
const [expanded, setExpanded] = useState(false);
const { x, y, strategy, refs, update } = useFloating<HTMLElement>({
placement,
middleware: [offset(offsetOptions), shift()],
});
const handleExpand: React.MouseEventHandler = () => {
setExpanded(true);
};
const handlePickEmoji = (emoji: Emoji) => {
onReact(emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined);
};
useEffect(() => {
refs.setReference(referenceElement);
}, [referenceElement]);
useEffect(() => () => {
document.body.style.overflow = '';
}, []);
useEffect(() => {
setExpanded(false);
}, [visible]);
useClickOutside(refs, () => {
if (onClose) {
onClose();
}
});
return (
<div
className={clsx('z-[101] transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible,
})}
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
}}
>
{expanded ? (
<EmojiPickerDropdown
visible={expanded}
setVisible={setExpanded}
update={update}
withCustom={customEmojiReacts}
onPickEmoji={handlePickEmoji}
/>
) : (
<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}
/>
))}
{all && (
<IconButton
className='text-gray-600 hover:text-gray-600 dark:hover:text-white'
src={require('@tabler/icons/outline/dots.svg')}
onClick={handleExpand}
/>
)}
</HStack>
)}
</div>
);
};
export { EmojiSelector as default };

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import Emoji from './emoji';
describe('<Emoji />', () => {
it('renders a simple emoji', () => {
render(<Emoji emoji='😀' />);
const img = screen.getByRole('img');
expect(img.getAttribute('src')).toBe('/packs/emoji/1f600.svg');
expect(img.getAttribute('alt')).toBe('😀');
});
// https://emojipedia.org/emoji-flag-sequence/
it('renders a sequence emoji', () => {
render(<Emoji emoji='🇺🇸' />);
const img = screen.getByRole('img');
expect(img.getAttribute('src')).toBe('/packs/emoji/1f1fa-1f1f8.svg');
expect(img.getAttribute('alt')).toBe('🇺🇸');
});
});

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { removeVS16s, toCodePoints } from 'soapbox/utils/emoji';
import { joinPublicPath } from 'soapbox/utils/static';
interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
/** Unicode emoji character. */
emoji?: string;
}
/** A single emoji image. */
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
const { emoji, alt, src, ...rest } = props;
let filename;
if (emoji) {
const codepoints = toCodePoints(removeVS16s(emoji));
filename = codepoints.join('-');
}
if (!filename && !src) return null;
return (
<img
draggable='false'
alt={alt || emoji}
src={src || joinPublicPath(`packs/emoji/${filename}.svg`)}
{...rest}
/>
);
};
export { Emoji as default };

View File

@@ -0,0 +1,14 @@
import React, { forwardRef } from 'react';
interface IFileInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'required' | 'disabled' | 'name' | 'accept'> { }
const FileInput = forwardRef<HTMLInputElement, IFileInput>((props, ref) => (
<input
{...props}
ref={ref}
type='file'
className='block w-full text-sm text-gray-800 file:mr-2 file:cursor-pointer file:rounded-full file:border file:border-solid file:border-gray-200 file:bg-white file:px-3 file:py-1.5 file:text-xs file:font-medium file:leading-4 file:text-gray-700 hover:file:bg-gray-100 dark:text-gray-200 dark:file:border-gray-800 dark:file:bg-gray-900 dark:file:text-gray-500 dark:file:hover:bg-gray-800'
/>
));
export { FileInput as default };

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import FormActions from './form-actions';
describe('<FormActions />', () => {
it('renders successfully', () => {
render(<FormActions><div data-testid='child'>child</div></FormActions>);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,16 @@
import React from 'react';
import HStack from '../hstack/hstack';
interface IFormActions {
children: React.ReactNode;
}
/** Container element to house form actions. */
const FormActions: React.FC<IFormActions> = ({ children }) => (
<HStack space={2} justifyContent='end'>
{children}
</HStack>
);
export { FormActions as default };

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import FormGroup from './form-group';
describe('<FormGroup />', () => {
it('connects the label and input', () => {
render(
<>
<div>
<FormGroup labelText='My label'>
<input type='text' data-testid='winner' />
</FormGroup>
</div>
<div>
<FormGroup labelText='My other label'>
<input type='text' />
</FormGroup>
</div>
</>,
);
expect(screen.getByLabelText('My label')).toHaveAttribute('data-testid');
expect(screen.getByLabelText('My other label')).not.toHaveAttribute('data-testid');
expect(screen.queryByTestId('form-group-error')).not.toBeInTheDocument();
});
it('renders errors', () => {
render(
<FormGroup labelText='My label' errors={['is invalid', 'is required']}>
<input type='text' />
</FormGroup>,
);
expect(screen.getByTestId('form-group-error')).toHaveTextContent('is invalid');
});
it('renders label', () => {
render(
<FormGroup labelText='My label'>
<input type='text' />
</FormGroup>,
);
expect(screen.getByTestId('form-group-label')).toHaveTextContent('My label');
});
it('renders hint', () => {
render(
<FormGroup labelText='My label' hintText='My hint'>
<input type='text' />
</FormGroup>,
);
expect(screen.getByTestId('form-group-hint')).toHaveTextContent('My hint');
});
});

View File

@@ -0,0 +1,114 @@
import React, { useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import Checkbox from '../checkbox/checkbox';
import HStack from '../hstack/hstack';
import Stack from '../stack/stack';
interface IFormGroup {
/** Input label message. */
labelText?: React.ReactNode;
/** Input label tooltip message. */
labelTitle?: string;
/** Input hint message. */
hintText?: React.ReactNode;
/** Input errors. */
errors?: string[];
/** Elements to display within the FormGroup. */
children: React.ReactNode;
}
/** Input container with label. Renders the child. */
const FormGroup: React.FC<IFormGroup> = (props) => {
const { children, errors = [], labelText, labelTitle, hintText } = props;
const formFieldId: string = useMemo(() => `field-${uuidv4()}`, []);
const inputChildren = React.Children.toArray(children);
const hasError = errors?.length > 0;
let firstChild;
if (React.isValidElement(inputChildren[0])) {
firstChild = React.cloneElement(
inputChildren[0],
// @ts-ignore
{ id: formFieldId },
);
}
// @ts-ignore
const isCheckboxFormGroup = firstChild?.type === Checkbox;
if (isCheckboxFormGroup) {
return (
<HStack alignItems='start' space={2}>
{firstChild}
<Stack>
{labelText && (
<label
htmlFor={formFieldId}
data-testid='form-group-label'
className='-mt-0.5 block text-sm font-medium text-gray-900 dark:text-gray-100'
title={labelTitle}
>
{labelText}
</label>
)}
{hasError && (
<div>
<p
data-testid='form-group-error'
className='form-error relative mt-0.5 inline-block rounded-md bg-danger-200 px-2 py-1 text-xs text-danger-900'
>
{errors.join(', ')}
</p>
</div>
)}
{hintText && (
<p data-testid='form-group-hint' className='mt-0.5 text-xs text-gray-700 dark:text-gray-600'>
{hintText}
</p>
)}
</Stack>
</HStack>
);
}
return (
<div>
{labelText && (
<label
htmlFor={formFieldId}
data-testid='form-group-label'
className='block text-sm font-medium text-gray-900 dark:text-gray-100'
title={labelTitle}
>
{labelText}
</label>
)}
<div className='mt-1 dark:text-white'>
{hintText && (
<p data-testid='form-group-hint' className='mb-0.5 text-xs text-gray-700 dark:text-gray-600'>
{hintText}
</p>
)}
{firstChild}
{inputChildren.filter((_, i) => i !== 0)}
{hasError && (
<p
data-testid='form-group-error'
className='form-error relative mt-0.5 inline-block rounded-md bg-danger-200 px-2 py-1 text-xs text-danger-900'
>
{errors.join(', ')}
</p>
)}
</div>
</div>
);
};
export { FormGroup as default };

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { fireEvent, render, screen } from 'soapbox/jest/test-helpers';
import Form from './form';
describe('<Form />', () => {
it('renders children', () => {
const onSubmitMock = vi.fn();
render(
<Form onSubmit={onSubmitMock}>children</Form>,
);
expect(screen.getByTestId('form')).toHaveTextContent('children');
});
it('handles onSubmit prop', () => {
const onSubmitMock = vi.fn();
render(
<Form onSubmit={onSubmitMock}>children</Form>,
);
fireEvent.submit(
screen.getByTestId('form'), {
preventDefault: () => {},
},
);
expect(onSubmitMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
interface IForm {
/** Form submission event handler. */
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.FormEventHandler = React.useCallback((event) => {
event.preventDefault();
if (onSubmit) {
onSubmit(event);
}
}, [onSubmit]);
return (
<form data-testid='form' onSubmit={handleSubmit} className='space-y-4' {...filteredProps}>
{children}
</form>
);
};
export { Form as default };

View File

@@ -0,0 +1,73 @@
import clsx from 'clsx';
import React, { forwardRef } from 'react';
const justifyContentOptions = {
between: 'justify-between',
center: 'justify-center',
start: 'justify-start',
end: 'justify-end',
around: 'justify-around',
};
const alignItemsOptions = {
top: 'items-start',
bottom: 'items-end',
center: 'items-center',
start: 'items-start',
stretch: 'items-stretch',
};
const spaces = {
0: 'gap-0',
[0.5]: 'gap-0.5',
1: 'gap-1',
1.5: 'gap-1.5',
2: 'gap-2',
2.5: 'gap-2.5',
3: 'gap-3',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
8: 'gap-8',
};
interface IHStack extends Pick<React.HTMLAttributes<HTMLDivElement>, 'children' | 'className' | 'onClick' | 'style' | 'title'> {
/** Vertical alignment of children. */
alignItems?: keyof typeof alignItemsOptions;
/** Horizontal alignment of children. */
justifyContent?: keyof typeof justifyContentOptions;
/** Size of the gap between elements. */
space?: keyof typeof spaces;
/** Whether to let the flexbox grow. */
grow?: boolean;
/** HTML element to use for container. */
element?: React.ComponentType | keyof JSX.IntrinsicElements;
/** Whether to let the flexbox wrap onto multiple lines. */
wrap?: boolean;
}
/** Horizontal row of child elements. */
const HStack = forwardRef<HTMLDivElement, IHStack>((props, ref) => {
const { space, alignItems, justifyContent, className, grow, element = 'div', wrap, ...filteredProps } = props;
const Elem = element as 'div';
return (
<Elem
{...filteredProps}
ref={ref}
className={clsx('flex', {
// @ts-ignore
[alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined',
// @ts-ignore
[justifyContentOptions[justifyContent]]: typeof justifyContent !== 'undefined',
// @ts-ignore
[spaces[space]]: typeof space !== 'undefined',
'grow': grow,
'flex-wrap': wrap,
}, className)}
/>
);
});
export { HStack as default };

View File

@@ -0,0 +1,54 @@
import clsx from 'clsx';
import React from 'react';
import SvgIcon from '../icon/svg-icon';
import Text from '../text/text';
interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Class name for the <svg> icon. */
iconClassName?: string;
/** URL to the svg icon. */
src: string;
/** Text to display next to the button. */
text?: string;
/** Predefined styles to display for the button. */
theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent' | 'dark';
/** Override the data-testid */
'data-testid'?: string;
/** URL address */
href?: string;
}
/** A clickable icon. */
const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef<HTMLButtonElement>): JSX.Element => {
const { src, className, iconClassName, text, theme = 'seamless', ...filteredProps } = props;
const Component = (props.href ? 'a' : 'button') as 'button';
return (
<Component
ref={ref}
type='button'
className={clsx('flex items-center space-x-2 rounded-full p-1 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:ring-offset-0', {
'bg-white dark:bg-transparent': theme === 'seamless',
'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500': theme === 'outlined',
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200': theme === 'secondary',
'bg-gray-900 text-white': theme === 'dark',
'opacity-50': filteredProps.disabled,
}, className)}
{...filteredProps}
data-testid={filteredProps['data-testid'] || 'icon-button'}
{...(props.href ? { target: '_blank' } as any : {})}
>
<SvgIcon src={src} className={iconClassName} />
{text ? (
<Text tag='span' theme='inherit' size='sm'>
{text}
</Text>
) : null}
</Component>
);
});
export { IconButton as default };

View File

@@ -0,0 +1,40 @@
import React from 'react';
import Counter from '../counter/counter';
import SvgIcon from './svg-icon';
interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
/** Class name for the <svg> element. */
className?: string;
/** Number to display a counter over the icon. */
count?: number;
/** Optional max to cap count (ie: N+) */
countMax?: number;
/** Tooltip text for the icon. */
alt?: string;
/** URL to the svg icon. */
src: string;
/** Width and height of the icon in pixels. */
size?: number;
/** Override the data-testid */
'data-testid'?: string;
}
/** Renders and SVG icon with optional counter. */
const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => (
<div
className='relative flex shrink-0 flex-col'
data-testid={filteredProps['data-testid'] || 'icon'}
>
{count ? (
<span className='absolute -right-3 -top-2 flex h-5 min-w-[20px] shrink-0 items-center justify-center whitespace-nowrap break-words'>
<Counter count={count} countMax={countMax} />
</span>
) : null}
<SvgIcon src={src} size={size} alt={alt} {...filteredProps} />
</div>
);
export { Icon as default };

View File

@@ -0,0 +1,17 @@
import IconCode from '@tabler/icons/outline/code.svg';
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import SvgIcon from './svg-icon';
describe('<SvgIcon />', () => {
it('renders loading element with default size', async () => {
render(<SvgIcon className='text-primary-500' src={IconCode} />);
const svg = screen.getByTestId('svg-icon-loader');
expect(svg.getAttribute('width')).toBe('24');
expect(svg.getAttribute('height')).toBe('24');
expect(svg.getAttribute('class')).toBe('text-primary-500');
});
});

View File

@@ -0,0 +1,44 @@
import React from 'react';
import InlineSVG, { Props as InlineSVGProps } from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
interface ISvgIcon extends InlineSVGProps {
/** Class name for the <svg> */
className?: string;
/** Tooltip text for the icon. */
alt?: string;
/** URL to the svg file. */
src: string;
/** Width and height of the icon in pixels. */
size?: number;
}
/** Renders an inline SVG with an empty frame loading state */
const SvgIcon: React.FC<ISvgIcon> = ({ src, alt, size = 24, className, ...filteredProps }): JSX.Element => {
const loader = (
<svg
className={className}
width={size}
height={size}
data-src={src}
data-testid='svg-icon-loader'
/>
);
return (
<InlineSVG
className={className}
src={src}
title={alt}
width={size}
height={size}
loader={loader}
data-testid='svg-icon'
{...filteredProps}
>
{/* If the fetch fails, fall back to displaying the loader */}
{loader}
</InlineSVG>
);
};
export { SvgIcon as default };

View File

@@ -0,0 +1,57 @@
export { default as Accordion } from './accordion/accordion';
export { default as Avatar } from './avatar/avatar';
export { default as Banner } from './banner/banner';
export { default as Button } from './button/button';
export { default as Carousel } from './carousel/carousel';
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
export { default as Checkbox } from './checkbox/checkbox';
export { Column, ColumnHeader } from './column/column';
export {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption,
ComboboxOptionText,
} from './combobox/combobox';
export { default as Counter } from './counter/counter';
export { default as Datepicker } from './datepicker/datepicker';
export { default as Divider } from './divider/divider';
export { default as Emoji } from './emoji/emoji';
export { default as EmojiSelector } from './emoji-selector/emoji-selector';
export { default as FileInput } from './file-input/file-input';
export { default as Form } from './form/form';
export { default as FormActions } from './form-actions/form-actions';
export { default as FormGroup } from './form-group/form-group';
export { default as HStack } from './hstack/hstack';
export { default as Icon } from './icon/icon';
export { default as IconButton } from './icon-button/icon-button';
export { default as Input } from './input/input';
export { default as Layout } from './layout/layout';
export {
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuItems,
MenuLink,
MenuList,
} from './menu/menu';
export { default as Modal } from './modal/modal';
export { default as Popover } from './popover/popover';
export { default as Portal } from './portal/portal';
export { default as ProgressBar } from './progress-bar/progress-bar';
export { default as RadioButton } from './radio-button/radio-button';
export { default as Select } from './select/select';
export { default as Slider } from './slider/slider';
export { default as Spinner } from './spinner/spinner';
export { default as Stack } from './stack/stack';
export { default as Streamfield } from './streamfield/streamfield';
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';

View File

@@ -0,0 +1,45 @@
import clsx from 'clsx';
import React from 'react';
interface IInlineMultiselect<T extends string> {
items: Record<T, string>;
value?: T[];
onChange: ((values: T[]) => void);
disabled?: boolean;
}
/** Allows to select many of available options. */
const InlineMultiselect = <T extends string>({ items, value, onChange, disabled }: IInlineMultiselect<T>) => (
<div className='flex w-fit overflow-auto rounded-md border border-gray-400 bg-white black:bg-black dark:border-gray-800 dark:bg-gray-900'>
{Object.entries(items).map(([key, label], i) => {
const checked = value?.includes(key as T);
return (
<label
className={clsx('whitespace-nowrap px-3 py-2 text-white transition-colors hover:bg-primary-700 [&:has(:focus-visible)]:bg-primary-700', {
'cursor-pointer': !disabled,
'opacity-75': disabled,
'bg-gray-500': !checked,
'bg-primary-600': checked,
'border-l border-gray-400 dark:border-gray-800': i !== 0,
})}
key={key}
>
<input
name={key}
type='checkbox'
className='sr-only'
checked={checked}
onChange={({ target }) => onChange((target.checked ? [...(value || []), target.name] : value?.filter(key => key !== target.name) || []) as Array<T>)}
disabled={disabled}
/>
{label as string}
</label>
);
})}
</div>
);
export {
InlineMultiselect,
};

View File

@@ -0,0 +1,141 @@
import clsx from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useLocale } from 'soapbox/hooks';
import { getTextDirection } from 'soapbox/utils/rtl';
import Icon from '../icon/icon';
import SvgIcon from '../icon/svg-icon';
import Tooltip from '../tooltip/tooltip';
const messages = defineMessages({
showPassword: { id: 'input.password.show_password', defaultMessage: 'Show password' },
hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' },
});
/** Possible theme names for an Input. */
type InputThemes = 'normal' | 'search' | 'transparent'
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern' | 'onKeyDown' | 'onKeyUp' | 'onFocus' | 'style' | 'id'> {
/** Put the cursor into the input on mount. */
autoFocus?: boolean;
/** The initial text in the input. */
defaultValue?: string;
/** Extra class names for the <input> element. */
className?: string;
/** Extra class names for the outer <div> element. */
outerClassName?: string;
/** URL to the svg icon. Cannot be used with prepend. */
icon?: string;
/** Internal input name. */
name?: string;
/** Text to display before a value is entered. */
placeholder?: string;
/** Text in the input. */
value?: string | number;
/** Change event handler for the input. */
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
/** 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. */
append?: React.ReactElement;
/** Theme to style the input with. */
theme?: InputThemes;
}
/** Form input element. */
const Input = React.forwardRef<HTMLInputElement, IInput>(
(props, ref) => {
const intl = useIntl();
const locale = useLocale();
const { type = 'text', icon, className, outerClassName, append, prepend, theme = 'normal', ...filteredProps } = props;
const [revealed, setRevealed] = React.useState(false);
const isPassword = type === 'password';
const togglePassword = React.useCallback(() => {
setRevealed((prev) => !prev);
}, []);
return (
<div
className={
clsx('relative', {
'rounded-md': theme !== 'search',
'rounded-full': theme === 'search',
'mt-1': !String(outerClassName).includes('mt-'),
[String(outerClassName)]: typeof outerClassName !== 'undefined',
})
}
>
{icon ? (
<div className='pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3'>
<Icon src={icon} className='h-4 w-4 text-gray-700 dark:text-gray-600' aria-hidden='true' />
</div>
) : null}
{prepend ? (
<div className='absolute inset-y-0 left-0 flex items-center'>
{prepend}
</div>
) : null}
<input
{...filteredProps}
type={revealed ? 'text' : type}
ref={ref}
className={clsx('block w-full text-base placeholder:text-gray-600 focus:border-primary-500 sm:text-sm dark:placeholder:text-gray-600 dark:focus:border-primary-500', {
'ring-1 focus:ring-primary-500 dark:ring-gray-800 dark:focus:ring-primary-500': ['search', 'normal'].includes(theme),
'px-0 border-none !ring-0': theme === 'transparent',
'text-gray-900 dark:text-gray-100': !props.disabled,
'text-gray-600': props.disabled,
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800 black:bg-black': theme === 'normal',
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white dark:focus:bg-gray-900': theme === 'search',
'pr-10 rtl:pl-10 rtl:pr-3': isPassword || append,
'pl-8': typeof icon !== 'undefined',
'pl-16': typeof prepend !== 'undefined',
}, className)}
dir={typeof props.value === 'string' ? getTextDirection(props.value, { fallback: locale.direction }) : undefined}
/>
{append ? (
<div className='absolute inset-y-0 right-0 flex items-center px-3 rtl:left-0 rtl:right-auto'>
{append}
</div>
) : null}
{isPassword ? (
<Tooltip
text={
revealed ?
intl.formatMessage(messages.hidePassword) :
intl.formatMessage(messages.showPassword)
}
>
<div className='absolute inset-y-0 right-0 flex items-center rtl:left-0 rtl:right-auto'>
<button
type='button'
onClick={togglePassword}
tabIndex={-1}
className='h-full px-2 text-gray-700 hover:text-gray-500 focus:ring-2 focus:ring-primary-500 dark:text-gray-600 dark:hover:text-gray-400'
>
<SvgIcon
src={revealed ? require('@tabler/icons/outline/eye-off.svg') : require('@tabler/icons/outline/eye.svg')}
className='h-4 w-4'
/>
</button>
</div>
</Tooltip>
) : null}
</div>
);
},
);
export {
Input as default,
InputThemes,
};

View File

@@ -0,0 +1,66 @@
import clsx from 'clsx';
import React, { Suspense } from 'react';
import StickyBox from 'react-sticky-box';
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<IAside>;
}
/** Layout container, to hold Sidebar, Main, and Aside. */
const Layout: LayoutComponent = ({ children }) => (
<div className='relative flex grow flex-col black:pt-0 sm:pt-4'>
<div className='mx-auto w-full max-w-3xl grow sm:px-6 md:grid md:max-w-7xl md:grid-cols-12 md:gap-8 md:px-8 xl:max-w-[1440px]'>
{children}
</div>
</div>
);
/** Left sidebar container in the UI. */
const Sidebar: React.FC<ISidebar> = ({ children }) => (
<div className='hidden lg:col-span-3 lg:block'>
<StickyBox offsetTop={16} className='pb-4'>
{children}
</StickyBox>
</div>
);
/** Center column container in the UI. */
const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className }) => (
<main
className={clsx({
'md:col-span-12 lg:col-span-9 xl:col-span-6 pb-36 black:border-gray-800 lg:black:border-l xl:black:border-r': true,
}, className)}
>
{children}
</main>
);
/** Right sidebar container in the UI. */
const Aside: React.FC<IAside> = ({ children }) => (
<aside className='hidden xl:col-span-3 xl:block'>
<StickyBox offsetTop={16} className='space-y-6 pb-12'>
<Suspense>
{children}
</Suspense>
</StickyBox>
</aside>
);
Layout.Sidebar = Sidebar;
Layout.Main = Main;
Layout.Aside = Aside;
export { Layout as default };

View File

@@ -0,0 +1,33 @@
[data-reach-menu-popover] {
@apply origin-top-right rtl:origin-top-left absolute mt-2 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-gray-800 focus:outline-none z-[1003];
}
[data-reach-menu-button] {
@apply focus:ring-primary-500 focus:ring-2 focus:ring-offset-2;
}
div:focus[data-reach-menu-list] {
outline: none;
}
[data-reach-menu-item][data-selected] {
@apply bg-gray-100 dark:bg-gray-800;
}
[data-reach-menu-list] {
@apply py-1;
}
[data-reach-menu-item],
[data-reach-menu-link] {
@apply block px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 cursor-pointer;
}
[data-reach-menu-link] {
@apply hover:bg-gray-100 dark:hover:bg-gray-800;
}
[data-reach-menu-item][data-disabled],
[data-reach-menu-link][data-disabled] {
@apply opacity-25 cursor-default;
}

View File

@@ -0,0 +1,42 @@
import {
Menu,
MenuButton,
MenuItem,
MenuItems,
MenuPopover,
MenuLink,
MenuListProps,
} from '@reach/menu-button';
import { positionDefault, positionRight } from '@reach/popover';
import clsx from 'clsx';
import React from 'react';
import './menu.css';
interface IMenuList extends Omit<MenuListProps, 'position'> {
/** Position of the dropdown menu. */
position?: 'left' | 'right';
className?: string;
}
/** Renders children as a dropdown menu. */
const MenuList: React.FC<IMenuList> = (props) => {
const { position, className, ...filteredProps } = props;
return (
<MenuPopover position={props.position === 'left' ? positionDefault : positionRight}>
<MenuItems
onKeyDown={(event) => event.nativeEvent.stopImmediatePropagation()}
className={
clsx(className, 'shadow-menu rounded-lg bg-white py-1 black:bg-black dark:bg-primary-900')
}
{...filteredProps}
/>
</MenuPopover>
);
};
/** Divides menu items. */
const MenuDivider = () => <hr className='mx-2 my-1 border-t-2 border-gray-100 black:border-t dark:border-gray-800' />;
export { Menu, MenuButton, MenuDivider, MenuItems, MenuItem, MenuList, MenuLink };

View File

@@ -0,0 +1,139 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import Modal from './modal';
describe('<Modal />', () => {
it('renders', () => {
render(<Modal title='Modal title' />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('renders children', () => {
render(<Modal title='Modal title'><div data-testid='child' /></Modal>);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
it('focuses the primary action', () => {
render(
<Modal
title='Modal title'
confirmationAction={() => null}
confirmationText='Click me'
/>,
);
expect(screen.getByRole('button')).toHaveFocus();
});
describe('onClose prop', () => {
it('renders the Icon to close the modal', async() => {
const mockFn = vi.fn();
const user = userEvent.setup();
render(<Modal title='Modal title' onClose={mockFn} />);
expect(screen.getByTestId('icon-button')).toBeInTheDocument();
expect(mockFn).not.toBeCalled();
await user.click(screen.getByTestId('icon-button'));
expect(mockFn).toBeCalled();
});
it('does not render the Icon to close the modal', () => {
render(<Modal title='Modal title' />);
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
describe('confirmationAction prop', () => {
it('renders the confirmation button', async() => {
const mockFn = vi.fn();
const user = userEvent.setup();
render(
<Modal
title='Modal title'
confirmationAction={mockFn}
confirmationText='Click me'
/>,
);
expect(mockFn).not.toBeCalled();
await user.click(screen.getByRole('button'));
expect(mockFn).toBeCalled();
});
it('does not render the actions to', () => {
render(<Modal title='Modal title' />);
expect(screen.queryAllByTestId('modal-actions')).toHaveLength(0);
});
describe('with secondaryAction', () => {
it('renders the secondary button', async() => {
const confirmationAction = vi.fn();
const secondaryAction = vi.fn();
const user = userEvent.setup();
render(
<Modal
title='Modal title'
confirmationAction={confirmationAction}
confirmationText='Primary'
secondaryAction={secondaryAction}
secondaryText='Secondary'
/>,
);
await user.click(screen.getByText(/secondary/i));
expect(secondaryAction).toBeCalled();
expect(confirmationAction).not.toBeCalled();
});
it('does not render the secondary action', () => {
render(
<Modal
title='Modal title'
confirmationAction={() => null}
confirmationText='Click me'
/>,
);
expect(screen.queryAllByRole('button')).toHaveLength(1);
});
});
describe('with cancelAction', () => {
it('renders the cancel button', async() => {
const confirmationAction = vi.fn();
const cancelAction = vi.fn();
const user = userEvent.setup();
render(
<Modal
title='Modal title'
confirmationAction={confirmationAction}
confirmationText='Primary'
secondaryAction={cancelAction}
secondaryText='Cancel'
/>,
);
await user.click(screen.getByText(/cancel/i));
expect(cancelAction).toBeCalled();
expect(confirmationAction).not.toBeCalled();
});
it('does not render the cancel action', () => {
render(
<Modal
title='Modal title'
confirmationAction={() => null}
confirmationText='Click me'
/>,
);
expect(screen.queryAllByRole('button')).toHaveLength(1);
});
});
});
});

View File

@@ -0,0 +1,185 @@
import clsx from 'clsx';
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import Button from '../button/button';
import { ButtonThemes } from '../button/useButtonStyles';
import HStack from '../hstack/hstack';
import IconButton from '../icon-button/icon-button';
const messages = defineMessages({
back: { id: 'card.back.label', defaultMessage: 'Back' },
close: { id: 'lightbox.close', defaultMessage: 'Close' },
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
});
const widths = {
xs: 'max-w-xs',
sm: 'max-w-sm',
md: 'max-w-base',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
};
interface IModal {
/** Callback when the modal is cancelled. */
cancelAction?: () => void;
/** Cancel button text. */
cancelText?: React.ReactNode;
/** URL to an SVG icon for the close button. */
closeIcon?: string;
/** Position of the close button. */
closePosition?: 'left' | 'right';
/** Callback when the modal is confirmed. */
confirmationAction?: (event?: React.MouseEvent<HTMLButtonElement>) => void;
/** Whether the confirmation button is disabled. */
confirmationDisabled?: boolean;
/** Confirmation button text. */
confirmationText?: React.ReactNode;
/** Confirmation button theme. */
confirmationTheme?: ButtonThemes;
/** Whether to use full width style for confirmation button. */
confirmationFullWidth?: boolean;
/** Callback when the modal is closed. */
onClose?: () => void;
/** Callback when the secondary action is chosen. */
secondaryAction?: (event?: React.MouseEvent<HTMLButtonElement>) => void;
/** Secondary button text. */
secondaryText?: React.ReactNode;
secondaryDisabled?: boolean;
/** Don't focus the "confirm" button on mount. */
skipFocus?: boolean;
/** Title text for the modal. */
title?: React.ReactNode;
width?: keyof typeof widths;
children?: React.ReactNode;
className?: string;
onBack?: () => void;
}
/** Displays a modal dialog box. */
const Modal = React.forwardRef<HTMLDivElement, IModal>(({
cancelAction,
cancelText,
children,
closeIcon = require('@tabler/icons/outline/x.svg'),
closePosition = 'right',
confirmationAction,
confirmationDisabled,
confirmationText,
confirmationTheme,
confirmationFullWidth,
onClose,
secondaryAction,
secondaryDisabled = false,
secondaryText,
skipFocus = false,
title,
width = 'xl',
className,
onBack,
}, ref) => {
const intl = useIntl();
const buttonRef = React.useRef<HTMLButtonElement>(null);
const [firstRender, setFirstRender] = React.useState(true);
React.useEffect(() => {
setFirstRender(false);
}, []);
React.useEffect(() => {
if (buttonRef?.current && !skipFocus) {
buttonRef.current.focus();
}
}, [skipFocus, buttonRef]);
return (
<div
ref={ref}
data-testid='modal'
className={clsx(className, 'pointer-events-auto relative mx-auto flex max-h-[90vh] w-full flex-col overflow-auto rounded-2xl bg-white text-start align-middle text-gray-900 shadow-xl transition-all ease-in-out black:bg-black md:max-h-[80vh] dark:bg-primary-900 dark:text-gray-100', widths[width], {
'bottom-0': !firstRender,
'-bottom-32': firstRender,
})}
>
{title && (
<div className='sticky top-0 z-10 bg-white/75 p-6 pb-2 backdrop-blur black:bg-black/80 dark:bg-primary-900/75'>
<div
className={clsx('flex w-full items-center gap-2', {
'flex-row-reverse': closePosition === 'left',
})}
>
{onBack && (
<IconButton
src={require('@tabler/icons/outline/arrow-left.svg')}
title={intl.formatMessage(messages.back)}
onClick={onBack}
className='text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200'
/>
)}
<h3 className='grow truncate text-lg font-bold leading-6 text-gray-900 dark:text-white'>
{title}
</h3>
{onClose && (
<IconButton
src={closeIcon}
title={intl.formatMessage(messages.close)}
onClick={onClose}
className='text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200'
/>
)}
</div>
</div>
)}
<div className={clsx('p-6', { 'pt-0': title })}>
<div className='w-full'>
{children}
</div>
{confirmationAction && (
<HStack className='mt-5' justifyContent='between' data-testid='modal-actions'>
<div className={clsx({ 'grow': !confirmationFullWidth })}>
{cancelAction && (
<Button
theme='tertiary'
onClick={cancelAction}
>
{cancelText || <FormattedMessage id='common.cancel' defaultMessage='Cancel' />}
</Button>
)}
</div>
<HStack space={2} className={clsx({ 'grow': confirmationFullWidth })}>
{secondaryAction && (
<Button
theme='secondary'
onClick={secondaryAction}
disabled={secondaryDisabled}
>
{secondaryText}
</Button>
)}
<Button
theme={confirmationTheme || 'primary'}
onClick={confirmationAction}
disabled={confirmationDisabled}
ref={buttonRef}
block={confirmationFullWidth}
>
{confirmationText}
</Button>
</HStack>
</HStack>
)}
</div>
</div>
);
});
export { Modal as default };

View File

@@ -0,0 +1,120 @@
import {
arrow,
autoPlacement,
FloatingArrow,
offset,
useClick,
useDismiss,
useFloating,
useHover,
useInteractions,
useTransitionStyles,
} from '@floating-ui/react';
import clsx from 'clsx';
import React, { useRef, useState } from 'react';
import Portal from '../portal/portal';
interface IPopover {
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>;
/** The content of the popover */
content: React.ReactNode;
/** Should we remove padding on the Popover */
isFlush?: boolean;
/** Should the popover trigger via click or hover */
interaction?: 'click' | 'hover';
/** Add a class to the reference (trigger) element */
referenceElementClassName?: string;
}
/**
* Popover
*
* 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 [isOpen, setIsOpen] = useState<boolean>(false);
const arrowRef = useRef<SVGSVGElement>(null);
const { x, y, strategy, refs, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'top',
middleware: [
autoPlacement({
allowedPlacements: ['top', 'bottom'],
}),
offset(10),
arrow({
element: arrowRef,
}),
],
});
const { isMounted, styles } = useTransitionStyles(context, {
initial: {
opacity: 0,
transform: 'scale(0.8)',
},
duration: {
open: 200,
close: 200,
},
});
const click = useClick(context, { enabled: interaction === 'click' });
const hover = useHover(context, { enabled: interaction === 'hover' });
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
hover,
dismiss,
]);
return (
<>
{React.cloneElement(children, {
ref: refs.setReference,
...getReferenceProps(),
className: clsx(children.props.className, referenceElementClassName),
})}
{(isMounted) && (
<Portal>
<div
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
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}
<FloatingArrow
ref={arrowRef}
context={context}
className='-ml-2 fill-white dark:hidden' /** -ml-2 to fix offcenter arrow */
tipRadius={3}
/>
</div>
</Portal>
)}
</>
);
};
export { Popover as default };

View File

@@ -0,0 +1,30 @@
import React, { useLayoutEffect, useState } from 'react';
import ReactDOM from 'react-dom';
interface IPortal {
children: React.ReactNode;
}
/**
* Portal
*/
const Portal: React.FC<IPortal> = ({ children }) => {
const [isRendered, setIsRendered] = useState<boolean>(false);
useLayoutEffect(() => {
setIsRendered(true);
}, []);
if (!isRendered) {
return null;
}
return (
ReactDOM.createPortal(
children,
document.getElementById('plfe') as HTMLDivElement,
)
);
};
export { Portal as default };

View File

@@ -0,0 +1,33 @@
import clsx from 'clsx';
import React from 'react';
import { spring } from 'react-motion';
import Motion from 'soapbox/features/ui/util/optional-motion';
interface IProgressBar {
/** 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, 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>
);
export { ProgressBar as default };

View File

@@ -0,0 +1,39 @@
import React, { useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import HStack from '../hstack/hstack';
interface IRadioButton {
value: string;
checked?: boolean;
name: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
label: React.ReactNode;
}
/**
* A group for radio input with label.
*/
const RadioButton: React.FC<IRadioButton> = ({ name, value, checked, onChange, label }) => {
const formFieldId: string = useMemo(() => `radio-${uuidv4()}`, []);
return (
<HStack alignItems='center' space={3}>
<input
type='radio'
name={name}
id={formFieldId}
value={value}
checked={checked}
onChange={onChange}
className='h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500'
/>
<label htmlFor={formFieldId} className='block text-sm font-medium text-gray-700'>
{label}
</label>
</HStack>
);
};
export { RadioButton as default };

View File

@@ -0,0 +1,30 @@
import clsx from 'clsx';
import React from 'react';
interface ISelect extends React.SelectHTMLAttributes<HTMLSelectElement> {
children: Iterable<React.ReactNode>;
full?: boolean;
}
/** Multiple-select dropdown. */
const Select = React.forwardRef<HTMLSelectElement, ISelect>((props, ref) => {
const { children, className, full = true, ...filteredProps } = props;
return (
<select
ref={ref}
className={clsx(
'truncate rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-primary-500 focus:outline-none focus:ring-primary-500 disabled:opacity-50 black:bg-black sm:text-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:focus:border-primary-500 dark:focus:ring-primary-500',
className,
{
'w-full': full,
},
)}
{...filteredProps}
>
{children}
</select>
);
});
export { Select as default };

View File

@@ -0,0 +1,123 @@
import throttle from 'lodash/throttle';
import React, { useRef } from 'react';
type Point = { x: number; y: number };
interface ISlider {
/** Value between 0 and 1. */
value: number;
/** Callback when the value changes. */
onChange(value: number): void;
}
/** Draggable slider component. */
const Slider: React.FC<ISlider> = ({ value, onChange }) => {
const node = useRef<HTMLDivElement>(null);
const handleMouseDown: React.MouseEventHandler = e => {
document.addEventListener('mousemove', handleMouseSlide, true);
document.addEventListener('mouseup', handleMouseUp, true);
document.addEventListener('touchmove', handleMouseSlide, true);
document.addEventListener('touchend', handleMouseUp, true);
handleMouseSlide(e);
e.preventDefault();
e.stopPropagation();
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseSlide, true);
document.removeEventListener('mouseup', handleMouseUp, true);
document.removeEventListener('touchmove', handleMouseSlide, true);
document.removeEventListener('touchend', handleMouseUp, true);
};
const handleMouseSlide = throttle(e => {
if (node.current) {
const { x } = getPointerPosition(node.current, e);
if (!isNaN(x)) {
let slideamt = x;
if (x > 1) {
slideamt = 1;
} else if (x < 0) {
slideamt = 0;
}
onChange(slideamt);
}
}
}, 60);
return (
<div
className='relative inline-flex h-6 cursor-pointer transition'
onMouseDown={handleMouseDown}
ref={node}
>
<div className='absolute top-1/2 h-1 w-full -translate-y-1/2 rounded-full bg-primary-200 dark:bg-primary-700' />
<div className='absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-accent-500' style={{ width: `${value * 100}%` }} />
<span
className='absolute top-1/2 z-10 -ml-1.5 h-3 w-3 -translate-y-1/2 rounded-full bg-accent-500 shadow'
tabIndex={0}
style={{ left: `${value * 100}%` }}
/>
</div>
);
};
const findElementPosition = (el: HTMLElement) => {
let box;
if (el.getBoundingClientRect && el.parentNode) {
box = el.getBoundingClientRect();
}
if (!box) {
return {
left: 0,
top: 0,
};
}
const docEl = document.documentElement;
const body = document.body;
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
const scrollLeft = window.pageXOffset || body.scrollLeft;
const left = (box.left + scrollLeft) - clientLeft;
const clientTop = docEl.clientTop || body.clientTop || 0;
const scrollTop = window.pageYOffset || body.scrollTop;
const top = (box.top + scrollTop) - clientTop;
return {
left: Math.round(left),
top: Math.round(top),
};
};
const getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Point => {
const box = findElementPosition(el);
const boxW = el.offsetWidth;
const boxH = el.offsetHeight;
const boxY = box.top;
const boxX = box.left;
let pageY = event.pageY;
let pageX = event.pageX;
if (event.changedTouches) {
pageX = event.changedTouches[0].pageX;
pageY = event.changedTouches[0].pageY;
}
return {
y: Math.max(0, Math.min(1, (pageY - boxY) / boxH)),
x: Math.max(0, Math.min(1, (pageX - boxX) / boxW)),
};
};
export { Slider as default };

View File

@@ -0,0 +1,93 @@
/**
* iOS style loading spinner.
* Adapted from: https://loading.io/css/
* With some help scaling it: https://signalvnoise.com/posts/2577-loading-spinner-animation-using-css-and-webkit
*/
.spinner {
@apply inline-block relative w-20 h-20;
}
.spinner > div {
@apply absolute origin-[50%_50%] w-full h-full;
animation: spinner 1.2s linear infinite;
}
.spinner > div::after {
@apply block absolute rounded-full bg-gray-700 dark:bg-gray-400;
content: ' ';
top: 3.75%;
left: 46.25%;
width: 7.5%;
height: 22.5%;
}
.spinner > div:nth-child(1) {
transform: rotate(0deg);
animation-delay: -1.1s;
}
.spinner > div:nth-child(2) {
transform: rotate(30deg);
animation-delay: -1s;
}
.spinner > div:nth-child(3) {
transform: rotate(60deg);
animation-delay: -0.9s;
}
.spinner > div:nth-child(4) {
transform: rotate(90deg);
animation-delay: -0.8s;
}
.spinner > div:nth-child(5) {
transform: rotate(120deg);
animation-delay: -0.7s;
}
.spinner > div:nth-child(6) {
transform: rotate(150deg);
animation-delay: -0.6s;
}
.spinner > div:nth-child(7) {
transform: rotate(180deg);
animation-delay: -0.5s;
}
.spinner > div:nth-child(8) {
transform: rotate(210deg);
animation-delay: -0.4s;
}
.spinner > div:nth-child(9) {
transform: rotate(240deg);
animation-delay: -0.3s;
}
.spinner > div:nth-child(10) {
transform: rotate(270deg);
animation-delay: -0.2s;
}
.spinner > div:nth-child(11) {
transform: rotate(300deg);
animation-delay: -0.1s;
}
.spinner > div:nth-child(12) {
transform: rotate(330deg);
animation-delay: 0s;
}
@keyframes spinner {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Stack from '../stack/stack';
import Text from '../text/text';
import './spinner.css';
interface ISpinner {
/** Width and height of the spinner in pixels. */
size?: number;
/** Whether to display "Loading..." beneath the spinner. */
withText?: boolean;
}
/** Spinning loading placeholder. */
const Spinner = ({ size = 30, withText = true }: ISpinner) => (
<Stack space={2} justifyContent='center' alignItems='center'>
<div className='spinner' style={{ width: size, height: size }}>
{Array.from(Array(12).keys()).map(i => (
<div key={i}>&nbsp;</div>
))}
</div>
{withText && (
<Text theme='muted' tracking='wide'>
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading…' />
</Text>
)}
</Stack>
);
export { Spinner as default };

View File

@@ -0,0 +1,68 @@
import clsx from 'clsx';
import React from 'react';
const spaces = {
0: 'gap-y-0',
[0.5]: 'gap-y-0.5',
1: 'gap-y-1',
[1.5]: 'gap-y-1.5',
2: 'gap-y-2',
3: 'gap-y-3',
4: 'gap-y-4',
5: 'gap-y-5',
6: 'gap-y-6',
9: 'gap-y-9',
10: 'gap-y-10',
};
const justifyContentOptions = {
between: 'justify-between',
center: 'justify-center',
end: 'justify-end',
};
const alignItemsOptions = {
top: 'items-start',
bottom: 'items-end',
center: 'items-center',
start: 'items-start',
end: 'items-end',
};
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
/** Horizontal alignment of children. */
alignItems?: keyof typeof alignItemsOptions;
/** Vertical alignment of children. */
justifyContent?: keyof typeof justifyContentOptions;
/** Size of the gap between elements. */
space?: keyof typeof spaces;
/** Whether to let the flexbox grow. */
grow?: boolean;
/** HTML element to use for container. */
element?: React.ComponentType | keyof JSX.IntrinsicElements;
}
/** Vertical stack of child elements. */
const Stack = React.forwardRef<HTMLDivElement, IStack>((props, ref: React.LegacyRef<HTMLDivElement> | undefined) => {
const { space, alignItems, justifyContent, className, grow, element = 'div', ...filteredProps } = props;
const Elem = element as 'div';
return (
<Elem
{...filteredProps}
ref={ref}
className={clsx('flex flex-col', {
// @ts-ignore
[spaces[space]]: typeof space !== 'undefined',
// @ts-ignore
[alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined',
// @ts-ignore
[justifyContentOptions[justifyContent]]: typeof justifyContent !== 'undefined',
'grow': grow,
}, className)}
/>
);
});
export { Stack as default };

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { useIntl, defineMessages } from 'react-intl';
import Button from '../button/button';
import HStack from '../hstack/hstack';
import IconButton from '../icon-button/icon-button';
import Stack from '../stack/stack';
import Text from '../text/text';
const messages = defineMessages({
add: { id: 'streamfield.add', defaultMessage: 'Add' },
remove: { id: 'streamfield.remove', defaultMessage: 'Remove' },
});
/** Type of the inner Streamfield input component. */
type StreamfieldComponent<T> = React.ComponentType<{
value: T;
onChange: (value: T) => void;
autoFocus: boolean;
}>;
interface IStreamfield {
/** Array of values for the streamfield. */
values: any[];
/** Input label message. */
label?: React.ReactNode;
/** Input hint message. */
hint?: React.ReactNode;
/** Callback to add an item. */
onAddItem?: () => void;
/** Callback to remove an item by index. */
onRemoveItem?: (i: number) => void;
/** Callback when values are changed. */
onChange: (values: any[]) => void;
/** Input to render for each value. */
component: StreamfieldComponent<any>;
/** Minimum number of allowed inputs. */
minItems?: number;
/** Maximum number of allowed inputs. */
maxItems?: number;
}
/** List of inputs that can be added or removed. */
const Streamfield: React.FC<IStreamfield> = ({
values,
label,
hint,
onAddItem,
onRemoveItem,
onChange,
component: Component,
maxItems = Infinity,
minItems = 0,
}) => {
const intl = useIntl();
const handleChange = (i: number) => (value: any) => {
const newData = [...values];
newData[i] = value;
onChange(newData);
};
return (
<Stack space={4}>
<Stack>
{label && <Text size='sm' weight='medium'>{label}</Text>}
{hint && <Text size='xs' theme='muted'>{hint}</Text>}
</Stack>
{(values.length > 0) && (
<Stack space={1}>
{values.map((value, i) => value?._destroy ? null : (
<HStack space={2} alignItems='center'>
<Component
key={i}
onChange={handleChange(i)}
value={value}
autoFocus={i > 0}
/>
{values.length > minItems && onRemoveItem && (
<IconButton
iconClassName='h-4 w-4'
className='bg-transparent text-gray-600 hover:text-gray-600'
src={require('@tabler/icons/outline/x.svg')}
onClick={() => onRemoveItem(i)}
title={intl.formatMessage(messages.remove)}
/>
)}
</HStack>
))}
</Stack>
)}
{(onAddItem && (values.length < maxItems)) && (
<Button
onClick={onAddItem}
theme='secondary'
block
>
{intl.formatMessage(messages.add)}
</Button>
)}
</Stack>
);
};
export { type StreamfieldComponent, Streamfield as default };

View File

@@ -0,0 +1,22 @@
:root {
--reach-tabs: 1;
}
[data-reach-tabs] {
@apply relative pb-[3px];
}
[data-reach-tab-list] {
@apply flex;
}
[data-reach-tab] {
@apply flex-1 flex justify-center items-center
py-4 px-1 text-center font-medium text-sm text-gray-700
dark:text-gray-600 hover:text-gray-800 dark:hover:text-gray-500
focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:ring-gray-800 dark:ring-offset-0 dark:focus-visible:ring-primary-500;
}
[data-reach-tab][data-selected] {
@apply text-gray-900 dark:text-gray-100;
}

View File

@@ -0,0 +1,182 @@
import { useRect } from '@reach/rect';
import {
Tabs as ReachTabs,
TabList as ReachTabList,
Tab as ReachTab,
useTabsContext,
} from '@reach/tabs';
import clsx from 'clsx';
import React from 'react';
import { useHistory } from 'react-router-dom';
import Counter from '../counter/counter';
import './tabs.css';
const HORIZONTAL_PADDING = 8;
const AnimatedContext = React.createContext(null);
interface IAnimatedInterface {
/** Callback when a tab is chosen. */
onChange(index: number): void;
/** Default tab index. */
defaultIndex: number;
children: React.ReactNode;
}
/** Tabs with a sliding active state. */
const AnimatedTabs: React.FC<IAnimatedInterface> = ({ children, ...rest }) => {
const [activeRect, setActiveRect] = React.useState(null);
const ref = React.useRef();
const rect = useRect(ref);
// @ts-ignore
const top: number = (activeRect && activeRect.bottom) - (rect && rect.top);
// @ts-ignore
const width: number = activeRect && activeRect.width - HORIZONTAL_PADDING * 2;
// @ts-ignore
const left: number = (activeRect && activeRect.left) - (rect && rect.left) + HORIZONTAL_PADDING;
return (
// @ts-ignore
<AnimatedContext.Provider value={setActiveRect}>
<ReachTabs
{...rest}
// @ts-ignore
ref={ref}
>
<div
className='absolute h-[3px] w-full bg-primary-200 dark:bg-gray-800'
style={{ top }}
/>
<div
className={clsx('absolute h-[3px] bg-primary-500 transition-all duration-200', {
'hidden': top <= 0,
})}
style={{ left, top, width }}
/>
{children}
</ReachTabs>
</AnimatedContext.Provider>
);
};
interface IAnimatedTab {
/** ARIA role. */
role: 'button';
/** Element to represent the tab. */
as: 'a' | 'button';
/** Route to visit when the tab is chosen. */
href?: string;
/** Tab title text. */
title: string;
/** Index value of the tab. */
index: number;
}
/** A single animated tab. */
const AnimatedTab: React.FC<IAnimatedTab> = ({ index, ...props }) => {
// get the currently selected index from useTabsContext
const { selectedIndex } = useTabsContext();
const isSelected: boolean = selectedIndex === index;
// measure the size of our element, only listen to rect if active
const ref = React.useRef();
const rect = useRect(ref, { observe: isSelected });
// get the style changing function from context
const setActiveRect = React.useContext(AnimatedContext);
// callup to set styles whenever we're active
React.useLayoutEffect(() => {
if (isSelected) {
// @ts-ignore
setActiveRect(rect);
}
}, [isSelected, rect, setActiveRect]);
return (
// @ts-ignore
<ReachTab ref={ref} {...props} />
);
};
/** Structure to represent a tab. */
type Item = {
/** Tab text. */
text: React.ReactNode;
/** Tab tooltip text. */
title?: string;
/** URL to visit when the tab is selected. */
href?: string;
/** Route to visit when the tab is selected. */
to?: string;
/** Callback when the tab is selected. */
action?: () => void;
/** Display a counter over the tab. */
count?: number;
/** Unique name for this tab. */
name: string;
}
interface ITabs {
/** Array of structured tab items. */
items: Item[];
/** Name of the active tab item. */
activeItem: string;
}
/** Animated tabs component. */
const Tabs = ({ items, activeItem }: ITabs) => {
const defaultIndex = items.findIndex(({ name }) => name === activeItem);
const history = useHistory();
const onChange = (selectedIndex: number) => {
const item = items[selectedIndex];
if (typeof item.action === 'function') {
item.action();
} else if (item.to) {
history.push(item.to);
}
};
const renderItem = (item: Item, idx: number) => {
const { name, text, title, count } = item;
return (
<AnimatedTab
key={name}
as='button'
role='button'
// @ts-ignore
title={title}
index={idx}
>
<div className='relative'>
{count ? (
<span className='absolute left-full ml-2'>
<Counter count={count} />
</span>
) : null}
{text}
</div>
</AnimatedTab>
);
};
return (
<AnimatedTabs onChange={onChange} defaultIndex={defaultIndex}>
<ReachTabList>
{items.map((item, i) => renderItem(item, i))}
</ReachTabList>
</AnimatedTabs>
);
};
export {
type Item,
Tabs as default,
};

View File

@@ -0,0 +1,70 @@
import React, { useState } from 'react';
import HStack from '../hstack/hstack';
import Tag from './tag';
interface ITagInput {
tags: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
}
/** Manage a list of tags. */
// https://blog.logrocket.com/building-a-tag-input-field-component-for-react/
const TagInput: React.FC<ITagInput> = ({ tags, onChange, placeholder }) => {
const [input, setInput] = useState('');
const handleTagDelete = (tag: string) => {
onChange(tags.filter(item => item !== tag));
};
const handleKeyDown: React.KeyboardEventHandler = (e) => {
const { key } = e;
const trimmedInput = input.trim();
if (key === 'Tab') {
e.preventDefault();
}
if ([',', 'Tab', 'Enter'].includes(key) && trimmedInput.length && !tags.includes(trimmedInput)) {
e.preventDefault();
onChange([...tags, trimmedInput]);
setInput('');
}
if (key === 'Backspace' && !input.length && tags.length) {
e.preventDefault();
const tagsCopy = [...tags];
tagsCopy.pop();
onChange(tagsCopy);
}
};
return (
<div className='relative mt-1 grow shadow-sm'>
<HStack
className='block w-full rounded-md border-gray-400 bg-white p-2 pb-0 text-gray-900 placeholder:text-gray-600 focus:border-primary-500 focus:ring-primary-500 sm:text-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus:border-primary-500 dark:focus:ring-primary-500'
space={2}
wrap
>
{tags.map((tag, i) => (
<div className='mb-2'>
<Tag tag={tag} onDelete={handleTagDelete} />
</div>
))}
<input
className='mb-2 h-8 w-32 grow bg-transparent p-1 outline-none'
value={input}
placeholder={placeholder}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
</HStack>
</div>
);
};
export { TagInput as default };

View File

@@ -0,0 +1,26 @@
import React from 'react';
import IconButton from '../icon-button/icon-button';
import Text from '../text/text';
interface ITag {
/** Name of the tag. */
tag: string;
/** Callback when the X icon is pressed. */
onDelete: (tag: string) => void;
}
/** A single editable Tag (used by TagInput). */
const Tag: React.FC<ITag> = ({ tag, onDelete }) => (
<div className='inline-flex items-center whitespace-nowrap rounded bg-primary-500 p-1'>
<Text theme='white'>{tag}</Text>
<IconButton
iconClassName='h-4 w-4'
src={require('@tabler/icons/outline/x.svg')}
onClick={() => onDelete(tag)}
/>
</div>
);
export { Tag as default };

View File

@@ -0,0 +1,140 @@
import clsx from 'clsx';
import React from 'react';
const themes = {
default: 'text-gray-900 dark:text-gray-100',
danger: 'text-danger-600',
primary: 'text-primary-600 dark:text-accent-blue',
muted: 'text-gray-700 dark:text-gray-600',
subtle: 'text-gray-400 dark:text-gray-500',
success: 'text-success-600',
inherit: 'text-inherit',
white: 'text-white',
};
const weights = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
const sizes = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base leading-5',
lg: 'text-lg',
xl: 'text-xl',
'2xl': 'text-2xl',
'3xl': 'text-3xl',
};
const alignments = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};
const trackingSizes = {
normal: 'tracking-normal',
wide: 'tracking-wide',
};
const transformProperties = {
normal: 'normal-case',
uppercase: 'uppercase',
};
const families = {
sans: 'font-sans',
mono: 'font-mono',
};
type Sizes = keyof typeof sizes
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' | 'div' | 'blockquote'
type Directions = 'ltr' | 'rtl'
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML' | 'tabIndex' | 'lang'> {
/** Text content. */
children?: React.ReactNode;
/** How to align the text. */
align?: keyof typeof alignments;
/** Extra class names for the outer element. */
className?: string;
/** Text direction. */
direction?: Directions;
/** Typeface of the text. */
family?: keyof typeof families;
/** The "for" attribute specifies which form element a label is bound to. */
htmlFor?: string;
/** Font size of the text. */
size?: Sizes;
/** HTML element name of the outer element. */
tag?: Tags;
/** Theme for the text. */
theme?: keyof typeof themes;
/** Letter-spacing of the text. */
tracking?: keyof typeof trackingSizes;
/** Transform (eg uppercase) for the text. */
transform?: keyof typeof transformProperties;
/** Whether to truncate the text if its container is too small. */
truncate?: boolean;
/** Font weight of the text. */
weight?: keyof typeof weights;
/** Tooltip title. */
title?: string;
/** Extra CSS styles */
style?: React.CSSProperties;
}
/** UI-friendly text container with dark mode support. */
const Text = React.forwardRef<any, IText>(
(props, ref) => {
const {
align,
className,
direction,
family = 'sans',
size = 'md',
tag = 'p',
theme = 'default',
tracking = 'normal',
transform = 'normal',
truncate = false,
weight = 'normal',
...filteredProps
} = props;
const Comp: React.ElementType = tag;
const alignmentClass = typeof align === 'string' ? alignments[align] : '';
return (
<Comp
{...filteredProps}
ref={ref}
style={{
textDecoration: tag === 'abbr' ? 'underline dotted' : undefined,
direction,
}}
className={clsx({
'cursor-default': tag === 'abbr',
truncate: truncate,
[sizes[size]]: true,
[themes[theme]]: true,
[weights[weight]]: true,
[trackingSizes[tracking]]: true,
[families[family]]: true,
[alignmentClass]: typeof align !== 'undefined',
[transformProperties[transform]]: typeof transform !== 'undefined',
}, className)}
/>
);
},
);
export {
type Sizes,
type IText,
Text as default,
};

View File

@@ -0,0 +1,121 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useLocale } from 'soapbox/hooks';
import { getTextDirection } from 'soapbox/utils/rtl';
import Stack from '../stack/stack';
import Text from '../text/text';
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'id' | 'maxLength' | 'onChange' | 'onClick' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
/** Put the cursor into the input on mount. */
autoFocus?: boolean;
/** Allows the textarea height to grow while typing */
autoGrow?: boolean;
/** Used with "autoGrow". Sets a max number of rows. */
maxRows?: number;
/** Used with "autoGrow". Sets a min number of rows. */
minRows?: number;
/** The initial text in the input. */
defaultValue?: string;
/** Internal input name. */
name?: string;
/** Renders the textarea as a code editor. */
isCodeEditor?: boolean;
/** Text to display before a value is entered. */
placeholder?: string;
/** Text in the textarea. */
value?: string;
/** Whether the device should autocomplete text in this textarea. */
autoComplete?: string;
/** Whether to display the textarea in red. */
hasError?: boolean;
/** Whether or not you can resize the teztarea */
isResizeable?: boolean;
/** Textarea theme. */
theme?: 'default' | 'transparent';
/** Whether to display a character counter below the textarea. */
withCounter?: boolean;
}
/** Textarea with custom styles. */
const Textarea = React.forwardRef(({
isCodeEditor = false,
hasError = false,
isResizeable = true,
onChange,
autoGrow = false,
maxRows = 10,
minRows = 1,
rows: initialRows = 4,
theme = 'default',
maxLength,
value,
...props
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
const length = value?.length || 0;
const [rows, setRows] = useState<number>(autoGrow ? minRows : initialRows);
const locale = useLocale();
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
if (autoGrow) {
const textareaLineHeight = 20;
const previousRows = event.target.rows;
event.target.rows = minRows;
const currentRows = ~~(event.target.scrollHeight / textareaLineHeight);
if (currentRows === previousRows) {
event.target.rows = currentRows;
}
if (currentRows >= maxRows) {
event.target.rows = maxRows;
event.target.scrollTop = event.target.scrollHeight;
}
setRows(currentRows < maxRows ? currentRows : maxRows);
}
if (onChange) {
onChange(event);
}
};
return (
<Stack space={1.5}>
<textarea
{...props}
value={value}
ref={ref}
rows={rows}
onChange={handleChange}
className={clsx('block w-full rounded-md text-gray-900 placeholder:text-gray-600 sm:text-sm dark:text-gray-100 dark:placeholder:text-gray-600', {
'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,
})}
dir={value?.length ? getTextDirection(value, { fallback: locale.direction }) : undefined}
/>
{maxLength && (
<div className='text-right rtl:text-left'>
<Text size='xs' theme={maxLength - length < 0 ? 'danger' : 'muted'}>
<FormattedMessage
id='textarea.counter.label'
defaultMessage='{count} characters remaining'
values={{ count: maxLength - length }}
/>
</Text>
</div>
)}
</Stack>
);
},
);
export { Textarea as default };

View File

@@ -0,0 +1,159 @@
import clsx 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';
import Stack from '../stack/stack';
import Text from '../text/text';
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;
summary?: string;
}
/**
* Customizable Toasts for in-app notifications.
*/
const Toast = (props: IToast) => {
const { t, message, type, action, actionLink, actionLabel, summary } = props;
const dismissToast = () => toast.dismiss(t.id);
const renderIcon = () => {
switch (type) {
case 'success':
return (
<Icon
src={require('@tabler/icons/outline/circle-check.svg')}
className='h-6 w-6 text-success-500 dark:text-success-400'
aria-hidden
/>
);
case 'info':
return (
<Icon
src={require('@tabler/icons/outline/info-circle.svg')}
className='h-6 w-6 text-primary-600 dark:text-accent-blue'
aria-hidden
/>
);
case 'error':
return (
<Icon
src={require('@tabler/icons/outline/alert-circle.svg')}
className='h-6 w-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={
clsx({
'p-4 pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white black:bg-black dark:bg-gray-900 shadow-lg dark:ring-2 dark:ring-gray-800': true,
'animate-enter': t.visible,
'animate-leave': !t.visible,
})
}
>
<Stack space={2}>
<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='shrink-0'>
{renderIcon()}
</div>
<Text
size='sm'
data-testid='toast-message'
className='pt-0.5'
weight={typeof summary === 'undefined' ? 'normal' : 'medium'}
>
{renderText(message)}
</Text>
</HStack>
{/* Action */}
{renderAction()}
</HStack>
{/* Dismiss Button */}
<div className='flex shrink-0 pt-0.5'>
<button
type='button'
className='inline-flex rounded-md text-gray-600 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:text-gray-600 dark:hover:text-gray-500'
onClick={dismissToast}
data-testid='toast-dismiss'
>
<span className='sr-only'><FormattedMessage id='lightbox.close' defaultMessage='Close' /></span>
<Icon src={require('@tabler/icons/outline/x.svg')} className='h-5 w-5' />
</button>
</div>
</HStack>
{summary ? (
<Text theme='muted' size='sm'>{summary}</Text>
) : null}
</Stack>
</div>
);
};
export { Toast as default };

View File

@@ -0,0 +1,55 @@
import clsx from 'clsx';
import React, { useRef } from 'react';
interface IToggle extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'id' | 'name' | 'checked' | 'onChange' | 'required' | 'disabled'> {
size?: 'sm' | 'md';
}
/** A glorified checkbox. */
const Toggle: React.FC<IToggle> = ({ id, size = 'md', name, checked = false, onChange, required, disabled }) => {
const input = useRef<HTMLInputElement>(null);
const handleClick: React.MouseEventHandler<HTMLButtonElement> = () => {
input.current?.focus();
input.current?.click();
};
return (
<button
className={clsx('flex-none rounded-full focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:ring-gray-800 dark:ring-offset-0 dark:focus:ring-primary-500', {
'bg-gray-500': !checked && !disabled,
'bg-primary-600': checked && !disabled,
'bg-gray-200': !checked && disabled,
'bg-primary-200': checked && disabled,
'w-9 p-0.5': size === 'sm',
'w-11 p-0.5': size === 'md',
'cursor-default': disabled,
})}
onClick={handleClick}
type='button'
>
<div className={clsx('rounded-full bg-white transition-transform', {
'h-4.5 w-4.5': size === 'sm',
'translate-x-3.5 rtl:-translate-x-3.5': size === 'sm' && checked,
'h-6 w-6': size === 'md',
'translate-x-4 rtl:-translate-x-4': size === 'md' && checked,
})}
/>
<input
id={id}
ref={input}
name={name}
type='checkbox'
className='sr-only'
checked={checked}
onChange={onChange}
required={required}
disabled={disabled}
tabIndex={-1}
/>
</button>
);
};
export { Toggle as default };

View File

@@ -0,0 +1,94 @@
import {
arrow,
FloatingArrow,
FloatingPortal,
offset,
useFloating,
useHover,
useInteractions,
useTransitionStyles,
} from '@floating-ui/react';
import React, { useRef, useState } from 'react';
interface ITooltip {
/** Element to display the tooltip around. */
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>;
/** Text to display in the tooltip. */
text: string;
/** If disabled, it will render the children without wrapping them. */
disabled?: boolean;
}
/**
* Tooltip
*/
const Tooltip: React.FC<ITooltip> = (props) => {
const { children, text, disabled = false } = props;
const [isOpen, setIsOpen] = useState<boolean>(false);
const arrowRef = useRef<SVGSVGElement>(null);
const { x, y, strategy, refs, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'top',
middleware: [
offset(6),
arrow({
element: arrowRef,
}),
],
});
const hover = useHover(context);
const { isMounted, styles } = useTransitionStyles(context, {
initial: {
opacity: 0,
transform: 'scale(0.8)',
},
duration: {
open: 200,
close: 200,
},
});
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
]);
if (disabled) {
return children;
}
return (
<>
{React.cloneElement(children, {
ref: refs.setReference,
...getReferenceProps(),
})}
{(isMounted) && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
...styles,
}}
className='pointer-events-none z-[100] whitespace-nowrap rounded bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-100 shadow dark:bg-gray-100 dark:text-gray-900'
{...getFloatingProps()}
>
{text}
<FloatingArrow ref={arrowRef} context={context} className='fill-gray-800 dark:fill-gray-100' />
</div>
</FloatingPortal>
)}
</>
);
};
export { Tooltip as default };

View File

@@ -0,0 +1,65 @@
import React from 'react';
import HStack from 'soapbox/components/ui/hstack/hstack';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import Stack from 'soapbox/components/ui/stack/stack';
import Text from 'soapbox/components/ui/text/text';
interface IWidgetTitle {
/** Title text for the widget. */
title: React.ReactNode;
}
/** Title of a widget. */
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<IWidgetBody> = ({ children }): JSX.Element => (
<Stack space={3}>{children}</Stack>
);
interface IWidget {
/** Widget title text. */
title: React.ReactNode;
/** Callback when the widget action is clicked. */
onActionClick?: () => void;
/** URL to the svg icon for the widget action. */
actionIcon?: string;
/** Text for the action. */
actionTitle?: string;
action?: JSX.Element;
children?: React.ReactNode;
}
/** Sidebar widget. */
const Widget: React.FC<IWidget> = ({
title,
children,
onActionClick,
actionIcon = require('@tabler/icons/outline/arrow-right.svg'),
actionTitle,
action,
}): JSX.Element => (
<Stack space={4}>
<HStack alignItems='center' justifyContent='between'>
<WidgetTitle title={title} />
{action || (onActionClick && (
<IconButton
className='ml-2 h-6 w-6 text-black rtl:rotate-180 dark:text-white'
src={actionIcon}
onClick={onActionClick}
title={actionTitle}
/>
))}
</HStack>
<WidgetBody>{children}</WidgetBody>
</Stack>
);
export { Widget as default };