Initial commit

This commit is contained in:
Tom Lerendu 2021-06-16 14:09:26 +01:00
commit 7878e653a0
253 changed files with 24552 additions and 0 deletions

View file

@ -0,0 +1,52 @@
import React from 'react';
import 'twin.macro';
import { action } from '@storybook/addon-actions';
import { Story, Meta } from '@storybook/react/types-6-0';
import ContextMenu, { ContextMenuProps } from './ContextMenu';
export default {
title: 'Context Menu',
component: ContextMenu,
argTypes: {
actions: {
contorl: {
type: 'object',
},
},
position: {
contorl: {
type: 'object',
},
},
},
decorators: [
(component, { args }) => (
<>
<div
tw="absolute w-4 h-4 bg-red-500 rounded-full"
style={{
left: `calc(${args.position[0]}px - 0.5rem)`,
top: `calc(${args.position[1]}px - 0.5rem)`,
}}
/>
{component()}
</>
),
],
} as Meta;
const Template: Story<ContextMenuProps> = (args) => (
<ContextMenu {...args} />
);
export const Primary = Template.bind({ });
Primary.args = {
actions: [
{ label: 'Action 1', onClick: action('Action 1 click') },
{ label: 'Action 2', onClick: action('Action 2 click') },
{ label: 'Action 3', onClick: action('Action 3 click') },
],
position: [200, 200],
close: action('Close triggered'),
};

View file

@ -0,0 +1,74 @@
import React from 'react';
import 'twin.macro';
import ContextMenuAction from '../../../providers/context-menu-action';
export type ContextMenuProps = {
position: [number, number],
actions: ContextMenuAction[],
align: 'left' | 'right',
close: () => void,
};
export default function ContextMenu({
position,
actions,
align,
close,
}: ContextMenuProps) {
return (
<div
role="presentation"
tw="absolute inset-0"
onClick={() => close()}
onContextMenu={(event) => {
event.preventDefault();
close();
}}
>
<div
tw="flex flex-col absolute bg-white dark:bg-gray-850 dark:border dark:border-gray-700 rounded shadow w-56 overflow-hidden py-2"
style={{
left: `calc(${align === 'right' ? '-14rem' : '0px'} + ${position[0]}px)`,
top: `${position[1]}px`,
}}
>
{actions.map((action) => {
if (action === '-') {
return (
<div
key="-"
tw="border-b my-1 border-gray-300 dark:border-gray-700"
/>
);
}
if (typeof action === 'string') {
return (
<div
key={action}
tw="px-4 py-2 uppercase text-gray-500 text-xs font-semibold"
>
{action}
</div>
);
}
return (
<button
key={action.key || action.label}
type="button"
tw="px-4 py-1 hover:bg-gray-200 hover:dark:bg-gray-800 text-left text-sm text-gray-700 dark:text-gray-200"
onClick={(event) => {
event.stopPropagation();
close();
setTimeout(() => action?.onClick?.(event));
}}
>
{action.label}
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,54 @@
import React, { ReactEventHandler } from 'react';
import { theme } from 'twin.macro';
import SimpleCodeEditor from 'react-simple-code-editor';
import { highlight, languages } from 'prismjs';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-json';
export interface EditorProps {
name?: string,
value: string,
onChange: (value: string) => void,
onBlur?: (event: ReactEventHandler) => void,
onFocus?: (event: ReactEventHandler) => void,
minLines: number,
maxLines: number,
placeholder?: string,
}
export default function Editor({
name,
value,
onChange,
onBlur,
onFocus,
placeholder,
minLines,
maxLines,
}: EditorProps) {
return (
<div
className="editor"
tw="overflow-y-auto"
style={{
minHeight: `${Number(theme`lineHeight.snug`) * minLines}rem`,
maxHeight: `${Number(theme`lineHeight.snug`) * maxLines}rem`,
}}
>
<SimpleCodeEditor
name={name}
onValueChange={onChange}
value={value}
onFocus={(event: any) => onFocus?.(event)}
onBlur={(event: any) => onBlur?.(event)}
placeholder={placeholder}
highlight={(code) => highlight(code, languages.json, 'json')}
tw="text-gray-800 dark:text-gray-200 font-mono text-sm leading-snug"
style={{
scrollBehavior: 'auto',
minHeight: `${Number(theme`lineHeight.snug`) * minLines}rem`,
}}
/>
</div>
);
}

View file

@ -0,0 +1,27 @@
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import FormCheckbox, { FormCheckboxProps } from './FormCheckbox';
import FormikDecorator, { FormikDecoratorProps } from '../../../../.storybook/decorators/FormikDecorator';
export default {
title: 'Form / Checkbox',
component: FormCheckbox,
argTypes: { },
decorators: [
FormikDecorator,
],
} as Meta;
const Template: Story<FormCheckboxProps & FormikDecoratorProps> = (args) => (
<FormCheckbox {...args} />
);
export const Primary = Template.bind({ });
Primary.args = {
name: 'test',
description: 'Test description',
initialValues: {
test: true,
},
};

View file

@ -0,0 +1,46 @@
import React from 'react';
import tw from 'twin.macro';
import { TiTick } from 'react-icons/all';
import { useField } from 'formik';
export interface FormCheckboxProps {
name: string,
description?: string,
}
export default function FormCheckbox({
name,
description,
}: FormCheckboxProps) {
const [field, , helpers] = useField({ name });
return (
<label tw="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg py-2 px-4 cursor-pointer">
<div tw="mr-4">
<button
type="button"
onClick={() => {
helpers.setValue(!field.value);
helpers.setTouched(true);
}}
css={[
tw`w-6 h-6 rounded flex items-center justify-center text-xl`,
field.value && tw`bg-purple-600 dark:bg-purple-700 hover:bg-purple-500 hover:dark:bg-purple-600 text-white`,
!field.value && tw`bg-gray-200 dark:bg-gray-500 hover:bg-gray-300 hover:dark:bg-gray-400 text-gray-600 dark:text-gray-700`,
]}
>
<TiTick />
</button>
</div>
<div tw="flex-grow">
{description && (
<div
tw="text-sm text-gray-700 dark:text-gray-300"
>
{description}
</div>
)}
</div>
</label>
);
}

View file

@ -0,0 +1,26 @@
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import FormEditor, { FormEditorProps } from './FormEditor';
import FormikDecorator, { FormikDecoratorProps } from '../../../../.storybook/decorators/FormikDecorator';
export default {
title: 'Form / Editor',
component: FormEditor,
argTypes: { },
decorators: [
FormikDecorator,
],
} as Meta;
const Template: Story<FormEditorProps & FormikDecoratorProps> = (args) => (
<FormEditor {...args} />
);
export const Primary = Template.bind({ });
Primary.args = {
name: 'test',
initialValues: {
test: '{ "json": "payload" }',
},
};

View file

@ -0,0 +1,48 @@
import React, { useState } from 'react';
import tw from 'twin.macro';
import { useField, ErrorMessage } from 'formik';
import Editor from '../Editor/Editor';
export interface FormEditorProps {
name: string,
}
export default function FormEditor({
name,
}: FormEditorProps) {
const [field, , helpers] = useField({ name });
const [isFocused, setIsFocused] = useState<boolean>(false);
return (
<>
<div
css={[
tw`overflow-hidden bg-gray-100 dark:bg-gray-800 rounded-lg border-2 py-2 px-4`,
isFocused && tw`border-gray-400 dark:border-gray-600`,
!isFocused && tw`border-gray-200 dark:border-gray-700`,
]}
>
<Editor
name={field.name}
onChange={(value) => {
helpers.setValue(value);
helpers.setTouched(true);
}}
onFocus={() => setIsFocused(true)}
onBlur={(event) => {
setIsFocused(false);
field.onBlur(event);
}}
value={field.value}
minLines={10}
maxLines={20}
/>
</div>
<ErrorMessage
name={name}
component="div"
tw="pt-2 text-red-800 text-sm font-semibold"
/>
</>
);
}

View file

@ -0,0 +1,33 @@
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import FormikDecorator, { FormikDecoratorProps } from '../../../../.storybook/decorators/FormikDecorator';
import { Filled as FormTextInput } from './FormTextInput.stories';
import FormField, { FormFieldProps } from './FormField';
export default {
title: 'Form / Form Field',
component: FormField,
argTypes: { },
decorators: [
FormikDecorator,
],
} as Meta;
const Template: Story<FormFieldProps & FormikDecoratorProps> = (args) => (
<FormField {...args}>
<FormTextInput name="test" initialValues={{ test: '' }} />
</FormField>
);
export const WithDescription = Template.bind({ });
WithDescription.args = {
title: 'Field Title',
description: 'Field Description or help text.',
};
export const WithoutDescription = Template.bind({ });
WithoutDescription.args = {
title: 'Field Title',
};

View file

@ -0,0 +1,37 @@
import React, { ReactNode } from 'react';
import 'twin.macro';
import Spacer from '../Utilities/Spacer';
export interface FormFieldProps {
title?: string,
description?: ReactNode,
children: ReactNode,
}
export default function FormField({
title,
description,
children,
}: FormFieldProps) {
return (
<div>
{title && (
<>
<span tw="uppercase font-semibold text-sm text-gray-700 dark:text-gray-200">
{title}
</span>
<Spacer size="half" />
</>
)}
{description && (
<>
<p tw="text-sm text-gray-700 dark:text-gray-500">
{description}
</p>
<Spacer />
</>
)}
{children}
</div>
);
}

View file

@ -0,0 +1,38 @@
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import FormikDecorator, { FormikDecoratorProps } from '../../../../.storybook/decorators/FormikDecorator';
import FormTextInput, { FormTextInputProps } from './FormTextInput';
export default {
title: 'Form / Text Input',
component: FormTextInput,
argTypes: { },
decorators: [
FormikDecorator,
],
} as Meta;
const Template: Story<FormTextInputProps & FormikDecoratorProps> = (args) => (
<FormTextInput {...args} />
);
export const Filled = Template.bind({ });
Filled.args = {
name: 'test',
maxLength: 20,
initialValues: {
test: 'Value',
},
};
export const Placeholder = Template.bind({ });
Placeholder.args = {
name: 'test',
maxLength: 50,
placeholder: 'Placeholder',
initialValues: {
test: '',
},
};

View file

@ -0,0 +1,34 @@
import React from 'react';
import 'twin.macro';
import { ErrorMessage, Field } from 'formik';
export interface FormTextInputProps {
name: string,
placeholder?: string,
maxLength?: number,
autoFocus?: boolean
}
export default function FormTextInput({
name,
placeholder,
maxLength,
autoFocus,
}: FormTextInputProps) {
return (
<>
<Field
name={name}
tw="w-full bg-gray-100 dark:bg-gray-800 rounded-lg px-4 leading-8 border-2 border-gray-200 dark:border-gray-700 focus:border-gray-400 focus:dark:border-gray-600 text-gray-800 dark:text-gray-200"
placeholder={placeholder}
maxLength={maxLength}
autoFocus={autoFocus}
/>
<ErrorMessage
name={name}
component="div"
tw="pt-2 text-red-800 text-sm font-semibold"
/>
</>
);
}

View file

@ -0,0 +1,30 @@
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import FormikDecorator, { FormikDecoratorProps } from '../../../../.storybook/decorators/FormikDecorator';
import FormTextInputArray, { FormTextInputArrayProps } from './FormTextInputArray';
export default {
title: 'Form / Text Input Array',
component: FormTextInputArray,
argTypes: { },
decorators: [
FormikDecorator,
],
} as Meta;
const Template: Story<FormTextInputArrayProps & FormikDecoratorProps> = (args) => (
<FormTextInputArray {...args} />
);
export const Primary = Template.bind({ });
Primary.args = {
name: 'test',
addItemCta: 'Add New Item',
initialValues: {
test: [
{ id: 'a', value: 'Test 1' },
{ id: 'b', value: 'Test 2' },
],
},
};

View file

@ -0,0 +1,107 @@
import React, { Fragment, useState } from 'react';
import { MdClose } from 'react-icons/md';
import tw from 'twin.macro';
import { v4 as uuid } from 'uuid';
import { useField } from 'formik';
import ButtonSecondary from '../Styled/ButtonSecondary';
export interface FormTextInputArrayProps {
name: string,
maxLength?: number,
addItemCta: string,
}
export default function FormTextInputArray({
name,
maxLength,
addItemCta,
}: FormTextInputArrayProps) {
const [field, meta, helpers] = useField<{ id: string, value: string}[]>({ name });
const [focusedItem, setFocusedItem] = (
useState<{ id: string, value: string} | undefined>(undefined)
);
return (
<>
<div
css={[
tw`w-full overflow-hidden bg-gray-100 dark:bg-gray-800 rounded-lg border-2 text-gray-800 dark:text-gray-300`,
focusedItem !== undefined && tw`border-gray-400 dark:border-gray-600`,
focusedItem === undefined && tw`border-gray-200 dark:border-gray-700`,
]}
>
{field.value.map((item) => (
<Fragment key={item.id}>
<div
className="group"
tw="flex"
>
<input
type="text"
tw="w-full py-1 px-4 bg-transparent"
maxLength={maxLength}
value={item.value}
onFocus={() => setFocusedItem(item)}
onBlur={() => setFocusedItem(undefined)}
onChange={(event) => {
helpers.setValue(
field.value.map((existingItem) => (
item === existingItem
? {
id: existingItem.id,
value: event.target.value,
}
: existingItem
)),
);
helpers.setTouched(true);
}}
/>
<ButtonSecondary
type="button"
tw="mr-3 p-1 invisible group-hover:visible"
onClick={() => helpers.setValue(
field.value.filter((existingItem) => existingItem.id !== item.id),
)}
>
<MdClose />
</ButtonSecondary>
</div>
<div
css={[
tw`border-b border-gray-200 dark:border-gray-700 mx-4`,
focusedItem?.id === item.id && tw`border-gray-400 dark:border-gray-600`,
]}
/>
</Fragment>
))}
<ButtonSecondary
type="button"
css={[
tw`w-full text-xs h-8 text-center`,
!!field.value.length && 'mt-2',
]}
onClick={() => (
helpers.setValue([
...field.value,
{ id: uuid(), value: '' },
])
)}
>
{addItemCta}
</ButtonSecondary>
</div>
{meta.error && (
<div
tw="pt-2 text-red-800 text-sm font-semibold"
>
{
typeof meta.error === 'string'
? meta.error
: (meta.error as any)?.find((error: any) => !!error).value
}
</div>
)}
</>
);
}

View file

@ -0,0 +1,32 @@
import React from 'react';
export default function SidebarIcon() {
return (
<svg
stroke="currentColor"
fill="none"
strokeWidth="3"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="2"
y="3"
width="20"
height="19"
rx="2"
ry="2"
/>
<line
x1="10"
y1="4"
x2="10"
y2="20"
/>
</svg>
);
}

View file

@ -0,0 +1,35 @@
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import List, { ListProps } from './List';
import ContextMenuDecorator from '../../../../.storybook/decorators/ContextMenuDecorator';
import DropdownMenuDecorator from '../../../../.storybook/decorators/DropdownMenuDecorator';
import * as ListItem from './ListItem.stories';
export default {
title: 'List / List',
component: List,
decorators: [
DropdownMenuDecorator,
ContextMenuDecorator,
],
} as Meta;
const Template: Story<ListProps> = (args) => (
<List>
{args.children}
</List>
);
export const Primary = Template.bind({ });
Primary.args = {
children: (
<>
<ListItem.Primary {...ListItem.Primary.args as any} />
<ListItem.WithLeftClickActions {...ListItem.WithLeftClickActions.args as any} />
<ListItem.WithRightClickActions {...ListItem.WithRightClickActions.args as any} />
<ListItem.WithSingleLeftClickAction {...ListItem.WithSingleLeftClickAction.args as any} />
<ListItem.WithSubtitle {...ListItem.WithSubtitle.args as any} />
</>
),
};

View file

@ -0,0 +1,15 @@
import React, { ReactNode } from 'react';
export interface ListProps {
children: ReactNode,
}
export default function List({
children,
}: ListProps) {
return (
<ul>
{children}
</ul>
);
}

View file

@ -0,0 +1,59 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { Story, Meta } from '@storybook/react/types-6-0';
import ListItem, { ListItemProps } from './ListItem';
import ContextMenuDecorator from '../../../../.storybook/decorators/ContextMenuDecorator';
import DropdownMenuDecorator from '../../../../.storybook/decorators/DropdownMenuDecorator';
export default {
title: 'List / List Item',
component: ListItem,
decorators: [
DropdownMenuDecorator,
ContextMenuDecorator,
],
} as Meta;
const Template: Story<ListItemProps> = (args) => (
<ListItem {...args} />
);
export const Primary = Template.bind({ });
Primary.args = {
title: 'List item title',
};
export const WithSubtitle = Template.bind({ });
WithSubtitle.args = {
...Primary.args,
subtitle: 'List item subtitle',
};
export const WithSingleLeftClickAction = Template.bind({ });
WithSingleLeftClickAction.args = {
...Primary.args,
onClick: action('Single left click'),
};
export const WithLeftClickActions = Template.bind({ });
WithLeftClickActions.args = {
...Primary.args,
primaryClickActions: [
{ label: 'Action 1', onClick: action('Action 1 click') },
{ label: 'Action 2', onClick: action('Action 2 click') },
],
};
export const WithRightClickActions = Template.bind({ });
WithRightClickActions.args = {
...Primary.args,
secondaryClickActions: [
{ label: 'Action 1', onClick: action('Action 1 click') },
{ label: 'Action 2', onClick: action('Action 2 click') },
],
};

View file

@ -0,0 +1,15 @@
import React from 'react';
import { render, testId } from '../../../tests/enzyme';
import ListItem from './ListItem';
describe('ListItem', () => {
it('displays the title', () => {
const wrapper = render(<ListItem title="Test Title" />);
expect(wrapper.find(testId('title')).text()).toEqual('Test Title');
});
it('displays the subtitle', () => {
const wrapper = render(<ListItem title="Test Title" subtitle="Subtitle" />);
expect(wrapper.find(testId('subtitle')).text()).toEqual('Subtitle');
});
});

View file

@ -0,0 +1,120 @@
import React, {
ReactElement,
useContext,
useState,
} from 'react';
import { FaEllipsisH } from 'react-icons/fa';
import tw from 'twin.macro';
import { ContextMenuContext } from '../../../providers/ContextMenuProvider';
import { DropdownMenuContext } from '../../../providers/DropdownMenuProvider';
import ContextMenuAction from '../../../providers/context-menu-action';
export interface ListItemProps {
title: ReactElement | string,
subtitle?: ReactElement | string,
isSelected?: boolean,
onClick?: (event: React.MouseEvent) => void,
primaryClickActions?: ContextMenuAction[],
secondaryClickActions?: ContextMenuAction[],
}
export default function ListItem({
title,
subtitle,
isSelected: isSelectedExternal,
onClick,
primaryClickActions,
secondaryClickActions,
}: ListItemProps) {
const contextMenu = useContext(ContextMenuContext);
const dropdownMenu = useContext(DropdownMenuContext);
const [isSelected, setIsSelected] = useState<boolean>(false);
const [isDropdownSelected, setIsDropdownSelected] = useState<boolean>(false);
return (
<li
className="group"
css={[
tw`flex border-b last:border-b-0 border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-100 hover:dark:bg-gray-800`,
(isSelected || isSelectedExternal) && tw`bg-gray-100 dark:bg-gray-800`,
]}
>
<button
type="button"
onClick={async (event) => {
if (primaryClickActions?.length) {
setIsSelected(true);
await contextMenu.openForMouseEvent(
event,
primaryClickActions,
);
setIsSelected(false);
}
onClick?.(event);
}}
onContextMenu={async (event) => {
if (secondaryClickActions?.length) {
setIsSelected(true);
await contextMenu.openForMouseEvent(
event,
secondaryClickActions,
);
setIsSelected(false);
}
}}
tw="w-full flex flex-row items-center py-2 px-4"
>
<div tw="flex flex-col flex-grow text-left">
<div
tw="text-gray-800 dark:text-gray-200"
data-testid="title"
>
{title}
</div>
{subtitle && (
<div
tw="pt-2 text-gray-500 dark:text-gray-400 text-xs"
data-testid="subtitle"
>
{subtitle}
</div>
)}
</div>
{!!secondaryClickActions?.length && (
<div
role="presentation"
css={[
tw`ml-2 text-xs text-gray-700 dark:text-gray-300 p-1 ml-2 hover:bg-gray-300 hover:dark:bg-gray-700`,
!isDropdownSelected && tw`invisible group-hover:visible`,
isDropdownSelected && tw`bg-gray-300 dark:bg-gray-900 visible`,
]}
onClick={async (event) => {
event.stopPropagation();
setIsSelected(true);
setIsDropdownSelected(true);
await dropdownMenu.openForElement(
event.currentTarget as HTMLElement,
secondaryClickActions!,
);
setIsSelected(false);
setIsDropdownSelected(false);
}}
onContextMenu={(event) => {
event.stopPropagation();
event.preventDefault();
}}
>
<FaEllipsisH />
</div>
)}
</button>
</li>
);
}
ListItem.defaultProps = {
primaryClickActions: [],
secondaryClickActions: [],
};

View file

@ -0,0 +1,29 @@
import React, { Fragment } from 'react';
import 'twin.macro';
import Notification from '../../../types/UserInterface/Notification';
import Spacer from '../Utilities/Spacer';
import NotificationListItem from './NotificationListItem';
export interface NotificationListProps {
notifications: Notification[],
onClose: (notification: Notification) => void,
}
export default function NotificationList({
notifications,
onClose,
}: NotificationListProps) {
return (
<div tw="p-4">
{notifications.map((notification, index) => (
<Fragment key={notification.id}>
<NotificationListItem
notification={notification}
onClose={() => onClose(notification)}
/>
{index !== notifications.length - 1 && <Spacer />}
</Fragment>
))}
</div>
);
}

View file

@ -0,0 +1,61 @@
import React from 'react';
import 'twin.macro';
import { MdClose } from 'react-icons/md';
import ButtonPrimary from '../Styled/ButtonPrimary';
import ButtonSecondary from '../Styled/ButtonSecondary';
import Notification from '../../../types/UserInterface/Notification';
export interface NotificationListItemProps {
notification: Notification,
onClose: () => void,
}
export default function NotificationListItem({
notification,
onClose,
}: NotificationListItemProps) {
return (
<div tw="bg-gray-100 dark:bg-gray-900 dark:border dark:border-gray-700 rounded-lg border shadow p-4 w-96">
<div tw="flex justify-between">
<strong tw="font-semibold text-gray-900 dark:text-gray-100">
{notification.title}
</strong>
<ButtonSecondary tw="p-1">
<MdClose onClick={() => onClose()} />
</ButtonSecondary>
</div>
<p tw="my-2 select-text text-sm text-gray-700 dark:text-gray-300">
{notification.body}
</p>
<div tw="flex justify-end">
{notification.actions.map((action) => (
action.theme === 'primary'
? (
<ButtonPrimary
key={action.label}
tw="ml-2 py-1 px-2 rounded"
onClick={() => {
action.onClick?.();
onClose();
}}
>
{action.label}
</ButtonPrimary>
)
: (
<ButtonSecondary
key={action.label}
tw="ml-2 py-1 px-2 rounded"
onClick={() => {
action.onClick?.();
onClose();
}}
>
{action.label}
</ButtonSecondary>
)
))}
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import React from 'react';
import 'twin.macro';
import { MdClose } from 'react-icons/md';
import { BsArrowRightShort } from 'react-icons/all';
import PopupManager from '../../../types/UserInterface/PopupManager';
export interface PopupProps {
popup: PopupManager,
}
export default function Popup({
popup,
}: PopupProps) {
return (
<div
role="presentation"
onClick={() => popup.popToRoot()}
tw="flex justify-center overflow-y-auto py-10 items-start bg-gray-900 dark:bg-white fixed inset-0"
style={{ background: 'rgba(0, 0, 0, .15)', backdropFilter: 'grayscale(50%)' }}
>
<div
role="presentation"
onClick={(e) => e.stopPropagation()}
tw="flex flex-col bg-white dark:bg-gray-900 w-full max-w-lg rounded overflow-hidden shadow-md dark:border dark:border-gray-700"
>
<div tw="p-2 flex flex-row items-center justify-between bg-gray-800 dark:bg-gray-850 text-white">
<div tw="p-2 flex items-center uppercase text-sm font-semibold">
{popup.title?.map((title, index) => (
<React.Fragment key={title}>
<span>{title}</span>
{index !== popup.title!.length - 1 && (
<span tw="text-gray-500 text-base mx-2 inline-block">
<BsArrowRightShort />
</span>
)}
</React.Fragment>
))}
</div>
<button
type="button"
onClick={() => popup.popToRoot()}
tw="flex-grow-0 p-1 mr-2 hover:bg-gray-700 cursor-pointer"
>
<MdClose />
</button>
</div>
<div tw="overflow-auto">
{popup.component}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,16 @@
import React, { ReactNode } from 'react';
import 'twin.macro';
export interface PopupBodyProps {
children: ReactNode,
}
export default function PopupBody({
children,
}: PopupBodyProps) {
return (
<div tw="p-4">
{children}
</div>
);
}

View file

@ -0,0 +1,43 @@
import React from 'react';
import 'twin.macro';
import ButtonAction from '../../../types/UserInterface/ButtonAction';
import ButtonPrimary from '../Styled/ButtonPrimary';
import ButtonSecondary from '../Styled/ButtonSecondary';
export interface PopupButtonsProps {
actions: ButtonAction[],
}
export default function PopupButtons({
actions,
}: PopupButtonsProps) {
return (
<div
tw="flex justify-end px-4 py-2 bg-gray-100 dark:bg-gray-850 border-t dark:border-none"
>
{actions.map((action) => (
action.theme === 'primary'
? (
<ButtonPrimary
key={action.label}
type={action.type || 'button'}
onClick={() => action.onClick?.()}
tw="ml-2 py-1 px-4 rounded"
>
{action.label}
</ButtonPrimary>
)
: (
<ButtonSecondary
key={action.label}
type={action.type || 'button'}
onClick={() => action.onClick?.()}
tw="ml-2 py-1 px-4 rounded"
>
{action.label}
</ButtonSecondary>
)
))}
</div>
);
}

View file

@ -0,0 +1,39 @@
import React, { useContext } from 'react';
import 'twin.macro';
import PopupButtons from '../Popup/PopupButtons';
import { PopupContext } from '../../../providers/PopupProvider';
import PopupBody from '../Popup/PopupBody';
export interface PopupConfirmationProps {
message: string,
}
export default function PopupConfirmation({
message,
}: PopupConfirmationProps) {
const popup = useContext(PopupContext);
return (
<>
<PopupBody>
<p tw="text-gray-800 dark:text-gray-200">
{message}
</p>
</PopupBody>
<PopupButtons
actions={[
{
label: 'No',
theme: 'secondary',
onClick: () => popup.pop(false),
},
{
label: 'Yes',
theme: 'primary',
onClick: () => popup.pop(true),
},
]}
/>
</>
);
}

View file

@ -0,0 +1,68 @@
import React, { useContext } from 'react';
import * as yup from 'yup';
import { Formik, Form } from 'formik';
import PopupButtons from '../Popup/PopupButtons';
import { PopupContext } from '../../../providers/PopupProvider';
import FormTextInput from '../Form/FormTextInput';
import PopupBody from '../Popup/PopupBody';
export interface PopupPromptProps {
label: string,
submitLabel: string,
defaultValue?: string,
yupValidator?: yup.Schema<any>,
maxLength?: number,
}
export default function PopupPrompt({
label,
submitLabel,
defaultValue,
yupValidator,
maxLength,
}: PopupPromptProps) {
const popup = useContext(PopupContext);
return (
<Formik
initialValues={{
field: defaultValue || '',
}}
validationSchema={yup.object({
field: yupValidator || yup.string(),
})}
validateOnChange={false}
validateOnBlur={false}
onSubmit={({ field }) => popup.pop(field)}
>
<Form>
<PopupBody>
<FormTextInput
placeholder={label}
name="field"
maxLength={maxLength}
autoFocus
/>
</PopupBody>
<PopupButtons
actions={[
{
label: 'Cancel',
theme: 'secondary',
onClick: () => popup.pop(),
},
{
label: submitLabel,
theme: 'primary',
type: 'submit',
},
]}
/>
</Form>
</Formik>
);
}
PopupPrompt.defaultValues = {
defaultValue: '',
};

View file

@ -0,0 +1,23 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { Story, Meta } from '@storybook/react/types-6-0';
import ButtonPrimary from './ButtonPrimary';
export default {
title: 'Styled / Button Primary',
component: ButtonPrimary,
} as Meta;
const Template: Story = (args) => (
<ButtonPrimary {...args}>
{args.children}
</ButtonPrimary>
);
export const Primary = Template.bind({ });
Primary.args = {
children: 'Button Text',
disabled: false,
onClick: action('Button click'),
};

View file

@ -0,0 +1,14 @@
import tw, { styled } from 'twin.macro';
export interface ButtonPrimaryProps {
disabled?: boolean,
}
const ButtonPrimary = styled.button(({
disabled,
}: ButtonPrimaryProps) => [
tw`bg-purple-700 dark:bg-purple-800 hover:bg-purple-600 hover:dark:bg-purple-700 text-white font-semibold dark:border-2 dark:border-purple-700`,
disabled && tw`opacity-50 pointer-events-none`,
]);
export default ButtonPrimary;

View file

@ -0,0 +1,23 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { Story, Meta } from '@storybook/react/types-6-0';
import ButtonSecondary from './ButtonSecondary';
export default {
title: 'Styled / Button Secondary',
component: ButtonSecondary,
} as Meta;
const Template: Story = (args) => (
<ButtonSecondary {...args}>
{args.children}
</ButtonSecondary>
);
export const Primary = Template.bind({ });
Primary.args = {
children: 'Button Text',
disabled: false,
onClick: action('Button click'),
};

View file

@ -0,0 +1,14 @@
import tw, { styled } from 'twin.macro';
export interface ButtonSecondaryProps {
disabled?: boolean,
}
const ButtonSecondary = styled.button(({
disabled,
}: ButtonSecondaryProps) => [
tw`text-blue-800 dark:text-blue-200 hover:bg-gray-300 hover:dark:bg-gray-800`,
disabled && tw`opacity-50 pointer-events-none`,
]);
export default ButtonSecondary;

View file

@ -0,0 +1,61 @@
import { createGlobalStyle } from 'styled-components';
import tw, { theme } from 'twin.macro';
export interface GlobalStylesProps {
backgroundColor?: string,
}
const GlobalStyles = createGlobalStyle<GlobalStylesProps>`
body {
${tw`text-base select-none bg-gray-50 dark:bg-gray-950`}
}
#root {
${tw`h-screen text-sm`}
}
input:focus,
select:focus,
textarea:focus,
button:focus,
div[contenteditable]:focus {
${tw`outline-none!`}
}
.editor {
@media (prefers-color-scheme: light) {
.token {
&.punctuation {
color: ${theme`colors.gray.900`};
}
&.property {
color: ${theme`colors.teal.700`};
}
&.operator {
color: ${theme`colors.gray.700`};
}
&.string {
color: ${theme`colors.blue.900`};
}
}
}
@media (prefers-color-scheme: dark) {
.token {
&.punctuation {
color: ${theme`colors.gray.100`};
}
&.property {
color: ${theme`colors.teal.300`};
}
&.operator {
color: ${theme`colors.gray.300`};
}
&.string {
color: ${theme`colors.blue.300`};
}
}
}
}
`;
export default GlobalStyles;

View file

@ -0,0 +1,21 @@
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import Link from './Link';
export default {
title: 'Styled / Link',
component: Link,
argTypes: { },
} as Meta;
const Template: Story = (args) => (
<Link href="https://google.com">
{args.children}
</Link>
);
export const Primary = Template.bind({ });
Primary.args = {
children: 'Link Text',
};

View file

@ -0,0 +1,5 @@
import tw from 'twin.macro';
const Link = tw.a`text-blue-800 dark:text-blue-400 hover:underline`;
export default Link;

View file

@ -0,0 +1,131 @@
import React, { useEffect, useState } from 'react';
import { theme } from 'twin.macro';
import ButtonPrimary from '../Styled/ButtonPrimary';
import ButtonSecondary from '../Styled/ButtonSecondary';
export interface TourProps {
onClose: () => void,
}
export default function Tour({
onClose,
}: TourProps) {
const steps = [
{
target: 'connection-url',
copy: 'Enter the WebSocket URL here and then click connect.',
position: 'bottom',
},
{
target: 'connection-editor',
copy: 'Once connected, use the editor to create a new message and sent it to the WebSocket server.',
position: 'bottom',
},
{
target: 'connection-list',
copy: 'If you wish to have multiple clients talking to the WebSocket server create another connection.',
position: 'bottom',
},
{
target: 'saved-payloads',
copy: 'Any payloads that are sent frequently can be saved here.',
position: 'right',
},
];
const [step, setStep] = useState<number>(0);
const [boundingBox, setBoundingBox] = useState<DOMRect | null>(null);
const [lastCheckAt, setLastCheckAt] = useState<number>(Date.now());
const padding1 = theme`padding.1`;
const padding2 = theme`padding.2`;
const padding4 = theme`padding.4`;
useEffect(
() => {
const interval = window.setInterval(
() => setLastCheckAt(Date.now()),
1000,
);
return () => window.clearInterval(interval);
},
[],
);
useEffect(
() => {
const element = document.querySelector(`[data-tour='${steps[step].target}']`);
if (!element) {
onClose();
}
setBoundingBox(
element!.getBoundingClientRect()!,
);
element!.scrollIntoView({ behavior: 'smooth' });
},
[step, lastCheckAt],
);
return (
<>
<div
tw="absolute rounded-lg pointer-events-none ring-3000 ring-gray-700 dark:ring-gray-600 ring-opacity-50 dark:ring-opacity-75 shadow-lg"
style={{
left: `calc(${boundingBox?.left}px - ${padding1})`,
top: `calc(${boundingBox?.top}px - ${padding1})`,
width: `calc(${boundingBox?.width}px + ${padding2})`,
height: `calc(${boundingBox?.height}px + ${padding2})`,
}}
/>
<div
tw="absolute flex content-center"
style={{
left: steps[step].position === 'bottom'
? `calc(${boundingBox?.left}px - ${padding1})`
: `calc(${boundingBox?.right}px + ${padding4})`,
top: steps[step].position === 'bottom'
? `calc(${boundingBox?.bottom}px + ${padding4})`
: `calc(${boundingBox?.top}px - ${padding1})`,
width: `calc(${boundingBox?.width}px + ${padding2})`,
}}
>
<div tw="bg-white dark:bg-gray-850 p-4 rounded-lg shadow-lg max-w-md">
<p tw="text-xs text-gray-600 dark:text-gray-400 uppercase font-semibold">
{`Step ${step + 1} of ${steps.length}`}
</p>
<p tw="mt-2 select-text text-gray-800 dark:text-gray-200">{steps[step].copy}</p>
<div tw="flex justify-end mt-2">
{(steps.length !== step + 1) && (
<ButtonSecondary
onClick={() => onClose()}
tw="py-1 px-4 rounded mr-2"
>
Exit
</ButtonSecondary>
)}
<ButtonPrimary
onClick={() => {
if (steps.length === step + 1) {
onClose();
} else {
setStep(step + 1);
}
}}
tw="py-1 px-4 rounded"
>
{
steps.length === step + 1
? 'Finish'
: 'Next'
}
</ButtonPrimary>
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import EmptyMessage, { EmptyMessageProps } from './EmptyMessage';
export default {
title: 'Utilities / Empty Message',
component: EmptyMessage,
argTypes: { },
} as Meta;
const Template: Story<EmptyMessageProps> = (args) => (
<EmptyMessage {...args}>
{args.children}
</EmptyMessage>
);
export const Primary = Template.bind({ });
Primary.args = {
heading: 'Title',
children: 'Empty message content. Empty message content.',
};

View file

@ -0,0 +1,17 @@
import React from 'react';
import { render, testId } from '../../../tests/enzyme';
import EmptyMessage from './EmptyMessage';
describe('Text Limit', () => {
const wrapper = render(<EmptyMessage heading="Heading">Description</EmptyMessage>);
it('can display a heading', () => {
expect(wrapper.find(testId('heading')).text())
.toEqual('Heading');
});
it('can display a child description', () => {
expect(wrapper.find(testId('description')).text())
.toEqual('Description');
});
});

View file

@ -0,0 +1,47 @@
import React, { ReactNode } from 'react';
import 'twin.macro';
import ButtonSecondary from '../Styled/ButtonSecondary';
export interface EmptyMessageProps {
heading: string,
children: ReactNode,
buttonText?: string,
buttonOnClick?: () => void,
}
export default function EmptyMessage({
heading,
children,
buttonText,
buttonOnClick,
}: EmptyMessageProps) {
return (
<div
tw="flex content-center justify-center flex-wrap flex-grow select-text"
>
<div tw="text-center text-xs w-3/4 p-4 rounded-lg">
<h3
tw="font-bold mb-2 text-gray-600 dark:text-gray-300 text-sm uppercase"
data-testid="heading"
>
{heading}
</h3>
<p
tw="text-gray-800 dark:text-gray-500 mb-2"
data-testid="description"
>
{children}
</p>
{(buttonText && buttonOnClick) && (
<ButtonSecondary
type="button"
tw="px-2 py-1 rounded"
onClick={() => buttonOnClick()}
>
{buttonText}
</ButtonSecondary>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import { action } from '@storybook/addon-actions';
import { MdAccessTime, MdAddLocation } from 'react-icons/md';
import Heading, { HeadingProps } from './Heading';
export default {
title: 'Utilities / Heading',
component: Heading,
argTypes: {
buttons: { control: 'object' },
},
} as Meta;
const Template: Story<HeadingProps> = (args) => (
<Heading {...args}>
{args.children}
</Heading>
);
export const WithoutButtons = Template.bind({ });
WithoutButtons.args = {
children: 'Heading',
buttons: [],
};
export const WithButtons = Template.bind({ });
WithButtons.args = {
children: 'Heading',
buttons: [
{
icon: <MdAccessTime />,
alt: 'Clock',
onClick: action('Clock click'),
},
{
icon: <MdAddLocation />,
alt: 'Location',
onClick: action('Location click'),
},
],
};

View file

@ -0,0 +1,62 @@
import React from 'react';
import { shallow, mount, testId } from '../../../tests/enzyme';
import Heading from './Heading';
describe('Heading', () => {
it('displays the title', () => {
const wrapper = shallow(<Heading>Title</Heading>);
expect(wrapper.find(testId('title')).text())
.toEqual('Title');
});
it('displays the buttons', () => {
const icon1 = <div>Icon 1</div>;
const icon2 = <div>Icon 2</div>;
const wrapper = mount(
<Heading
buttons={[
{
icon: icon1,
alt: 'Close',
onClick: () => true,
},
{
icon: icon2,
alt: 'Open',
onClick: () => true,
},
]}
>
Title
</Heading>,
);
expect(wrapper.find('ButtonSecondary').length)
.toEqual(2);
expect(wrapper.find('ButtonSecondary').first().text())
.toContain('Icon 1');
expect(wrapper.find('ButtonSecondary').last().text())
.toContain('Icon 2');
});
it('has clickable buttons', () => {
const fn = jest.fn();
const wrapper = mount(
<Heading
buttons={[
{
icon: <div />,
alt: 'Action',
onClick: fn,
},
]}
>
Title
</Heading>,
);
wrapper.find('button').simulate('click');
expect(fn.mock.calls.length).toBe(1);
});
});

View file

@ -0,0 +1,43 @@
import React, { ReactElement, ReactNode } from 'react';
import 'twin.macro';
import ButtonSecondary from '../Styled/ButtonSecondary';
export interface HeadingProps {
buttons?: {
icon: ReactElement,
alt: string,
onClick: any,
}[],
children: ReactNode,
}
export default function Heading({
buttons,
children,
}: HeadingProps) {
return (
<div
tw="w-full flex bg-gray-200 dark:bg-gray-900 pl-4 pr-2"
>
<div
tw="flex items-center flex-grow uppercase text-xs text-gray-800 dark:text-gray-100 font-semibold py-1 select-text"
data-testid="title"
>
{children}
</div>
{buttons && buttons.map(
(button) => (
<ButtonSecondary
type="button"
key={button.icon + button.alt}
tw="p-2 cursor-pointer border-gray-300 text-xs"
title={button.alt}
onClick={button.onClick}
>
{ button.icon }
</ButtonSecondary>
),
)}
</div>
);
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import 'twin.macro';
import { Story, Meta } from '@storybook/react/types-6-0';
import Spacer, { SpacerProps } from './Spacer';
export default {
title: 'Utilities / Spacer',
component: Spacer,
decorators: [
(story) => <div tw="w-full bg-gray-300">{story()}</div>,
],
} as Meta;
const Template: Story<SpacerProps> = (args) => (
<Spacer {...args} />
);
export const Primary = Template.bind({ });
Primary.args = {
size: 'default',
};

View file

@ -0,0 +1,11 @@
import React from 'react';
import { mount } from '../../../tests/enzyme';
import Spacer from './Spacer';
describe('Spacer', () => {
it('can render without errors', () => {
expect(mount(<Spacer />).first()).not.toBeNull();
expect(mount(<Spacer size="half" />).first()).not.toBeNull();
expect(mount(<Spacer size="default" />).first()).not.toBeNull();
});
});

View file

@ -0,0 +1,24 @@
import React from 'react';
import tw from 'twin.macro';
export interface SpacerProps {
size?: 'half' | 'default',
}
export default function Spacer({
size,
}: SpacerProps) {
return (
<div
css={[
tw`w-full`,
size === 'half' && tw`py-1`,
(size === 'default' || !size) && tw`py-2`,
]}
/>
);
}
Spacer.defaultProps = {
size: 'default',
};

View file

@ -0,0 +1,40 @@
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import TextLimit, { TextLimitProps } from './TextLimit';
export default {
title: 'Utilities / Text Limit',
component: TextLimit,
} as Meta;
const Template: Story<TextLimitProps> = (args) => (
<TextLimit {...args}>{args.children}</TextLimit>
);
export const ShortText = Template.bind({ });
ShortText.args = {
characters: 50,
children: 'Short text',
};
export const LongText = Template.bind({ });
LongText.args = {
characters: 100,
children: 'Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text.',
};
export const ShortTextCut = Template.bind({ });
ShortTextCut.args = {
characters: 8,
children: 'Short text',
};
export const LongTextCut = Template.bind({ });
LongTextCut.args = {
characters: 50,
children: 'Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text.',
};

View file

@ -0,0 +1,15 @@
import React from 'react';
import { mount } from '../../../tests/enzyme';
import TextLimit from './TextLimit';
describe('Text Limit', () => {
it('doesnt cut text until it exceeds the character limit', () => {
const wrapper = mount(<TextLimit characters={4}>Text</TextLimit>);
expect(wrapper.text()).toEqual('Text');
});
it('cuts off text that exceeds the character limit', () => {
const wrapper = mount(<TextLimit characters={6}>Longer text</TextLimit>);
expect(wrapper.text()).toEqual('Longer…');
});
});

View file

@ -0,0 +1,21 @@
import React from 'react';
export interface TextLimitProps {
characters: number,
children: string,
}
export default function TextLimit({
characters,
children,
}: TextLimitProps) {
return (
<>
{
children.length > characters
? `${children.substring(0, characters)}`
: children
}
</>
);
}