95
packages/pl-fe/src/components/ui/accordion/accordion.tsx
Normal file
95
packages/pl-fe/src/components/ui/accordion/accordion.tsx
Normal 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 };
|
||||
21
packages/pl-fe/src/components/ui/avatar/avatar.test.tsx
Normal file
21
packages/pl-fe/src/components/ui/avatar/avatar.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
63
packages/pl-fe/src/components/ui/avatar/avatar.tsx
Normal file
63
packages/pl-fe/src/components/ui/avatar/avatar.tsx
Normal 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 };
|
||||
25
packages/pl-fe/src/components/ui/banner/banner.tsx
Normal file
25
packages/pl-fe/src/components/ui/banner/banner.tsx
Normal 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 };
|
||||
91
packages/pl-fe/src/components/ui/button/button.test.tsx
Normal file
91
packages/pl-fe/src/components/ui/button/button.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
packages/pl-fe/src/components/ui/button/button.tsx
Normal file
96
packages/pl-fe/src/components/ui/button/button.tsx
Normal 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,
|
||||
};
|
||||
52
packages/pl-fe/src/components/ui/button/useButtonStyles.ts
Normal file
52
packages/pl-fe/src/components/ui/button/useButtonStyles.ts
Normal 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 };
|
||||
37
packages/pl-fe/src/components/ui/card/card.test.tsx
Normal file
37
packages/pl-fe/src/components/ui/card/card.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
111
packages/pl-fe/src/components/ui/card/card.tsx
Normal file
111
packages/pl-fe/src/components/ui/card/card.tsx
Normal 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 };
|
||||
112
packages/pl-fe/src/components/ui/carousel/carousel.tsx
Normal file
112
packages/pl-fe/src/components/ui/carousel/carousel.tsx
Normal 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 };
|
||||
15
packages/pl-fe/src/components/ui/checkbox/checkbox.tsx
Normal file
15
packages/pl-fe/src/components/ui/checkbox/checkbox.tsx
Normal 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 };
|
||||
13
packages/pl-fe/src/components/ui/column/column.test.tsx
Normal file
13
packages/pl-fe/src/components/ui/column/column.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
126
packages/pl-fe/src/components/ui/column/column.tsx
Normal file
126
packages/pl-fe/src/components/ui/column/column.tsx
Normal 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,
|
||||
};
|
||||
31
packages/pl-fe/src/components/ui/combobox/combobox.css
Normal file
31
packages/pl-fe/src/components/ui/combobox/combobox.css
Normal 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;
|
||||
}
|
||||
10
packages/pl-fe/src/components/ui/combobox/combobox.tsx
Normal file
10
packages/pl-fe/src/components/ui/combobox/combobox.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import './combobox.css';
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxPopover,
|
||||
ComboboxList,
|
||||
ComboboxOption,
|
||||
ComboboxOptionText,
|
||||
} from '@reach/combobox';
|
||||
19
packages/pl-fe/src/components/ui/counter/counter.tsx
Normal file
19
packages/pl-fe/src/components/ui/counter/counter.tsx
Normal 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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
92
packages/pl-fe/src/components/ui/datepicker/datepicker.tsx
Normal file
92
packages/pl-fe/src/components/ui/datepicker/datepicker.tsx
Normal 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 };
|
||||
20
packages/pl-fe/src/components/ui/divider/divider.test.tsx
Normal file
20
packages/pl-fe/src/components/ui/divider/divider.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
29
packages/pl-fe/src/components/ui/divider/divider.tsx
Normal file
29
packages/pl-fe/src/components/ui/divider/divider.tsx
Normal 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 };
|
||||
@@ -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 };
|
||||
24
packages/pl-fe/src/components/ui/emoji/emoji.test.tsx
Normal file
24
packages/pl-fe/src/components/ui/emoji/emoji.test.tsx
Normal 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('🇺🇸');
|
||||
});
|
||||
});
|
||||
34
packages/pl-fe/src/components/ui/emoji/emoji.tsx
Normal file
34
packages/pl-fe/src/components/ui/emoji/emoji.tsx
Normal 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 };
|
||||
14
packages/pl-fe/src/components/ui/file-input/file-input.tsx
Normal file
14
packages/pl-fe/src/components/ui/file-input/file-input.tsx
Normal 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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
114
packages/pl-fe/src/components/ui/form-group/form-group.tsx
Normal file
114
packages/pl-fe/src/components/ui/form-group/form-group.tsx
Normal 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 };
|
||||
30
packages/pl-fe/src/components/ui/form/form.test.tsx
Normal file
30
packages/pl-fe/src/components/ui/form/form.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
29
packages/pl-fe/src/components/ui/form/form.tsx
Normal file
29
packages/pl-fe/src/components/ui/form/form.tsx
Normal 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 };
|
||||
73
packages/pl-fe/src/components/ui/hstack/hstack.tsx
Normal file
73
packages/pl-fe/src/components/ui/hstack/hstack.tsx
Normal 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 };
|
||||
54
packages/pl-fe/src/components/ui/icon-button/icon-button.tsx
Normal file
54
packages/pl-fe/src/components/ui/icon-button/icon-button.tsx
Normal 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 };
|
||||
40
packages/pl-fe/src/components/ui/icon/icon.tsx
Normal file
40
packages/pl-fe/src/components/ui/icon/icon.tsx
Normal 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 };
|
||||
17
packages/pl-fe/src/components/ui/icon/svg-icon.test.tsx
Normal file
17
packages/pl-fe/src/components/ui/icon/svg-icon.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
44
packages/pl-fe/src/components/ui/icon/svg-icon.tsx
Normal file
44
packages/pl-fe/src/components/ui/icon/svg-icon.tsx
Normal 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 };
|
||||
57
packages/pl-fe/src/components/ui/index.ts
Normal file
57
packages/pl-fe/src/components/ui/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
};
|
||||
141
packages/pl-fe/src/components/ui/input/input.tsx
Normal file
141
packages/pl-fe/src/components/ui/input/input.tsx
Normal 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,
|
||||
};
|
||||
66
packages/pl-fe/src/components/ui/layout/layout.tsx
Normal file
66
packages/pl-fe/src/components/ui/layout/layout.tsx
Normal 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 };
|
||||
33
packages/pl-fe/src/components/ui/menu/menu.css
Normal file
33
packages/pl-fe/src/components/ui/menu/menu.css
Normal 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;
|
||||
}
|
||||
42
packages/pl-fe/src/components/ui/menu/menu.tsx
Normal file
42
packages/pl-fe/src/components/ui/menu/menu.tsx
Normal 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 };
|
||||
139
packages/pl-fe/src/components/ui/modal/modal.test.tsx
Normal file
139
packages/pl-fe/src/components/ui/modal/modal.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
185
packages/pl-fe/src/components/ui/modal/modal.tsx
Normal file
185
packages/pl-fe/src/components/ui/modal/modal.tsx
Normal 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 };
|
||||
120
packages/pl-fe/src/components/ui/popover/popover.tsx
Normal file
120
packages/pl-fe/src/components/ui/popover/popover.tsx
Normal 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 };
|
||||
30
packages/pl-fe/src/components/ui/portal/portal.tsx
Normal file
30
packages/pl-fe/src/components/ui/portal/portal.tsx
Normal 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
30
packages/pl-fe/src/components/ui/select/select.tsx
Normal file
30
packages/pl-fe/src/components/ui/select/select.tsx
Normal 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 };
|
||||
123
packages/pl-fe/src/components/ui/slider/slider.tsx
Normal file
123
packages/pl-fe/src/components/ui/slider/slider.tsx
Normal 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 };
|
||||
93
packages/pl-fe/src/components/ui/spinner/spinner.css
Normal file
93
packages/pl-fe/src/components/ui/spinner/spinner.css
Normal 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;
|
||||
}
|
||||
}
|
||||
33
packages/pl-fe/src/components/ui/spinner/spinner.tsx
Normal file
33
packages/pl-fe/src/components/ui/spinner/spinner.tsx
Normal 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}> </div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{withText && (
|
||||
<Text theme='muted' tracking='wide'>
|
||||
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading…' />
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export { Spinner as default };
|
||||
68
packages/pl-fe/src/components/ui/stack/stack.tsx
Normal file
68
packages/pl-fe/src/components/ui/stack/stack.tsx
Normal 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 };
|
||||
107
packages/pl-fe/src/components/ui/streamfield/streamfield.tsx
Normal file
107
packages/pl-fe/src/components/ui/streamfield/streamfield.tsx
Normal 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 };
|
||||
22
packages/pl-fe/src/components/ui/tabs/tabs.css
Normal file
22
packages/pl-fe/src/components/ui/tabs/tabs.css
Normal 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;
|
||||
}
|
||||
182
packages/pl-fe/src/components/ui/tabs/tabs.tsx
Normal file
182
packages/pl-fe/src/components/ui/tabs/tabs.tsx
Normal 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,
|
||||
};
|
||||
70
packages/pl-fe/src/components/ui/tag-input/tag-input.tsx
Normal file
70
packages/pl-fe/src/components/ui/tag-input/tag-input.tsx
Normal 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 };
|
||||
26
packages/pl-fe/src/components/ui/tag-input/tag.tsx
Normal file
26
packages/pl-fe/src/components/ui/tag-input/tag.tsx
Normal 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 };
|
||||
140
packages/pl-fe/src/components/ui/text/text.tsx
Normal file
140
packages/pl-fe/src/components/ui/text/text.tsx
Normal 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,
|
||||
};
|
||||
121
packages/pl-fe/src/components/ui/textarea/textarea.tsx
Normal file
121
packages/pl-fe/src/components/ui/textarea/textarea.tsx
Normal 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 };
|
||||
159
packages/pl-fe/src/components/ui/toast/toast.tsx
Normal file
159
packages/pl-fe/src/components/ui/toast/toast.tsx
Normal 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 };
|
||||
55
packages/pl-fe/src/components/ui/toggle/toggle.tsx
Normal file
55
packages/pl-fe/src/components/ui/toggle/toggle.tsx
Normal 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 };
|
||||
94
packages/pl-fe/src/components/ui/tooltip/tooltip.tsx
Normal file
94
packages/pl-fe/src/components/ui/tooltip/tooltip.tsx
Normal 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 };
|
||||
65
packages/pl-fe/src/components/ui/widget/widget.tsx
Normal file
65
packages/pl-fe/src/components/ui/widget/widget.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user