Added some basic shared components, utils, hooks

This commit is contained in:
ireic
2019-12-08 03:49:49 +01:00
parent 6be3ac2e77
commit 3143f66a0f
82 changed files with 40121 additions and 5 deletions

View File

@@ -0,0 +1,30 @@
import styled from 'styled-components';
import { font, mixin } from 'shared/utils/styles';
export const Image = styled.div`
display: inline-block;
width: ${props => props.size}px;
height: ${props => props.size}px;
border-radius: 100%;
background-image: url('${props => props.avatarUrl}');
${mixin.backgroundImage}
`;
export const Letter = styled.div`
display: inline-block;
width: ${props => props.size}px;
height: ${props => props.size}px;
border-radius: 100%;
text-transform: uppercase;
color: #fff;
background: ${props => props.color};
${font.medium}
${props => font.size(Math.round(props.size / 1.7))}
& > span {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
`;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Image, Letter } from './Styles';
const propTypes = {
className: PropTypes.string,
avatarUrl: PropTypes.string,
name: PropTypes.string,
size: PropTypes.number,
};
const defaultProps = {
className: undefined,
avatarUrl: null,
name: '',
size: 24,
};
const colors = [
'#DA7657',
'#6ADA57',
'#5784DA',
'#AA57DA',
'#DA5757',
'#DA5792',
'#57DACA',
'#57A5DA',
];
const getColorFromName = name => colors[name.toLocaleLowerCase().charCodeAt(0) % colors.length];
const Avatar = ({ className, avatarUrl, name, size }) => {
if (avatarUrl) {
return <Image className={className} size={size} avatarUrl={avatarUrl} />;
}
return (
<Letter className={className} size={size} color={getColorFromName(name)}>
<span>{name.charAt(0)}</span>
</Letter>
);
};
Avatar.propTypes = propTypes;
Avatar.defaultProps = defaultProps;
export default Avatar;

View File

@@ -0,0 +1,86 @@
import styled, { css } from 'styled-components';
import Spinner from 'shared/components/Spinner';
import { color, font, mixin } from 'shared/utils/styles';
export const StyledButton = styled.button`
display: inline-block;
height: 36px;
line-height: 34px;
padding: 0 18px;
vertical-align: middle;
white-space: nowrap;
text-align: center;
border-radius: 4px;
transition: all 0.1s;
appearance: none !important;
${mixin.clickable}
${font.bold}
${font.size(14)}
${props => (props.hollow ? hollowStyles : filledStyles)}
&:disabled {
opacity: 0.6;
cursor: default;
}
i {
position: relative;
top: -1px;
right: 4px;
margin-right: 7px;
display: inline-block;
vertical-align: middle;
line-height: 1;
font-size: 16px;
}
${props => (props.iconOnly ? iconOnlyStyles : '')}
`;
const filledStyles = props => css`
color: #fff;
background: ${color[props.color]};
border: 1px solid ${color[props.color]};
${!props.disabled &&
css`
&:hover,
&:focus {
background: ${mixin.darken(color[props.color], 0.15)};
border: 1px solid ${mixin.darken(color[props.color], 0.15)};
}
&:active {
background: ${mixin.lighten(color[props.color], 0.1)};
border: 1px solid ${mixin.lighten(color[props.color], 0.1)};
}
`}
`;
const hollowStyles = props => css`
color: ${color.textMediumBlue};
background: #fff;
border: 1px solid ${color.borderBlue};
${!props.disabled &&
css`
&:hover,
&:focus {
border: 1px solid ${mixin.darken(color.borderBlue, 0.15)};
}
&:active {
border: 1px solid ${color.borderBlue};
}
`}
`;
const iconOnlyStyles = css`
padding: 0 12px;
i {
right: 0;
margin-right: 0;
}
`;
export const StyledSpinner = styled(Spinner)`
position: relative;
right: 8px;
display: inline-block;
vertical-align: middle;
line-height: 1;
`;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import PropTypes from 'prop-types';
import { color } from 'shared/utils/styles';
import Icon from 'shared/components/Icon';
import { StyledButton, StyledSpinner } from './Styles';
const propTypes = {
className: PropTypes.string,
children: PropTypes.node,
type: PropTypes.string,
hollow: PropTypes.bool,
color: PropTypes.oneOf(['primary', 'success', 'danger']),
icon: PropTypes.string,
iconSize: PropTypes.number,
disabled: PropTypes.bool,
working: PropTypes.bool,
onClick: PropTypes.func,
};
const defaultProps = {
className: undefined,
children: undefined,
type: 'button',
hollow: false,
color: 'primary',
icon: undefined,
iconSize: undefined,
disabled: false,
working: false,
onClick: () => {},
};
const Button = ({
children,
hollow,
icon,
iconSize,
disabled,
working,
onClick = () => {},
...buttonProps
}) => (
<StyledButton
{...buttonProps}
hollow={hollow}
onClick={() => {
if (!disabled && !working) {
onClick();
}
}}
disabled={disabled || working}
working={working}
iconOnly={!children}
>
{working && <StyledSpinner size={26} color={hollow ? color.textMediumBlue : '#fff'} />}
{!working && icon && (
<Icon type={icon} size={iconSize} color={hollow ? color.textMediumBlue : '#fff'} />
)}
{children}
</StyledButton>
);
Button.propTypes = propTypes;
Button.defaultProps = defaultProps;
export default Button;

View File

@@ -0,0 +1,38 @@
import styled from 'styled-components';
import Modal from 'shared/components/Modal';
import Input from 'shared/components/Input';
import Button from 'shared/components/Button';
import { font } from 'shared/utils/styles';
export const StyledConfirmModal = styled(Modal)`
padding: 45px 50px 50px;
`;
export const Title = styled.div`
padding-bottom: 25px;
${font.bold}
${font.size(24)}
line-height: 1.5;
`;
export const Message = styled.p`
padding-bottom: 25px;
white-space: pre-wrap;
${font.size(16)}
`;
export const InputLabel = styled.div`
padding-bottom: 12px;
${font.bold}
${font.size(16)}
`;
export const StyledInput = styled(Input)`
margin-bottom: 25px;
max-width: 220px;
`;
export const StyledButton = styled(Button)`
margin: 5px 20px 0 0;
`;

View File

@@ -0,0 +1,99 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import {
StyledConfirmModal,
Title,
Message,
InputLabel,
StyledInput,
StyledButton,
} from './Styles';
const propTypes = {
className: PropTypes.string,
title: PropTypes.string,
message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
confirmText: PropTypes.string,
cancelText: PropTypes.string,
confirmInput: PropTypes.string,
type: PropTypes.oneOf(['primary', 'danger']),
onConfirm: PropTypes.func.isRequired,
renderLink: PropTypes.func.isRequired,
};
const defaultProps = {
className: undefined,
title: 'Warning',
message: 'Are you sure you want to continue with this action?',
confirmText: 'Confirm',
cancelText: 'Cancel',
confirmInput: null,
type: 'primary',
};
const ConfirmModal = ({
className,
title,
message,
confirmText,
cancelText,
confirmInput,
type,
onConfirm,
renderLink,
}) => {
const [isConfirmEnabled, setConfirmEnabled] = useState(false);
const [isWorking, setWorking] = useState(false);
const handleConfirm = modal => {
setWorking(true);
onConfirm({
close: () => {
modal.close();
setWorking(false);
},
});
};
const handleConfirmInputChange = value =>
setConfirmEnabled(value.trim().toLowerCase() === confirmInput.toLowerCase());
return (
<StyledConfirmModal
suppressClassNameWarning
className={className}
afterClose={() => setConfirmEnabled(false)}
renderLink={renderLink}
renderContent={modal => (
<>
<Title>{title}</Title>
{message && <Message>{message}</Message>}
{confirmInput && (
<>
<InputLabel>{`Type ${confirmInput} below to confirm.`}</InputLabel>
<StyledInput onChange={(event, value) => handleConfirmInputChange(value)} />
<br />
</>
)}
<StyledButton hollow onClick={modal.close}>
{cancelText}
</StyledButton>
<StyledButton
color={type}
disabled={confirmInput && !isConfirmEnabled}
working={isWorking}
onClick={() => handleConfirm(modal)}
>
{confirmText}
</StyledButton>
</>
)}
/>
);
};
ConfirmModal.propTypes = propTypes;
ConfirmModal.defaultProps = defaultProps;
export default ConfirmModal;

View File

@@ -0,0 +1,117 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import { times, range } from 'lodash';
import { formatDate, formatDateTimeForAPI } from 'shared/utils/dateTime';
import Icon from 'shared/components/Icon';
import {
DateSection,
YearSelect,
SelectedMonthYear,
Grid,
PrevNextIcons,
DayName,
Day,
} from './Styles';
const propTypes = {
withTime: PropTypes.bool,
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
setDropdownOpen: PropTypes.func.isRequired,
};
const defaultProps = {
withTime: true,
value: null,
};
const DatePickerDateSection = ({ withTime, value, onChange, setDropdownOpen }) => {
const [selectedMonth, setSelectedMonth] = useState(moment(value || undefined).startOf('month'));
const handleYearChange = year => {
setSelectedMonth(moment(selectedMonth).set({ year: parseInt(year) }));
};
const handleMonthChange = addOrSubtract => {
setSelectedMonth(moment(selectedMonth)[addOrSubtract](1, 'month'));
};
const handleDayChange = newDate => {
const existingHour = value ? moment(value).hour() : '00';
const existingMinute = value ? moment(value).minute() : '00';
const newDateWithExistingTime = newDate.set({
hour: existingHour,
minute: existingMinute,
});
onChange(formatDateTimeForAPI(newDateWithExistingTime));
if (!withTime) {
setDropdownOpen(false);
}
};
const generateYears = () => times(50, i => ({ label: `${i + 2010}`, value: `${i + 2010}` }));
const generateWeekDayNames = () => moment.weekdaysMin(true);
const generateFillerDaysBeforeMonthStart = () => {
const count = selectedMonth.diff(moment(selectedMonth).startOf('week'), 'days');
return range(count);
};
const generateMonthDays = () =>
times(selectedMonth.daysInMonth()).map(i => moment(selectedMonth).add(i, 'days'));
const generateFillerDaysAfterMonthEnd = () => {
const selectedMonthEnd = moment(selectedMonth).endOf('month');
const weekEnd = moment(selectedMonthEnd).endOf('week');
const count = weekEnd.diff(selectedMonthEnd, 'days');
return range(count);
};
return (
<DateSection>
<SelectedMonthYear>{formatDate(selectedMonth, 'MMM YYYY')}</SelectedMonthYear>
<YearSelect onChange={event => handleYearChange(event.target.value)}>
{[{ label: 'Year', value: '' }, ...generateYears()].map(option => (
<option key={option.label} value={option.value}>
{option.label}
</option>
))}
</YearSelect>
<PrevNextIcons>
<Icon type="arrow-left" onClick={() => handleMonthChange('subtract')} />
<Icon type="arrow-right" onClick={() => handleMonthChange('add')} />
</PrevNextIcons>
<Grid>
{generateWeekDayNames().map(name => (
<DayName key={name}>{name}</DayName>
))}
{generateFillerDaysBeforeMonthStart().map(i => (
<Day key={`before-${i}`} isFiller />
))}
{generateMonthDays().map(date => (
<Day
key={date}
isToday={moment().isSame(date, 'day')}
isSelected={moment(value).isSame(date, 'day')}
onClick={() => handleDayChange(date)}
>
{formatDate(date, 'D')}
</Day>
))}
{generateFillerDaysAfterMonthEnd().map(i => (
<Day key={`after-${i}`} isFiller />
))}
</Grid>
</DateSection>
);
};
DatePickerDateSection.propTypes = propTypes;
DatePickerDateSection.defaultProps = defaultProps;
export default DatePickerDateSection;

View File

@@ -0,0 +1,116 @@
import styled from 'styled-components';
import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
export const StyledDatePicker = styled.div`
position: relative;
`;
export const Dropdown = styled.div`
z-index: ${zIndexValues.dropdown};
position: absolute;
top: 130%;
right: 0;
width: 270px;
border-radius: 4px;
background: #fff;
${mixin.boxShadowBorderMedium}
${props => (props.withTime ? withTimeStyles : '')}
`;
const withTimeStyles = `
width: 360px;
padding-right: 90px;
`;
export const DateSection = styled.div`
position: relative;
padding: 20px;
`;
export const SelectedMonthYear = styled.div`
display: inline-block;
padding-left: 7px;
${font.bold}
${font.size(16)}
`;
export const YearSelect = styled.select`
margin-left: 5px;
width: 60px;
height: 22px;
${font.size(13)}
`;
export const PrevNextIcons = styled.div`
position: absolute;
top: 12px;
right: 19px;
i {
padding: 7px 5px 4px;
font-size: 22px;
color: ${color.textLight};
${mixin.clickable}
&:hover {
color: ${color.textDarkest};
}
}
`;
export const Grid = styled.div`
display: flex;
flex-wrap: wrap;
padding-top: 15px;
text-align: center;
`;
export const DayName = styled.div`
width: 14.28%;
height: 30px;
line-height: 30px;
color: ${color.textLight};
${font.size(13)}
`;
export const Day = styled.div`
width: 14.28%;
height: 30px;
line-height: 30px;
border-radius: 4px;
${font.size(15)}
${props => (!props.isFiller ? hoverStyles : '')}
${props => (props.isToday ? font.bold : '')}
${props => (props.isSelected ? selectedStyles : '')}
`;
export const TimeSection = styled.div`
position: absolute;
top: 0;
right: 0;
height: 100%;
width: 90px;
padding: 5px 0;
border-left: 1px solid ${color.borderLight};
${mixin.scrollableY}
`;
export const Time = styled.div`
padding: 5px 0 5px 20px;
${font.size(14)}
${props => (!props.isFiller ? hoverStyles : '')}
${props => (props.isSelected ? selectedStyles : '')}
`;
const hoverStyles = `
${mixin.clickable}
&:hover {
background: ${color.backgroundMedium};
}
`;
const selectedStyles = `
color: #fff;
&:hover, & {
background: ${color.primary};
}
`;

View File

@@ -0,0 +1,76 @@
import React, { useLayoutEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import { range } from 'lodash';
import { formatDate, formatDateTimeForAPI } from 'shared/utils/dateTime';
import { TimeSection, Time } from './Styles';
const propTypes = {
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
setDropdownOpen: PropTypes.func.isRequired,
};
const defaultProps = {
value: null,
};
const DatePickerTimeSection = ({ value, onChange, setDropdownOpen }) => {
const $sectionRef = useRef();
const formattedTimeValue = formatDate(value, 'HH:mm');
useLayoutEffect(() => {
const scrollToSelectedTime = () => {
if (!$sectionRef.current) return;
const $selectedTime = $sectionRef.current.querySelector(
`[data-time="${formattedTimeValue}"]`,
);
if (!$selectedTime) return;
$sectionRef.current.scrollTop = $selectedTime.offsetTop - 80;
};
scrollToSelectedTime();
}, [formattedTimeValue]);
const handleTimeChange = newTime => {
const [newHour, newMinute] = newTime.split(':');
const existingDate = moment(value || undefined);
const existingDateWithNewTime = existingDate.set({
hour: parseInt(newHour),
minute: parseInt(newMinute),
});
onChange(formatDateTimeForAPI(existingDateWithNewTime));
setDropdownOpen(false);
};
const generateTimes = () =>
range(48).map(i => {
const hour = `${Math.floor(i / 2)}`;
const paddedHour = hour.length < 2 ? `0${hour}` : hour;
const minute = i % 2 === 0 ? '00' : '30';
return `${paddedHour}:${minute}`;
});
return (
<TimeSection ref={$sectionRef}>
{generateTimes().map(time => (
<Time
key={time}
data-time={time}
isSelected={time === formattedTimeValue}
onClick={() => handleTimeChange(time)}
>
{time}
</Time>
))}
</TimeSection>
);
};
DatePickerTimeSection.propTypes = propTypes;
DatePickerTimeSection.defaultProps = defaultProps;
export default DatePickerTimeSection;

View File

@@ -0,0 +1,65 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { formatDate, formatDateTime } from 'shared/utils/dateTime';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import Input from 'shared/components/Input';
import DateSection from './DateSection';
import TimeSection from './TimeSection';
import { StyledDatePicker, Dropdown } from './Styles';
const propTypes = {
className: PropTypes.string,
withTime: PropTypes.bool,
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
const defaultProps = {
className: undefined,
withTime: true,
value: null,
};
const DatePicker = ({ className, withTime, value, onChange, ...inputProps }) => {
const [isDropdownOpen, setDropdownOpen] = useState(false);
const $containerRef = useRef();
useOnOutsideClick($containerRef, isDropdownOpen, () => setDropdownOpen(false));
const formatValueForInput = () => {
if (!value) return '';
return withTime ? formatDateTime(value) : formatDate(value);
};
return (
<StyledDatePicker ref={$containerRef}>
<Input
icon="calendar"
{...inputProps}
className={className}
autoComplete="off"
value={formatValueForInput()}
onClick={() => setDropdownOpen(true)}
/>
{isDropdownOpen && (
<Dropdown withTime={withTime}>
<DateSection
withTime={withTime}
value={value}
onChange={onChange}
setDropdownOpen={setDropdownOpen}
/>
{withTime && (
<TimeSection value={value} onChange={onChange} setDropdownOpen={setDropdownOpen} />
)}
</Dropdown>
)}
</StyledDatePicker>
);
};
DatePicker.propTypes = propTypes;
DatePicker.defaultProps = defaultProps;
export default DatePicker;

View File

@@ -0,0 +1,20 @@
import styled from 'styled-components';
export default styled.i`
display: inline-block;
font-size: ${props => `${props.size}px`};
${props =>
props.left || props.top ? `transform: translate(${props.left}px, ${props.top}px);` : ''}
&:before {
content: "${props => props.code}";
font-family: "jira" !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
`;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import StyledIcon from './Styles';
const codes = {
[`check-circle`]: '\\e86c',
[`check-fat`]: '\\f00c',
[`arrow-left`]: '\\e900',
[`arrow-right`]: '\\e912',
[`upload-thin`]: '\\e91f',
[`bell`]: '\\e901',
[`calendar`]: '\\e903',
[`check`]: '\\e904',
[`chevron-down`]: '\\e905',
[`chevron-left`]: '\\e906',
[`chevron-right`]: '\\e907',
[`chevron-up`]: '\\e908',
[`clock`]: '\\e909',
[`download`]: '\\e90a',
[`plus`]: '\\e90c',
[`refresh`]: '\\e90d',
[`search`]: '\\e90e',
[`upload`]: '\\e90f',
[`close`]: '\\e910',
[`archive`]: '\\e915',
[`briefcase`]: '\\e916',
[`settings`]: '\\e902',
[`email`]: '\\e914',
[`lock`]: '\\e913',
[`dashboard`]: '\\e917',
[`alert`]: '\\e911',
[`edit`]: '\\e918',
[`delete`]: '\\e919',
[`sort`]: '\\f0dc',
[`sort-up`]: '\\f0d8',
[`sort-down`]: '\\f0d7',
[`euro`]: '\\f153',
[`folder-plus`]: '\\e921',
[`folder-minus`]: '\\e920',
[`file`]: '\\e90b',
[`file-text`]: '\\e924',
};
const propTypes = {
type: PropTypes.oneOf(Object.keys(codes)).isRequired,
className: PropTypes.string,
size: PropTypes.number,
left: PropTypes.number,
top: PropTypes.number,
};
const defaultProps = {
className: undefined,
size: 16,
left: 0,
top: 0,
};
const Icon = ({ type, ...iconProps }) => <StyledIcon {...iconProps} code={codes[type]} />;
Icon.propTypes = propTypes;
Icon.defaultProps = defaultProps;
export default Icon;

View File

@@ -0,0 +1,34 @@
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export default styled.div`
position: relative;
display: inline-block;
height: 40px;
width: 100%;
input {
height: 100%;
width: 100%;
padding: 0 15px;
border-radius: 4px;
border: 1px solid ${color.borderLight};
box-shadow: inset 0 0 1px 0 rgba(0, 0, 0, 0.03);
background: #fff;
${font.regular}
${font.size(14)}
&:focus {
border: 1px solid ${color.borderMedium};
}
${props => (props.icon ? 'padding-left: 40px;' : '')}
${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
}
i {
position: absolute;
top: 12px;
left: 14px;
font-size: 16px;
pointer-events: none;
color: ${color.textMedium};
}
`;

View File

@@ -0,0 +1,42 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import Icon from 'shared/components/Icon';
import StyledInput from './Styles';
const propTypes = {
icon: PropTypes.string,
className: PropTypes.string,
invalid: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
filter: PropTypes.instanceOf(RegExp),
onChange: PropTypes.func,
};
const defaultProps = {
icon: undefined,
className: undefined,
invalid: false,
value: undefined,
filter: undefined,
onChange: () => {},
};
const Input = forwardRef(({ icon, className, invalid, filter, onChange, ...inputProps }, ref) => {
const handleChange = event => {
if (!filter || filter.test(event.target.value)) {
onChange(event, event.target.value);
}
};
return (
<StyledInput className={className} icon={icon} invalid={invalid}>
{icon && <Icon type={icon} />}
<input {...inputProps} onChange={handleChange} ref={ref} />
</StyledInput>
);
});
Input.propTypes = propTypes;
Input.defaultProps = defaultProps;
export default Input;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import PropTypes from 'prop-types';
const propTypes = {
className: PropTypes.string,
width: PropTypes.number,
};
const defaultProps = {
className: undefined,
width: 28,
};
const Logo = ({ className, width }) => (
<span className={className}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.76 75.76" width={width}>
<defs>
<linearGradient
id="linear-gradient"
x1="34.64"
y1="15.35"
x2="19"
y2="30.99"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.18" stopColor="rgba(0, 82, 204, 0.2)" />
<stop offset="1" stopColor="#DEEBFE" />
</linearGradient>
<linearGradient
id="linear-gradient-2"
x1="38.78"
y1="60.28"
x2="54.39"
y2="44.67"
xlinkHref="#linear-gradient"
/>
</defs>
<title>Jira Software-blue</title>
<g id="Layer_2" data-name="Layer 2">
<g id="Blue">
<path
style={{ fill: '#DEEBFE' }}
d="M72.4,35.76,39.8,3.16,36.64,0h0L12.1,24.54h0L.88,35.76A3,3,0,0,0,.88,40L23.3,62.42,36.64,75.76,61.18,51.22l.38-.38L72.4,40A3,3,0,0,0,72.4,35.76ZM36.64,49.08l-11.2-11.2,11.2-11.2,11.2,11.2Z"
/>
<path
style={{ fill: 'url(#linear-gradient)' }}
d="M36.64,26.68A18.86,18.86,0,0,1,36.56.09L12.05,24.59,25.39,37.93,36.64,26.68Z"
/>
<path
style={{ fill: 'url(#linear-gradient-2)' }}
d="M47.87,37.85,36.64,49.08a18.86,18.86,0,0,1,0,26.68h0L61.21,51.19Z"
/>
</g>
</g>
</svg>
</span>
);
Logo.propTypes = propTypes;
Logo.defaultProps = defaultProps;
export default Logo;

View File

@@ -0,0 +1,82 @@
import styled, { css } from 'styled-components';
import Icon from 'shared/components/Icon';
import { color, mixin, zIndexValues } from 'shared/utils/styles';
export const ScrollOverlay = styled.div`
z-index: ${zIndexValues.modal};
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
${mixin.scrollableY}
`;
export const ClickableOverlay = styled.div`
min-height: 100%;
background: ${mixin.rgba(color.textLightBlue, 0.7)};
${props => clickOverlayStyles[props.variant]}
`;
const clickOverlayStyles = {
center: css`
display: flex;
justify-content: center;
align-items: center;
padding: 50px;
`,
aside: css`
text-align: right;
`,
};
export const StyledModal = styled.div`
display: inline-block;
position: relative;
width: 100%;
background: #fff;
${props => modalStyles[props.variant]}
`;
const modalStyles = {
center: css`
max-width: 600px;
vertical-align: middle;
text-align: left;
${mixin.boxShadowMedium}
`,
aside: css`
min-height: 100vh;
max-width: 500px;
text-align: left;
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.15);
`,
};
export const CloseIcon = styled(Icon)`
position: absolute;
font-size: 25px;
color: ${color.textDark};
${mixin.clickable}
${props => closeIconStyles[props.variant]}
`;
const closeIconStyles = {
center: css`
top: 8px;
right: 10px;
padding: 7px 7px 0;
`,
aside: css`
top: 10px;
left: -50px;
width: 40px;
height: 40px;
padding-top: 8px;
border-radius: 40px;
text-align: center;
background: #fff;
opacity: 0.5;
`,
};

View File

@@ -0,0 +1,94 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { uniqueId as uniqueIncreasingIntegerId } from 'lodash';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import { ScrollOverlay, ClickableOverlay, StyledModal, CloseIcon } from './Styles';
const propTypes = {
className: PropTypes.string,
variant: PropTypes.oneOf(['center', 'aside']),
isOpen: PropTypes.bool,
onClose: PropTypes.func,
renderLink: PropTypes.func,
renderContent: PropTypes.func.isRequired,
};
const defaultProps = {
className: undefined,
variant: 'center',
isOpen: undefined,
onClose: () => {},
renderLink: () => {},
};
const Modal = ({
className,
variant,
isOpen: propsIsOpen,
onClose: tellParentToClose,
renderLink,
renderContent,
}) => {
const [stateIsOpen, setStateOpen] = useState(false);
const isControlled = typeof propsIsOpen === 'boolean';
const isOpen = isControlled ? propsIsOpen : stateIsOpen;
const $modalRef = useRef();
const modalIdRef = useRef(uniqueIncreasingIntegerId());
const closeModal = useCallback(() => {
if (shouldNotCloseBecauseHasOpenChildModal(modalIdRef.current)) {
return;
}
if (!isControlled) {
setStateOpen(false);
} else {
tellParentToClose();
}
}, [isControlled, tellParentToClose]);
useOnOutsideClick($modalRef, isOpen, closeModal);
useOnEscapeKeyDown(isOpen, closeModal);
useEffect(setBodyScrollLock, [isOpen]);
const renderModal = () => (
<ScrollOverlay data-jira-modal-id={modalIdRef.current}>
<ClickableOverlay variant={variant}>
<StyledModal className={className} variant={variant} ref={$modalRef}>
<CloseIcon type="close" variant={variant} onClick={closeModal} />
{renderContent({ close: closeModal })}
</StyledModal>
</ClickableOverlay>
</ScrollOverlay>
);
return (
<>
{!isControlled && renderLink({ open: () => setStateOpen(true) })}
{isOpen && ReactDOM.createPortal(renderModal(), $root)}
</>
);
};
const $root = document.getElementById('root');
const getIdsOfAllOpenModals = () => {
const $modalNodes = Array.from(document.querySelectorAll('[data-jira-modal-id]'));
return $modalNodes.map($node => parseInt($node.getAttribute('data-jira-modal-id')));
};
const shouldNotCloseBecauseHasOpenChildModal = modalId =>
getIdsOfAllOpenModals().some(id => id > modalId);
const setBodyScrollLock = () => {
const areAnyModalsOpen = getIdsOfAllOpenModals().length > 0;
document.body.style.overflow = areAnyModalsOpen ? 'hidden' : 'visible';
};
Modal.propTypes = propTypes;
Modal.defaultProps = defaultProps;
export default Modal;

View File

@@ -0,0 +1,7 @@
import styled from 'styled-components';
export default styled.div`
width: 100%;
padding: 100px;
text-align: center;
`;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import Spinner from 'shared/components/Spinner';
import StyledPageLoader from './Styles';
const PageLoader = () => (
<StyledPageLoader>
<Spinner size={70} />
</StyledPageLoader>
);
export default PageLoader;

View File

@@ -0,0 +1,204 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { uniq } from 'lodash';
import { KeyCodes } from 'shared/constants/keyCodes';
import { ClearIcon, Dropdown, DropdownInput, Options, Option, OptionsNoResults } from './Styles';
const propTypes = {
value: PropTypes.any,
isValueEmpty: PropTypes.bool.isRequired,
searchValue: PropTypes.string.isRequired,
setSearchValue: PropTypes.func.isRequired,
$inputRef: PropTypes.object.isRequired,
deactivateDropdown: PropTypes.func.isRequired,
options: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
onCreate: PropTypes.func,
isMulti: PropTypes.bool,
};
const defaultProps = {
value: undefined,
onCreate: undefined,
isMulti: false,
};
const SelectDropdown = ({
value,
isValueEmpty,
searchValue,
setSearchValue,
$inputRef,
deactivateDropdown,
options,
onChange,
onCreate,
isMulti,
}) => {
const [isCreatingOption, setCreatingOption] = useState(false);
const $optionsRef = useRef();
const selectOptionValue = optionValue => {
deactivateDropdown();
if (isMulti) {
onChange(uniq([...value, optionValue]));
} else {
onChange(optionValue);
}
};
const createOption = newOptionLabel => {
setCreatingOption(true);
onCreate(newOptionLabel, createdOptionValue => {
setCreatingOption(false);
selectOptionValue(createdOptionValue);
});
};
const clearOptionValues = () => {
$inputRef.current.value = '';
$inputRef.current.focus();
onChange(isMulti ? [] : null);
};
const handleInputKeyDown = event => {
if (event.keyCode === KeyCodes.escape) {
handleInputEscapeKeyDown(event);
} else if (event.keyCode === KeyCodes.enter) {
handleInputEnterKeyDown(event);
} else if (event.keyCode === KeyCodes.arrowDown || event.keyCode === KeyCodes.arrowUp) {
handleInputArrowUpOrDownKeyDown(event);
}
};
const handleInputEscapeKeyDown = event => {
event.nativeEvent.stopImmediatePropagation();
deactivateDropdown();
};
const handleInputEnterKeyDown = event => {
event.preventDefault();
const $active = getActiveOptionNode();
if (!$active) return;
const optionValueToSelect = $active.getAttribute('data-select-option-value');
const optionLabelToCreate = $active.getAttribute('data-create-option-label');
if (optionValueToSelect) {
selectOptionValue(optionValueToSelect);
} else if (optionLabelToCreate) {
createOption(optionLabelToCreate);
}
};
const handleInputArrowUpOrDownKeyDown = event => {
const $active = getActiveOptionNode();
if (!$active) return;
const $options = $optionsRef.current;
const $optionsHeight = $options.getBoundingClientRect().height;
const $activeHeight = $active.getBoundingClientRect().height;
if (event.keyCode === KeyCodes.arrowDown) {
if ($options.lastElementChild === $active) {
$active.classList.remove(activeOptionClass);
$options.firstElementChild.classList.add(activeOptionClass);
$options.scrollTop = 0;
} else {
$active.classList.remove(activeOptionClass);
$active.nextElementSibling.classList.add(activeOptionClass);
if ($active.offsetTop > $options.scrollTop + $optionsHeight / 1.4) {
$options.scrollTop += $activeHeight;
}
}
} else if (event.keyCode === KeyCodes.arrowUp) {
if ($options.firstElementChild === $active) {
$active.classList.remove(activeOptionClass);
$options.lastElementChild.classList.add(activeOptionClass);
$options.scrollTop = $options.scrollHeight;
} else {
$active.classList.remove(activeOptionClass);
$active.previousElementSibling.classList.add(activeOptionClass);
if ($active.offsetTop < $options.scrollTop + $optionsHeight / 2.4) {
$options.scrollTop -= $activeHeight;
}
}
}
};
const handleOptionMouseEnter = event => {
const $active = getActiveOptionNode();
if ($active) $active.classList.remove(activeOptionClass);
event.target.classList.add(activeOptionClass);
};
const getActiveOptionNode = () => $optionsRef.current.querySelector(`.${activeOptionClass}`);
const optionsFilteredBySearchValue = options.filter(option =>
option.label
.toString()
.toLowerCase()
.includes(searchValue.toLowerCase()),
);
const removeSelectedOptions = opts => opts.filter(option => !value.includes(option.value));
const filteredOptions = isMulti
? removeSelectedOptions(optionsFilteredBySearchValue)
: optionsFilteredBySearchValue;
const searchValueNotInOptions = !options.map(option => option.label).includes(searchValue);
const isOptionCreatable = onCreate && searchValue && searchValueNotInOptions;
const renderSelectableOption = (option, i) => (
<Option
key={option.value}
className={i === 0 ? activeOptionClass : undefined}
isSelected={option.value === value}
data-select-option-value={option.value}
onMouseEnter={handleOptionMouseEnter}
onClick={() => selectOptionValue(option.value)}
>
{option.label}
</Option>
);
const renderCreatableOption = () => (
<Option
className={filteredOptions.length === 0 ? activeOptionClass : undefined}
data-create-option-label={searchValue}
onMouseEnter={handleOptionMouseEnter}
onClick={() => createOption(searchValue)}
>
{isCreatingOption ? `Creating "${searchValue}"...` : `Create "${searchValue}"`}
</Option>
);
return (
<Dropdown>
<DropdownInput
type="text"
placeholder="Search"
ref={$inputRef}
autoFocus
onKeyDown={handleInputKeyDown}
onChange={event => setSearchValue(event.target.value)}
/>
{!isValueEmpty && <ClearIcon type="close" onClick={clearOptionValues} />}
<Options ref={$optionsRef}>
{filteredOptions.map(renderSelectableOption)}
{isOptionCreatable && renderCreatableOption()}
{filteredOptions.length === 0 && <OptionsNoResults>No results</OptionsNoResults>}
</Options>
</Dropdown>
);
};
const activeOptionClass = 'jira-select-option-is-active';
SelectDropdown.propTypes = propTypes;
SelectDropdown.defaultProps = defaultProps;
export default SelectDropdown;

View File

@@ -0,0 +1,134 @@
import styled, { css } from 'styled-components';
import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
import Icon from 'shared/components/Icon';
export const StyledSelect = styled.div`
position: relative;
width: 100%;
border-radius: 4px;
border: 1px solid ${color.borderLight};
background: #fff;
${font.size(14)}
&:focus {
outline: none;
border: 1px solid ${color.borderMedium};
}
${props => (props.hasIcon ? 'padding-left: 25px;' : '')}
${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
`;
export const StyledIcon = styled(Icon)`
position: absolute;
top: 12px;
left: 14px;
font-size: 16px;
color: ${color.textMedium};
`;
export const ValueContainer = styled.div`
min-height: 38px;
width: 100%;
`;
export const ChevronIcon = styled(Icon)`
position: absolute;
top: 10px;
right: 11px;
font-size: 18px;
color: ${color.textMedium};
`;
export const Placeholder = styled.div`
padding: 11px 0 0 15px;
color: ${color.textLightBlue};
`;
export const ValueSingle = styled.div`
padding: 11px 0 0 15px;
`;
export const ValueMulti = styled.div`
padding: 15px 5px 10px 10px;
`;
export const ValueMultiItem = styled.div`
margin: 0 5px 5px 0;
${mixin.tag}
`;
export const AddMore = styled.div`
display: inline-block;
height: 24px;
line-height: 22px;
padding-right: 5px;
${font.size(12)}
${mixin.link()}
i {
margin-right: 3px;
vertical-align: middle;
font-size: 14px;
}
`;
export const Dropdown = styled.div`
z-index: ${zIndexValues.dropdown};
position: absolute;
top: 100%;
left: 0;
width: 100%;
background: #fff;
${mixin.boxShadowBorderMedium}
`;
export const DropdownInput = styled.input`
padding: 10px 15px 8px;
width: 100%;
border: none;
color: ${color.textDarkest};
background: none;
&:focus {
outline: none;
}
`;
export const ClearIcon = styled(Icon)`
position: absolute;
top: 4px;
right: 7px;
padding: 5px;
font-size: 16px;
color: ${color.textMedium};
${mixin.clickable}
`;
export const Options = styled.div`
max-height: 200px;
${mixin.scrollableY};
${mixin.customScrollbar()};
`;
export const Option = styled.div`
padding: 5px 15px;
word-break: break-word;
&:hover {
cursor: pointer;
}
&:last-of-type {
margin-bottom: 8px;
}
&.jira-select-option-is-active {
background: ${mixin.lighten(color.backgroundMedium, 0.05)};
}
${props => (props.isSelected ? selectedOptionStyles : '')}
`;
const selectedOptionStyles = css`
color: #fff !important;
background: ${color.primary} !important;
`;
export const OptionsNoResults = styled.div`
padding: 5px 15px 15px;
color: ${color.textLight};
`;

View File

@@ -0,0 +1,164 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { KeyCodes } from 'shared/constants/keyCodes';
import Icon from 'shared/components/Icon';
import Dropdown from './Dropdown';
import {
StyledSelect,
StyledIcon,
ValueContainer,
ChevronIcon,
Placeholder,
ValueSingle,
ValueMulti,
ValueMultiItem,
AddMore,
} from './Styles';
const propTypes = {
className: PropTypes.string,
icon: PropTypes.string,
value: PropTypes.any,
defaultValue: PropTypes.any,
placeholder: PropTypes.string,
invalid: PropTypes.bool,
options: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
onCreate: PropTypes.func,
isMulti: PropTypes.bool,
};
const defaultProps = {
className: undefined,
icon: undefined,
value: undefined,
defaultValue: undefined,
placeholder: '',
invalid: false,
onCreate: undefined,
isMulti: false,
};
const Select = ({
className,
icon,
value: propsValue,
defaultValue,
placeholder,
invalid,
options,
onChange,
onCreate,
isMulti,
}) => {
const [stateValue, setStateValue] = useState(defaultValue || (isMulti ? [] : null));
const [isDropdownOpen, setDropdownOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');
const isControlled = propsValue !== undefined;
const value = isControlled ? propsValue : stateValue;
const $selectRef = useRef();
const $inputRef = useRef();
const activateDropdown = () => {
if (isDropdownOpen) {
$inputRef.current.focus();
} else {
setDropdownOpen(true);
}
};
const deactivateDropdown = () => {
setDropdownOpen(false);
setSearchValue('');
$selectRef.current.focus();
};
useOnOutsideClick($selectRef, isDropdownOpen, deactivateDropdown);
const handleChange = newValue => {
if (!isControlled) {
setStateValue(newValue);
}
onChange(newValue);
};
const removeOptionValue = optionValue => {
handleChange(value.filter(val => val !== optionValue));
};
const handleFocusedSelectKeydown = event => {
if (isDropdownOpen) return;
if (event.keyCode === KeyCodes.enter) {
event.preventDefault();
}
if (event.keyCode !== KeyCodes.escape && event.keyCode !== KeyCodes.tab && !event.shiftKey) {
setDropdownOpen(true);
}
};
const getOption = optionValue => options.find(option => option.value === optionValue);
const getOptionLabel = optionValue => (getOption(optionValue) || { label: '' }).label;
const isValueEmpty = isMulti ? !value.length : !getOption(value);
const renderSingleValue = () => <ValueSingle>{getOptionLabel(value)}</ValueSingle>;
const renderMultiValue = () => (
<ValueMulti>
{value.map(optionValue => (
<ValueMultiItem key={optionValue} onClick={() => removeOptionValue(optionValue)}>
{getOptionLabel(optionValue)}
<Icon type="close" />
</ValueMultiItem>
))}
<AddMore>
<Icon type="plus" />
Add more
</AddMore>
</ValueMulti>
);
return (
<StyledSelect
className={className}
ref={$selectRef}
tabIndex="0"
hasIcon={!!icon}
onKeyDown={handleFocusedSelectKeydown}
invalid={invalid}
>
<ValueContainer onClick={activateDropdown}>
{icon && <StyledIcon type={icon} />}
{(!isMulti || isValueEmpty) && <ChevronIcon type="chevron-down" />}
{isValueEmpty && <Placeholder>{placeholder}</Placeholder>}
{!isValueEmpty && !isMulti && renderSingleValue()}
{!isValueEmpty && isMulti && renderMultiValue()}
</ValueContainer>
{isDropdownOpen && (
<Dropdown
value={value}
isValueEmpty={isValueEmpty}
searchValue={searchValue}
setSearchValue={setSearchValue}
$selectRef={$selectRef}
$inputRef={$inputRef}
deactivateDropdown={deactivateDropdown}
options={options}
onChange={handleChange}
onCreate={onCreate}
isMulti={isMulti}
/>
)}
</StyledSelect>
);
};
Select.propTypes = propTypes;
Select.defaultProps = defaultProps;
export default Select;

View File

@@ -0,0 +1,219 @@
import React from 'react';
import PropTypes from 'prop-types';
import { color as colors } from 'shared/utils/styles';
const propTypes = {
className: PropTypes.string,
size: PropTypes.number,
color: PropTypes.string,
};
const defaultProps = {
className: undefined,
size: 32,
color: colors.textMedium,
};
const Spinner = ({ className, size, color }) => (
<span className={className}>
<svg
width={size}
height={size}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
style={{ background: '0 0' }}
>
<g>
<g transform="translate(80 50)">
<circle r={8} fill={color}>
<animateTransform
attributeName="transform"
type="scale"
begin="-0.875s"
values="1 1;1 1"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
/>
<animate
attributeName="fill-opacity"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
values="1;0"
begin="-0.875s"
/>
</circle>
</g>
</g>
<g>
<g transform="rotate(45 -50.355 121.569)">
<circle r={8} fill={color} fillOpacity=".875">
<animateTransform
attributeName="transform"
type="scale"
begin="-0.75s"
values="1 1;1 1"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
/>
<animate
attributeName="fill-opacity"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
values="1;0"
begin="-0.75s"
/>
</circle>
</g>
</g>
<g>
<g transform="rotate(90 -15 65)">
<circle r={8} fill={color} fillOpacity=".75">
<animateTransform
attributeName="transform"
type="scale"
begin="-0.625s"
values="1 1;1 1"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
/>
<animate
attributeName="fill-opacity"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
values="1;0"
begin="-0.625s"
/>
</circle>
</g>
</g>
<g>
<g transform="rotate(135 -.355 41.569)">
<circle r={8} fill={color} fillOpacity=".625">
<animateTransform
attributeName="transform"
type="scale"
begin="-0.5s"
values="1 1;1 1"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
/>
<animate
attributeName="fill-opacity"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
values="1;0"
begin="-0.5s"
/>
</circle>
</g>
</g>
<g>
<g transform="rotate(180 10 25)">
<circle r={8} fill={color} fillOpacity=".5">
<animateTransform
attributeName="transform"
type="scale"
begin="-0.375s"
values="1 1;1 1"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
/>
<animate
attributeName="fill-opacity"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
values="1;0"
begin="-0.375s"
/>
</circle>
</g>
</g>
<g>
<g transform="rotate(-135 20.355 8.431)">
<circle r={8} fill={color} fillOpacity=".375">
<animateTransform
attributeName="transform"
type="scale"
begin="-0.25s"
values="1 1;1 1"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
/>
<animate
attributeName="fill-opacity"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
values="1;0"
begin="-0.25s"
/>
</circle>
</g>
</g>
<g>
<g transform="rotate(-90 35 -15)">
<circle r={8} fill={color} fillOpacity=".25">
<animateTransform
attributeName="transform"
type="scale"
begin="-0.125s"
values="1 1;1 1"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
/>
<animate
attributeName="fill-opacity"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
values="1;0"
begin="-0.125s"
/>
</circle>
</g>
</g>
<g>
<g transform="rotate(-45 70.355 -71.569)">
<circle r={8} fill={color} fillOpacity=".125">
<animateTransform
attributeName="transform"
type="scale"
begin="0s"
values="1 1;1 1"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
/>
<animate
attributeName="fill-opacity"
keyTimes="0;1"
dur="1s"
repeatCount="indefinite"
values="1;0"
begin="0s"
/>
</circle>
</g>
</g>
</svg>
</span>
);
Spinner.propTypes = propTypes;
Spinner.defaultProps = defaultProps;
export default Spinner;

View File

@@ -0,0 +1,23 @@
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export default styled.div`
display: inline-block;
width: 100%;
textarea {
width: 100%;
padding: 13px 15px 14px;
border-radius: 4px;
border: 1px solid ${color.borderLight};
box-shadow: inset 0 0 1px 0 rgba(0, 0, 0, 0.03);
background: #fff;
overflow-y: hidden;
${font.regular}
${font.size(14)}
&:focus {
border: 1px solid ${color.borderMedium};
}
${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
}
`;

View File

@@ -0,0 +1,36 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import TextareaAutoSize from 'react-textarea-autosize';
import StyledTextarea from './Styles';
const propTypes = {
className: PropTypes.string,
invalid: PropTypes.bool,
minRows: PropTypes.number,
value: PropTypes.string,
onChange: PropTypes.func,
};
const defaultProps = {
className: undefined,
invalid: false,
minRows: 2,
value: undefined,
onChange: () => {},
};
const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps }, ref) => (
<StyledTextarea className={className} invalid={invalid}>
<TextareaAutoSize
{...textareaProps}
onChange={event => onChange(event, event.target.value)}
ref={ref}
/>
</StyledTextarea>
));
Textarea.propTypes = propTypes;
Textarea.defaultProps = defaultProps;
export default Textarea;

View File

@@ -0,0 +1,12 @@
export { default as Avatar } from './Avatar';
export { default as Button } from './Button';
export { default as ConfirmModal } from './ConfirmModal';
export { default as DatePicker } from './DatePicker';
export { default as Icon } from './Icon';
export { default as Input } from './Input';
export { default as Logo } from './Logo';
export { default as Modal } from './Modal';
export { default as PageLoader } from './PageLoader';
export { default as Select } from './Select';
export { default as Spinner } from './Spinner';
export { default as Textarea } from './Textarea';