Initial commit
This commit is contained in:
commit
7878e653a0
253 changed files with 24552 additions and 0 deletions
52
src/components/General/ContextMenu/ContextMenu.stories.tsx
Normal file
52
src/components/General/ContextMenu/ContextMenu.stories.tsx
Normal 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'),
|
||||
};
|
74
src/components/General/ContextMenu/ContextMenu.tsx
Normal file
74
src/components/General/ContextMenu/ContextMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
54
src/components/General/Editor/Editor.tsx
Normal file
54
src/components/General/Editor/Editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
27
src/components/General/Form/FormCheckbox.stories.tsx
Normal file
27
src/components/General/Form/FormCheckbox.stories.tsx
Normal 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,
|
||||
},
|
||||
};
|
46
src/components/General/Form/FormCheckbox.tsx
Normal file
46
src/components/General/Form/FormCheckbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
26
src/components/General/Form/FormEditor.stories.tsx
Normal file
26
src/components/General/Form/FormEditor.stories.tsx
Normal 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" }',
|
||||
},
|
||||
};
|
48
src/components/General/Form/FormEditor.tsx
Normal file
48
src/components/General/Form/FormEditor.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
33
src/components/General/Form/FormField.stories.tsx
Normal file
33
src/components/General/Form/FormField.stories.tsx
Normal 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',
|
||||
};
|
37
src/components/General/Form/FormField.tsx
Normal file
37
src/components/General/Form/FormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
38
src/components/General/Form/FormTextInput.stories.tsx
Normal file
38
src/components/General/Form/FormTextInput.stories.tsx
Normal 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: '',
|
||||
},
|
||||
};
|
34
src/components/General/Form/FormTextInput.tsx
Normal file
34
src/components/General/Form/FormTextInput.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
30
src/components/General/Form/FormTextInputArray.stories.tsx
Normal file
30
src/components/General/Form/FormTextInputArray.stories.tsx
Normal 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' },
|
||||
],
|
||||
},
|
||||
};
|
107
src/components/General/Form/FormTextInputArray.tsx
Normal file
107
src/components/General/Form/FormTextInputArray.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
32
src/components/General/Icons/SidebarIcon.tsx
Normal file
32
src/components/General/Icons/SidebarIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
35
src/components/General/List/List.stories.tsx
Normal file
35
src/components/General/List/List.stories.tsx
Normal 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} />
|
||||
</>
|
||||
),
|
||||
};
|
15
src/components/General/List/List.tsx
Normal file
15
src/components/General/List/List.tsx
Normal 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>
|
||||
);
|
||||
}
|
59
src/components/General/List/ListItem.stories.tsx
Normal file
59
src/components/General/List/ListItem.stories.tsx
Normal 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') },
|
||||
],
|
||||
};
|
15
src/components/General/List/ListItem.test.tsx
Normal file
15
src/components/General/List/ListItem.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
120
src/components/General/List/ListItem.tsx
Normal file
120
src/components/General/List/ListItem.tsx
Normal 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: [],
|
||||
};
|
29
src/components/General/NotificationList/NotificationList.tsx
Normal file
29
src/components/General/NotificationList/NotificationList.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
53
src/components/General/Popup/Popup.tsx
Normal file
53
src/components/General/Popup/Popup.tsx
Normal 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>
|
||||
);
|
||||
}
|
16
src/components/General/Popup/PopupBody.tsx
Normal file
16
src/components/General/Popup/PopupBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
43
src/components/General/Popup/PopupButtons.tsx
Normal file
43
src/components/General/Popup/PopupButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
39
src/components/General/PopupPresets/PopupConfirmation.tsx
Normal file
39
src/components/General/PopupPresets/PopupConfirmation.tsx
Normal 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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
68
src/components/General/PopupPresets/PopupPrompt.tsx
Normal file
68
src/components/General/PopupPresets/PopupPrompt.tsx
Normal 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: '',
|
||||
};
|
23
src/components/General/Styled/ButtonPrimary.stories.tsx
Normal file
23
src/components/General/Styled/ButtonPrimary.stories.tsx
Normal 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'),
|
||||
};
|
14
src/components/General/Styled/ButtonPrimary.tsx
Normal file
14
src/components/General/Styled/ButtonPrimary.tsx
Normal 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;
|
23
src/components/General/Styled/ButtonSecondary.stories.tsx
Normal file
23
src/components/General/Styled/ButtonSecondary.stories.tsx
Normal 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'),
|
||||
};
|
14
src/components/General/Styled/ButtonSecondary.tsx
Normal file
14
src/components/General/Styled/ButtonSecondary.tsx
Normal 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;
|
61
src/components/General/Styled/GlobalStyles.tsx
Normal file
61
src/components/General/Styled/GlobalStyles.tsx
Normal 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;
|
21
src/components/General/Styled/Link.stories.tsx
Normal file
21
src/components/General/Styled/Link.stories.tsx
Normal 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',
|
||||
};
|
5
src/components/General/Styled/Link.tsx
Normal file
5
src/components/General/Styled/Link.tsx
Normal 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;
|
131
src/components/General/Tour/Tour.tsx
Normal file
131
src/components/General/Tour/Tour.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
22
src/components/General/Utilities/EmptyMessage.stories.tsx
Normal file
22
src/components/General/Utilities/EmptyMessage.stories.tsx
Normal 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.',
|
||||
};
|
17
src/components/General/Utilities/EmptyMessage.test.tsx
Normal file
17
src/components/General/Utilities/EmptyMessage.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
47
src/components/General/Utilities/EmptyMessage.tsx
Normal file
47
src/components/General/Utilities/EmptyMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
44
src/components/General/Utilities/Heading.stories.tsx
Normal file
44
src/components/General/Utilities/Heading.stories.tsx
Normal 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'),
|
||||
},
|
||||
],
|
||||
};
|
62
src/components/General/Utilities/Heading.test.tsx
Normal file
62
src/components/General/Utilities/Heading.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
43
src/components/General/Utilities/Heading.tsx
Normal file
43
src/components/General/Utilities/Heading.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
src/components/General/Utilities/Spacer.stories.tsx
Normal file
22
src/components/General/Utilities/Spacer.stories.tsx
Normal 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',
|
||||
};
|
11
src/components/General/Utilities/Spacer.test.tsx
Normal file
11
src/components/General/Utilities/Spacer.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
24
src/components/General/Utilities/Spacer.tsx
Normal file
24
src/components/General/Utilities/Spacer.tsx
Normal 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',
|
||||
};
|
40
src/components/General/Utilities/TextLimit.stories.tsx
Normal file
40
src/components/General/Utilities/TextLimit.stories.tsx
Normal 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.',
|
||||
};
|
15
src/components/General/Utilities/TextLimit.test.tsx
Normal file
15
src/components/General/Utilities/TextLimit.test.tsx
Normal 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…');
|
||||
});
|
||||
});
|
21
src/components/General/Utilities/TextLimit.tsx
Normal file
21
src/components/General/Utilities/TextLimit.tsx
Normal 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
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue