diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/auth/SetupAssistant.js | 3 | ||||
-rw-r--r-- | src/components/settings/recipes/RecipesDashboard.js | 3 | ||||
-rw-r--r-- | src/components/settings/user/EditUserForm.js | 3 | ||||
-rw-r--r-- | src/components/ui/button/index.tsx | 265 | ||||
-rw-r--r-- | src/components/ui/error/index.tsx | 20 | ||||
-rw-r--r-- | src/components/ui/error/styles.ts | 9 | ||||
-rw-r--r-- | src/components/ui/input/index.tsx | 208 | ||||
-rw-r--r-- | src/components/ui/input/scorePassword.ts | 42 | ||||
-rw-r--r-- | src/components/ui/input/styles.ts | 102 | ||||
-rw-r--r-- | src/components/ui/label/index.tsx | 52 | ||||
-rw-r--r-- | src/components/ui/label/styles.ts | 12 | ||||
-rw-r--r-- | src/components/ui/select/index.tsx | 461 | ||||
-rw-r--r-- | src/components/ui/textarea/index.tsx | 126 | ||||
-rw-r--r-- | src/components/ui/textarea/styles.ts | 54 | ||||
-rw-r--r-- | src/components/ui/toggle/index.tsx | 125 | ||||
-rw-r--r-- | src/components/ui/typings/generic.ts | 8 | ||||
-rw-r--r-- | src/components/ui/wrapper/index.tsx | 37 |
17 files changed, 1526 insertions, 4 deletions
diff --git a/src/components/auth/SetupAssistant.js b/src/components/auth/SetupAssistant.js index d009a2878..1665bf837 100644 --- a/src/components/auth/SetupAssistant.js +++ b/src/components/auth/SetupAssistant.js | |||
@@ -5,7 +5,8 @@ import { defineMessages, injectIntl } from 'react-intl'; | |||
5 | import injectSheet from 'react-jss'; | 5 | import injectSheet from 'react-jss'; |
6 | import classnames from 'classnames'; | 6 | import classnames from 'classnames'; |
7 | 7 | ||
8 | import { Input, Button } from '@meetfranz/forms'; | 8 | import { Input } from '../ui/input/index'; |
9 | import { Button } from '../ui/button/index'; | ||
9 | import { Badge } from '../ui/badge'; | 10 | import { Badge } from '../ui/badge'; |
10 | import Modal from '../ui/Modal'; | 11 | import Modal from '../ui/Modal'; |
11 | import Infobox from '../ui/Infobox'; | 12 | import Infobox from '../ui/Infobox'; |
diff --git a/src/components/settings/recipes/RecipesDashboard.js b/src/components/settings/recipes/RecipesDashboard.js index 8ab726eb3..bdb6f3ca0 100644 --- a/src/components/settings/recipes/RecipesDashboard.js +++ b/src/components/settings/recipes/RecipesDashboard.js | |||
@@ -4,9 +4,10 @@ import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | |||
4 | import { defineMessages, injectIntl } from 'react-intl'; | 4 | import { defineMessages, injectIntl } from 'react-intl'; |
5 | import { Link } from 'react-router'; | 5 | import { Link } from 'react-router'; |
6 | 6 | ||
7 | import { Button, Input } from '@meetfranz/forms'; | ||
8 | import injectSheet from 'react-jss'; | 7 | import injectSheet from 'react-jss'; |
9 | 8 | ||
9 | import { Button } from '../../ui/button/index'; | ||
10 | import { Input } from '../../ui/input/index'; | ||
10 | import { H3, H2 } from '../../ui/headline'; | 11 | import { H3, H2 } from '../../ui/headline'; |
11 | import SearchInput from '../../ui/SearchInput'; | 12 | import SearchInput from '../../ui/SearchInput'; |
12 | import Infobox from '../../ui/Infobox'; | 13 | import Infobox from '../../ui/Infobox'; |
diff --git a/src/components/settings/user/EditUserForm.js b/src/components/settings/user/EditUserForm.js index 55883e65f..1b8a4f25a 100644 --- a/src/components/settings/user/EditUserForm.js +++ b/src/components/settings/user/EditUserForm.js | |||
@@ -3,10 +3,9 @@ import PropTypes from 'prop-types'; | |||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | 3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; |
4 | import { defineMessages, injectIntl } from 'react-intl'; | 4 | import { defineMessages, injectIntl } from 'react-intl'; |
5 | import { Link } from 'react-router'; | 5 | import { Link } from 'react-router'; |
6 | import { Input } from '@meetfranz/forms'; | ||
7 | 6 | ||
7 | import { Input } from '../../ui/input/index'; | ||
8 | import Form from '../../../lib/Form'; | 8 | import Form from '../../../lib/Form'; |
9 | // import Input from '../../ui/Input'; | ||
10 | import Button from '../../ui/Button'; | 9 | import Button from '../../ui/Button'; |
11 | import Radio from '../../ui/Radio'; | 10 | import Radio from '../../ui/Radio'; |
12 | import Infobox from '../../ui/Infobox'; | 11 | import Infobox from '../../ui/Infobox'; |
diff --git a/src/components/ui/button/index.tsx b/src/components/ui/button/index.tsx new file mode 100644 index 000000000..5b8927b51 --- /dev/null +++ b/src/components/ui/button/index.tsx | |||
@@ -0,0 +1,265 @@ | |||
1 | import Icon from '@mdi/react'; | ||
2 | import classnames from 'classnames'; | ||
3 | import { Property } from 'csstype'; | ||
4 | import { Component, MouseEvent } from 'react'; | ||
5 | import injectStyle, { withTheme } from 'react-jss'; | ||
6 | import Loader from 'react-loader'; | ||
7 | import { Theme } from '@meetfranz/theme'; | ||
8 | |||
9 | import { IFormField, IWithStyle } from '../typings/generic'; | ||
10 | |||
11 | type ButtonType = | ||
12 | | 'primary' | ||
13 | | 'secondary' | ||
14 | | 'success' | ||
15 | | 'danger' | ||
16 | | 'warning' | ||
17 | | 'inverted'; | ||
18 | |||
19 | interface IProps extends IFormField, IWithStyle { | ||
20 | className?: string; | ||
21 | disabled?: boolean; | ||
22 | id?: string; | ||
23 | type?: 'button' | 'reset' | 'submit' | undefined; | ||
24 | onClick: ( | ||
25 | event: MouseEvent<HTMLButtonElement> | MouseEvent<HTMLAnchorElement>, | ||
26 | ) => void; | ||
27 | buttonType?: ButtonType; | ||
28 | stretch?: boolean; | ||
29 | loaded?: boolean; | ||
30 | busy?: boolean; | ||
31 | icon?: string; | ||
32 | href?: string; | ||
33 | target?: string; | ||
34 | } | ||
35 | |||
36 | let buttonTransition: string = 'none'; | ||
37 | let loaderContainerTransition: string = 'none'; | ||
38 | |||
39 | if (window && window.matchMedia('(prefers-reduced-motion: no-preference)')) { | ||
40 | buttonTransition = 'background .5s, opacity 0.3s'; | ||
41 | loaderContainerTransition = 'all 0.3s'; | ||
42 | } | ||
43 | |||
44 | const styles = (theme: Theme) => ({ | ||
45 | button: { | ||
46 | borderRadius: theme.borderRadiusSmall, | ||
47 | border: 'none', | ||
48 | display: 'inline-flex', | ||
49 | position: 'relative' as Property.Position, | ||
50 | transition: buttonTransition, | ||
51 | textAlign: 'center' as Property.TextAlign, | ||
52 | outline: 'none', | ||
53 | alignItems: 'center', | ||
54 | padding: 0, | ||
55 | width: (props: IProps) => | ||
56 | (props.stretch ? '100%' : 'auto') as Property.Width<string>, | ||
57 | fontSize: theme.uiFontSize, | ||
58 | textDecoration: 'none', | ||
59 | |||
60 | '&:hover': { | ||
61 | opacity: 0.8, | ||
62 | }, | ||
63 | '&:active': { | ||
64 | opacity: 0.5, | ||
65 | transition: 'none', | ||
66 | }, | ||
67 | }, | ||
68 | label: { | ||
69 | margin: '10px 20px', | ||
70 | width: '100%', | ||
71 | display: 'flex', | ||
72 | alignItems: 'center', | ||
73 | justifyContent: 'center', | ||
74 | }, | ||
75 | primary: { | ||
76 | background: theme.buttonPrimaryBackground, | ||
77 | color: theme.buttonPrimaryTextColor, | ||
78 | |||
79 | '& svg': { | ||
80 | fill: theme.buttonPrimaryTextColor, | ||
81 | }, | ||
82 | }, | ||
83 | secondary: { | ||
84 | background: theme.buttonSecondaryBackground, | ||
85 | color: theme.buttonSecondaryTextColor, | ||
86 | |||
87 | '& svg': { | ||
88 | fill: theme.buttonSecondaryTextColor, | ||
89 | }, | ||
90 | }, | ||
91 | success: { | ||
92 | background: theme.buttonSuccessBackground, | ||
93 | color: theme.buttonSuccessTextColor, | ||
94 | |||
95 | '& svg': { | ||
96 | fill: theme.buttonSuccessTextColor, | ||
97 | }, | ||
98 | }, | ||
99 | danger: { | ||
100 | background: theme.buttonDangerBackground, | ||
101 | color: theme.buttonDangerTextColor, | ||
102 | |||
103 | '& svg': { | ||
104 | fill: theme.buttonDangerTextColor, | ||
105 | }, | ||
106 | }, | ||
107 | warning: { | ||
108 | background: theme.buttonWarningBackground, | ||
109 | color: theme.buttonWarningTextColor, | ||
110 | |||
111 | '& svg': { | ||
112 | fill: theme.buttonWarningTextColor, | ||
113 | }, | ||
114 | }, | ||
115 | inverted: { | ||
116 | background: theme.buttonInvertedBackground, | ||
117 | color: theme.buttonInvertedTextColor, | ||
118 | border: theme.buttonInvertedBorder, | ||
119 | |||
120 | '& svg': { | ||
121 | fill: theme.buttonInvertedTextColor, | ||
122 | }, | ||
123 | }, | ||
124 | disabled: { | ||
125 | opacity: theme.inputDisabledOpacity, | ||
126 | }, | ||
127 | loader: { | ||
128 | position: 'relative' as Property.Position, | ||
129 | width: 20, | ||
130 | height: 18, | ||
131 | zIndex: 9999, | ||
132 | }, | ||
133 | loaderContainer: { | ||
134 | width: (props: IProps): string => (!props.busy ? '0' : '40px'), | ||
135 | height: 20, | ||
136 | overflow: 'hidden', | ||
137 | transition: loaderContainerTransition, | ||
138 | marginLeft: (props: IProps): number => (!props.busy ? 10 : 20), | ||
139 | marginRight: (props: IProps): number => (!props.busy ? -10 : -20), | ||
140 | position: (props: IProps): Property.Position => | ||
141 | props.stretch ? 'absolute' : 'inherit', | ||
142 | }, | ||
143 | icon: { | ||
144 | margin: [1, 10, 0, -5], | ||
145 | }, | ||
146 | }); | ||
147 | |||
148 | class ButtonComponent extends Component<IProps> { | ||
149 | public static defaultProps = { | ||
150 | type: 'button', | ||
151 | disabled: false, | ||
152 | onClick: () => null, | ||
153 | buttonType: 'primary' as ButtonType, | ||
154 | stretch: false, | ||
155 | busy: false, | ||
156 | }; | ||
157 | |||
158 | state = { | ||
159 | busy: false, | ||
160 | }; | ||
161 | |||
162 | componentWillMount() { | ||
163 | this.setState({ busy: this.props.busy }); | ||
164 | } | ||
165 | |||
166 | componentWillReceiveProps(nextProps: IProps) { | ||
167 | if (nextProps.busy !== this.props.busy) { | ||
168 | if (this.props.busy) { | ||
169 | setTimeout(() => { | ||
170 | this.setState({ busy: nextProps.busy }); | ||
171 | }, 300); | ||
172 | } else { | ||
173 | this.setState({ busy: nextProps.busy }); | ||
174 | } | ||
175 | } | ||
176 | } | ||
177 | |||
178 | render() { | ||
179 | const { | ||
180 | classes, | ||
181 | className, | ||
182 | theme, | ||
183 | disabled, | ||
184 | id, | ||
185 | label, | ||
186 | type, | ||
187 | onClick, | ||
188 | buttonType, | ||
189 | loaded, | ||
190 | icon, | ||
191 | href, | ||
192 | target, | ||
193 | } = this.props; | ||
194 | |||
195 | const { busy } = this.state; | ||
196 | |||
197 | let showLoader = false; | ||
198 | if (loaded) { | ||
199 | showLoader = !loaded; | ||
200 | console.warn( | ||
201 | 'Ferdi Button prop `loaded` will be deprecated in the future. Please use `busy` instead', | ||
202 | ); | ||
203 | } | ||
204 | if (busy) { | ||
205 | showLoader = busy; | ||
206 | } | ||
207 | |||
208 | const content = ( | ||
209 | <> | ||
210 | <div className={classes.loaderContainer}> | ||
211 | {showLoader && ( | ||
212 | <Loader | ||
213 | loaded={false} | ||
214 | width={4} | ||
215 | scale={0.45} | ||
216 | color={theme.buttonLoaderColor[buttonType!]} | ||
217 | parentClassName={classes.loader} | ||
218 | /> | ||
219 | )} | ||
220 | </div> | ||
221 | <div className={classes.label}> | ||
222 | {icon && <Icon path={icon} size={0.8} className={classes.icon} />} | ||
223 | {label} | ||
224 | </div> | ||
225 | </> | ||
226 | ); | ||
227 | |||
228 | const wrapperComponent = !href ? ( | ||
229 | <button | ||
230 | id={id} | ||
231 | type={type} | ||
232 | onClick={onClick} | ||
233 | className={classnames({ | ||
234 | [`${classes.button}`]: true, | ||
235 | [`${classes[buttonType as ButtonType]}`]: true, | ||
236 | [`${classes.disabled}`]: disabled, | ||
237 | [`${className}`]: className, | ||
238 | })} | ||
239 | disabled={disabled} | ||
240 | data-type="franz-button" | ||
241 | > | ||
242 | {content} | ||
243 | </button> | ||
244 | ) : ( | ||
245 | <a | ||
246 | href={href} | ||
247 | target={target} | ||
248 | onClick={onClick} | ||
249 | className={classnames({ | ||
250 | [`${classes.button}`]: true, | ||
251 | [`${classes[buttonType as ButtonType]}`]: true, | ||
252 | [`${className}`]: className, | ||
253 | })} | ||
254 | rel={target === '_blank' ? 'noopener' : ''} | ||
255 | data-type="franz-button" | ||
256 | > | ||
257 | {content} | ||
258 | </a> | ||
259 | ); | ||
260 | |||
261 | return wrapperComponent; | ||
262 | } | ||
263 | } | ||
264 | |||
265 | export const Button = injectStyle(styles)(withTheme(ButtonComponent)); | ||
diff --git a/src/components/ui/error/index.tsx b/src/components/ui/error/index.tsx new file mode 100644 index 000000000..8439bfc8b --- /dev/null +++ b/src/components/ui/error/index.tsx | |||
@@ -0,0 +1,20 @@ | |||
1 | import { Classes } from 'jss'; | ||
2 | import { Component } from 'react'; | ||
3 | import injectSheet from 'react-jss'; | ||
4 | |||
5 | import styles from './styles'; | ||
6 | |||
7 | interface IProps { | ||
8 | classes: Classes; | ||
9 | message: string; | ||
10 | } | ||
11 | |||
12 | class ErrorComponent extends Component<IProps> { | ||
13 | render() { | ||
14 | const { classes, message } = this.props; | ||
15 | |||
16 | return <p className={classes.message}>{message}</p>; | ||
17 | } | ||
18 | } | ||
19 | |||
20 | export const Error = injectSheet(styles)(ErrorComponent); | ||
diff --git a/src/components/ui/error/styles.ts b/src/components/ui/error/styles.ts new file mode 100644 index 000000000..ed993ddd5 --- /dev/null +++ b/src/components/ui/error/styles.ts | |||
@@ -0,0 +1,9 @@ | |||
1 | import { Theme } from '@meetfranz/theme'; | ||
2 | |||
3 | export default (theme: Theme) => ({ | ||
4 | message: { | ||
5 | color: theme.brandDanger, | ||
6 | margin: '5px 0 0', | ||
7 | fontSize: theme.uiFontSize, | ||
8 | }, | ||
9 | }); | ||
diff --git a/src/components/ui/input/index.tsx b/src/components/ui/input/index.tsx new file mode 100644 index 000000000..0b16fe688 --- /dev/null +++ b/src/components/ui/input/index.tsx | |||
@@ -0,0 +1,208 @@ | |||
1 | import { mdiEye, mdiEyeOff } from '@mdi/js'; | ||
2 | import Icon from '@mdi/react'; | ||
3 | import classnames from 'classnames'; | ||
4 | import { Component, createRef, InputHTMLAttributes } from 'react'; | ||
5 | import injectSheet from 'react-jss'; | ||
6 | |||
7 | import { IFormField, IWithStyle } from '../typings/generic'; | ||
8 | |||
9 | import { Error } from '../error'; | ||
10 | import { Label } from '../label'; | ||
11 | import { Wrapper } from '../wrapper'; | ||
12 | import { scorePasswordFunc } from './scorePassword'; | ||
13 | |||
14 | import styles from './styles'; | ||
15 | |||
16 | interface IData { | ||
17 | [index: string]: string; | ||
18 | } | ||
19 | |||
20 | interface IProps | ||
21 | extends InputHTMLAttributes<HTMLInputElement>, | ||
22 | IFormField, | ||
23 | IWithStyle { | ||
24 | focus?: boolean; | ||
25 | prefix?: string; | ||
26 | suffix?: string; | ||
27 | scorePassword?: boolean; | ||
28 | showPasswordToggle?: boolean; | ||
29 | data: IData; | ||
30 | inputClassName?: string; | ||
31 | onEnterKey?: Function; | ||
32 | } | ||
33 | |||
34 | interface IState { | ||
35 | showPassword: boolean; | ||
36 | passwordScore: number; | ||
37 | } | ||
38 | |||
39 | class InputComponent extends Component<IProps, IState> { | ||
40 | static defaultProps = { | ||
41 | focus: false, | ||
42 | onChange: () => {}, | ||
43 | onBlur: () => {}, | ||
44 | onFocus: () => {}, | ||
45 | scorePassword: false, | ||
46 | showLabel: true, | ||
47 | showPasswordToggle: false, | ||
48 | type: 'text', | ||
49 | disabled: false, | ||
50 | }; | ||
51 | |||
52 | state = { | ||
53 | passwordScore: 0, | ||
54 | showPassword: false, | ||
55 | }; | ||
56 | |||
57 | private inputRef = createRef<HTMLInputElement>(); | ||
58 | |||
59 | componentDidMount() { | ||
60 | const { focus, data } = this.props; | ||
61 | |||
62 | if (this.inputRef && this.inputRef.current) { | ||
63 | if (focus) { | ||
64 | this.inputRef.current.focus(); | ||
65 | } | ||
66 | |||
67 | if (data) { | ||
68 | Object.keys(data).map( | ||
69 | key => (this.inputRef.current!.dataset[key] = data[key]), | ||
70 | ); | ||
71 | } | ||
72 | } | ||
73 | } | ||
74 | |||
75 | onChange(e: React.ChangeEvent<HTMLInputElement>) { | ||
76 | const { scorePassword, onChange } = this.props; | ||
77 | |||
78 | if (onChange) { | ||
79 | onChange(e); | ||
80 | } | ||
81 | |||
82 | if (this.inputRef && this.inputRef.current && scorePassword) { | ||
83 | this.setState({ | ||
84 | passwordScore: scorePasswordFunc(this.inputRef.current.value), | ||
85 | }); | ||
86 | } | ||
87 | } | ||
88 | |||
89 | onInputKeyPress(e: React.KeyboardEvent) { | ||
90 | if (e.key === 'Enter') { | ||
91 | const { onEnterKey } = this.props; | ||
92 | onEnterKey && onEnterKey(); | ||
93 | } | ||
94 | } | ||
95 | |||
96 | render() { | ||
97 | const { | ||
98 | classes, | ||
99 | className, | ||
100 | disabled, | ||
101 | error, | ||
102 | id, | ||
103 | inputClassName, | ||
104 | label, | ||
105 | prefix, | ||
106 | scorePassword, | ||
107 | suffix, | ||
108 | showLabel, | ||
109 | showPasswordToggle, | ||
110 | type, | ||
111 | value, | ||
112 | name, | ||
113 | placeholder, | ||
114 | spellCheck, | ||
115 | onBlur, | ||
116 | onFocus, | ||
117 | min, | ||
118 | max, | ||
119 | step, | ||
120 | required, | ||
121 | noMargin, | ||
122 | } = this.props; | ||
123 | |||
124 | const { showPassword, passwordScore } = this.state; | ||
125 | |||
126 | const inputType = type === 'password' && showPassword ? 'text' : type; | ||
127 | |||
128 | return ( | ||
129 | <Wrapper | ||
130 | className={className} | ||
131 | identifier="franz-input" | ||
132 | noMargin={noMargin} | ||
133 | > | ||
134 | <Label | ||
135 | title={label} | ||
136 | showLabel={showLabel} | ||
137 | htmlFor={id} | ||
138 | className={classes.label} | ||
139 | isRequired={required} | ||
140 | > | ||
141 | <div | ||
142 | className={classnames({ | ||
143 | [`${inputClassName}`]: inputClassName, | ||
144 | [`${classes.hasPasswordScore}`]: scorePassword, | ||
145 | [`${classes.wrapper}`]: true, | ||
146 | [`${classes.disabled}`]: disabled, | ||
147 | [`${classes.hasError}`]: error, | ||
148 | })} | ||
149 | > | ||
150 | {prefix && <span className={classes.prefix}>{prefix}</span>} | ||
151 | <input | ||
152 | id={id} | ||
153 | type={inputType} | ||
154 | name={name} | ||
155 | value={value as string} | ||
156 | placeholder={placeholder} | ||
157 | spellCheck={spellCheck} | ||
158 | className={classes.input} | ||
159 | ref={this.inputRef} | ||
160 | onChange={this.onChange.bind(this)} | ||
161 | onFocus={onFocus} | ||
162 | onBlur={onBlur} | ||
163 | disabled={disabled} | ||
164 | onKeyPress={this.onInputKeyPress.bind(this)} | ||
165 | min={min} | ||
166 | max={max} | ||
167 | step={step} | ||
168 | /> | ||
169 | {suffix && <span className={classes.suffix}>{suffix}</span>} | ||
170 | {showPasswordToggle && ( | ||
171 | <button | ||
172 | type="button" | ||
173 | className={classes.formModifier} | ||
174 | onClick={() => | ||
175 | this.setState(prevState => ({ | ||
176 | showPassword: !prevState.showPassword, | ||
177 | })) | ||
178 | } | ||
179 | tabIndex={-1} | ||
180 | > | ||
181 | <Icon path={!showPassword ? mdiEye : mdiEyeOff} size={1} /> | ||
182 | </button> | ||
183 | )} | ||
184 | </div> | ||
185 | {scorePassword && ( | ||
186 | <div | ||
187 | className={classnames({ | ||
188 | [`${classes.passwordScore}`]: true, | ||
189 | [`${classes.hasError}`]: error, | ||
190 | })} | ||
191 | > | ||
192 | <meter | ||
193 | value={passwordScore < 5 ? 5 : passwordScore} | ||
194 | low={30} | ||
195 | high={75} | ||
196 | optimum={100} | ||
197 | max={100} | ||
198 | /> | ||
199 | </div> | ||
200 | )} | ||
201 | </Label> | ||
202 | {error && <Error message={error} />} | ||
203 | </Wrapper> | ||
204 | ); | ||
205 | } | ||
206 | } | ||
207 | |||
208 | export const Input = injectSheet(styles)(InputComponent); | ||
diff --git a/src/components/ui/input/scorePassword.ts b/src/components/ui/input/scorePassword.ts new file mode 100644 index 000000000..59502e2b0 --- /dev/null +++ b/src/components/ui/input/scorePassword.ts | |||
@@ -0,0 +1,42 @@ | |||
1 | interface ILetters { | ||
2 | [key: string]: number; | ||
3 | } | ||
4 | |||
5 | interface IVariations { | ||
6 | [index: string]: boolean; | ||
7 | digits: boolean; | ||
8 | lower: boolean; | ||
9 | nonWords: boolean; | ||
10 | upper: boolean; | ||
11 | } | ||
12 | |||
13 | export function scorePasswordFunc(password: string): number { | ||
14 | let score = 0; | ||
15 | if (!password) { | ||
16 | return score; | ||
17 | } | ||
18 | |||
19 | // award every unique letter until 5 repetitions | ||
20 | const letters: ILetters = {}; | ||
21 | for (const element of password) { | ||
22 | letters[element] = (letters[element] || 0) + 1; | ||
23 | score += 5 / letters[element]; | ||
24 | } | ||
25 | |||
26 | // bonus points for mixing it up | ||
27 | const variations: IVariations = { | ||
28 | digits: /\d/.test(password), | ||
29 | lower: /[a-z]/.test(password), | ||
30 | nonWords: /\W/.test(password), | ||
31 | upper: /[A-Z]/.test(password), | ||
32 | }; | ||
33 | |||
34 | let variationCount = 0; | ||
35 | for (const key of Object.keys(variations)) { | ||
36 | variationCount += variations[key] === true ? 1 : 0; | ||
37 | } | ||
38 | |||
39 | score += (variationCount - 1) * 10; | ||
40 | |||
41 | return Math.round(score); | ||
42 | } | ||
diff --git a/src/components/ui/input/styles.ts b/src/components/ui/input/styles.ts new file mode 100644 index 000000000..27426152e --- /dev/null +++ b/src/components/ui/input/styles.ts | |||
@@ -0,0 +1,102 @@ | |||
1 | import { Property } from 'csstype'; | ||
2 | |||
3 | import { Theme } from '@meetfranz/theme'; | ||
4 | |||
5 | const prefixStyles = (theme: Theme) => ({ | ||
6 | background: theme.inputPrefixBackground, | ||
7 | color: theme.inputPrefixColor, | ||
8 | lineHeight: `${theme.inputHeight}px`, | ||
9 | padding: '0 10px', | ||
10 | fontSize: theme.uiFontSize, | ||
11 | }); | ||
12 | |||
13 | export default (theme: Theme) => ({ | ||
14 | label: { | ||
15 | '& > div': { | ||
16 | marginTop: 5, | ||
17 | }, | ||
18 | }, | ||
19 | disabled: { | ||
20 | opacity: theme.inputDisabledOpacity, | ||
21 | }, | ||
22 | formModifier: { | ||
23 | background: 'none', | ||
24 | border: 0, | ||
25 | borderLeft: theme.inputBorder, | ||
26 | padding: '4px 20px 0', | ||
27 | outline: 'none', | ||
28 | |||
29 | '&:active': { | ||
30 | opacity: 0.5, | ||
31 | }, | ||
32 | |||
33 | '& svg': { | ||
34 | fill: theme.inputModifierColor, | ||
35 | }, | ||
36 | }, | ||
37 | input: { | ||
38 | background: 'none', | ||
39 | border: 0, | ||
40 | fontSize: theme.uiFontSize, | ||
41 | outline: 'none', | ||
42 | padding: 8, | ||
43 | width: '100%', | ||
44 | color: theme.inputColor, | ||
45 | |||
46 | '&::placeholder': { | ||
47 | color: theme.inputPlaceholderColor, | ||
48 | }, | ||
49 | }, | ||
50 | passwordScore: { | ||
51 | background: theme.inputScorePasswordBackground, | ||
52 | border: theme.inputBorder, | ||
53 | borderTopWidth: 0, | ||
54 | borderBottomLeftRadius: theme.borderRadiusSmall, | ||
55 | borderBottomRightRadius: theme.borderRadiusSmall, | ||
56 | display: 'block', | ||
57 | flexBasis: '100%', | ||
58 | height: 5, | ||
59 | overflow: 'hidden', | ||
60 | |||
61 | '& meter': { | ||
62 | display: 'block', | ||
63 | height: '100%', | ||
64 | width: '100%', | ||
65 | |||
66 | '&::-webkit-meter-bar': { | ||
67 | background: 'none', | ||
68 | }, | ||
69 | |||
70 | '&::-webkit-meter-even-less-good-value': { | ||
71 | background: theme.brandDanger, | ||
72 | }, | ||
73 | |||
74 | '&::-webkit-meter-suboptimum-value': { | ||
75 | background: theme.brandWarning, | ||
76 | }, | ||
77 | |||
78 | '&::-webkit-meter-optimum-value': { | ||
79 | background: theme.brandSuccess, | ||
80 | }, | ||
81 | }, | ||
82 | }, | ||
83 | prefix: prefixStyles(theme), | ||
84 | suffix: prefixStyles(theme), | ||
85 | wrapper: { | ||
86 | background: theme.inputBackground, | ||
87 | border: theme.inputBorder, | ||
88 | borderRadius: theme.borderRadiusSmall, | ||
89 | boxSizing: 'border-box' as Property.BoxSizing, | ||
90 | display: 'flex', | ||
91 | height: theme.inputHeight, | ||
92 | order: 1, | ||
93 | width: '100%', | ||
94 | }, | ||
95 | hasPasswordScore: { | ||
96 | borderBottomLeftRadius: 0, | ||
97 | borderBottomRightRadius: 0, | ||
98 | }, | ||
99 | hasError: { | ||
100 | borderColor: theme.brandDanger, | ||
101 | }, | ||
102 | }); | ||
diff --git a/src/components/ui/label/index.tsx b/src/components/ui/label/index.tsx new file mode 100644 index 000000000..4d86f23f7 --- /dev/null +++ b/src/components/ui/label/index.tsx | |||
@@ -0,0 +1,52 @@ | |||
1 | import classnames from 'classnames'; | ||
2 | import { Classes } from 'jss'; | ||
3 | import { Component, LabelHTMLAttributes } from 'react'; | ||
4 | import injectSheet from 'react-jss'; | ||
5 | |||
6 | import { IFormField } from '../typings/generic'; | ||
7 | |||
8 | import styles from './styles'; | ||
9 | |||
10 | interface ILabel extends IFormField, LabelHTMLAttributes<HTMLLabelElement> { | ||
11 | classes: Classes; | ||
12 | isRequired: boolean; | ||
13 | } | ||
14 | |||
15 | class LabelComponent extends Component<ILabel> { | ||
16 | static defaultProps = { | ||
17 | showLabel: true, | ||
18 | }; | ||
19 | |||
20 | render() { | ||
21 | const { | ||
22 | title, | ||
23 | showLabel, | ||
24 | classes, | ||
25 | className, | ||
26 | children, | ||
27 | htmlFor, | ||
28 | isRequired, | ||
29 | } = this.props; | ||
30 | |||
31 | if (!showLabel) return children; | ||
32 | |||
33 | return ( | ||
34 | <label | ||
35 | className={classnames({ | ||
36 | [`${className}`]: className, | ||
37 | })} | ||
38 | htmlFor={htmlFor} | ||
39 | > | ||
40 | {showLabel && ( | ||
41 | <span className={classes.label}> | ||
42 | {title} | ||
43 | {isRequired && ' *'} | ||
44 | </span> | ||
45 | )} | ||
46 | <div className={classes.content}>{children}</div> | ||
47 | </label> | ||
48 | ); | ||
49 | } | ||
50 | } | ||
51 | |||
52 | export const Label = injectSheet(styles)(LabelComponent); | ||
diff --git a/src/components/ui/label/styles.ts b/src/components/ui/label/styles.ts new file mode 100644 index 000000000..0c9cef8bf --- /dev/null +++ b/src/components/ui/label/styles.ts | |||
@@ -0,0 +1,12 @@ | |||
1 | import { Theme } from '@meetfranz/theme'; | ||
2 | |||
3 | export default (theme: Theme) => ({ | ||
4 | content: {}, | ||
5 | label: { | ||
6 | color: theme.labelColor, | ||
7 | fontSize: theme.uiFontSize, | ||
8 | }, | ||
9 | hasError: { | ||
10 | color: theme.brandDanger, | ||
11 | }, | ||
12 | }); | ||
diff --git a/src/components/ui/select/index.tsx b/src/components/ui/select/index.tsx new file mode 100644 index 000000000..41cab7818 --- /dev/null +++ b/src/components/ui/select/index.tsx | |||
@@ -0,0 +1,461 @@ | |||
1 | import { | ||
2 | mdiArrowRightDropCircleOutline, | ||
3 | mdiCloseCircle, | ||
4 | mdiMagnify, | ||
5 | } from '@mdi/js'; | ||
6 | import Icon from '@mdi/react'; | ||
7 | import classnames from 'classnames'; | ||
8 | import { ChangeEvent, Component, createRef } from 'react'; | ||
9 | import injectStyle from 'react-jss'; | ||
10 | |||
11 | import { Theme } from '@meetfranz/theme'; | ||
12 | |||
13 | import { IFormField, IWithStyle } from '../typings/generic'; | ||
14 | |||
15 | import { Error } from '../error'; | ||
16 | import { Label } from '../label'; | ||
17 | import { Wrapper } from '../wrapper'; | ||
18 | |||
19 | interface IOptions { | ||
20 | [index: string]: string; | ||
21 | } | ||
22 | |||
23 | interface IData { | ||
24 | [index: string]: string; | ||
25 | } | ||
26 | |||
27 | interface IProps extends IFormField, IWithStyle { | ||
28 | actionText: string; | ||
29 | className?: string; | ||
30 | inputClassName?: string; | ||
31 | defaultValue?: string; | ||
32 | disabled?: boolean; | ||
33 | id?: string; | ||
34 | name: string; | ||
35 | options: IOptions; | ||
36 | value: string; | ||
37 | onChange: (event: ChangeEvent<HTMLInputElement>) => void; | ||
38 | showSearch: boolean; | ||
39 | data: IData; | ||
40 | } | ||
41 | |||
42 | interface IState { | ||
43 | open: boolean; | ||
44 | value: string; | ||
45 | needle: string; | ||
46 | selected: number; | ||
47 | options: IOptions; | ||
48 | } | ||
49 | |||
50 | let popupTransition: string = 'none'; | ||
51 | let toggleTransition: string = 'none'; | ||
52 | |||
53 | if (window && window.matchMedia('(prefers-reduced-motion: no-preference)')) { | ||
54 | popupTransition = 'all 0.3s'; | ||
55 | toggleTransition = 'transform 0.3s'; | ||
56 | } | ||
57 | |||
58 | const styles = (theme: Theme) => ({ | ||
59 | select: { | ||
60 | background: theme.selectBackground, | ||
61 | border: theme.selectBorder, | ||
62 | borderRadius: theme.borderRadiusSmall, | ||
63 | height: theme.selectHeight, | ||
64 | fontSize: theme.uiFontSize, | ||
65 | width: '100%', | ||
66 | display: 'flex', | ||
67 | alignItems: 'center', | ||
68 | textAlign: 'left', | ||
69 | color: theme.selectColor, | ||
70 | }, | ||
71 | label: { | ||
72 | '& > div': { | ||
73 | marginTop: 5, | ||
74 | }, | ||
75 | }, | ||
76 | popup: { | ||
77 | opacity: 0, | ||
78 | height: 0, | ||
79 | overflowX: 'scroll', | ||
80 | border: theme.selectBorder, | ||
81 | borderTop: 0, | ||
82 | transition: popupTransition, | ||
83 | }, | ||
84 | open: { | ||
85 | opacity: 1, | ||
86 | height: 350, | ||
87 | background: theme.selectPopupBackground, | ||
88 | }, | ||
89 | option: { | ||
90 | padding: 10, | ||
91 | borderBottom: theme.selectOptionBorder, | ||
92 | color: theme.selectOptionColor, | ||
93 | |||
94 | '&:hover': { | ||
95 | background: theme.selectOptionItemHover, | ||
96 | color: theme.selectOptionItemHoverColor, | ||
97 | }, | ||
98 | '&:active': { | ||
99 | background: theme.selectOptionItemActive, | ||
100 | color: theme.selectOptionItemActiveColor, | ||
101 | }, | ||
102 | }, | ||
103 | selected: { | ||
104 | background: theme.selectOptionItemActive, | ||
105 | color: theme.selectOptionItemActiveColor, | ||
106 | }, | ||
107 | toggle: { | ||
108 | marginLeft: 'auto', | ||
109 | fill: theme.selectToggleColor, | ||
110 | transition: toggleTransition, | ||
111 | }, | ||
112 | toggleOpened: { | ||
113 | transform: 'rotateZ(90deg)', | ||
114 | }, | ||
115 | searchContainer: { | ||
116 | display: 'flex', | ||
117 | background: theme.selectSearchBackground, | ||
118 | alignItems: 'center', | ||
119 | paddingLeft: 10, | ||
120 | color: theme.selectColor, | ||
121 | |||
122 | '& svg': { | ||
123 | fill: theme.selectSearchColor, | ||
124 | }, | ||
125 | }, | ||
126 | search: { | ||
127 | border: 0, | ||
128 | width: '100%', | ||
129 | fontSize: theme.uiFontSize, | ||
130 | background: 'none', | ||
131 | marginLeft: 10, | ||
132 | padding: [10, 0], | ||
133 | color: theme.selectSearchColor, | ||
134 | }, | ||
135 | clearNeedle: { | ||
136 | background: 'none', | ||
137 | border: 0, | ||
138 | }, | ||
139 | focused: { | ||
140 | fontWeight: 'bold', | ||
141 | background: theme.selectOptionItemHover, | ||
142 | color: theme.selectOptionItemHoverColor, | ||
143 | }, | ||
144 | hasError: { | ||
145 | borderColor: theme.brandDanger, | ||
146 | }, | ||
147 | disabled: { | ||
148 | opacity: theme.selectDisabledOpacity, | ||
149 | }, | ||
150 | }); | ||
151 | |||
152 | class SelectComponent extends Component<IProps> { | ||
153 | public static defaultProps = { | ||
154 | onChange: () => {}, | ||
155 | showLabel: true, | ||
156 | disabled: false, | ||
157 | error: '', | ||
158 | }; | ||
159 | |||
160 | state = { | ||
161 | open: false, | ||
162 | value: '', | ||
163 | needle: '', | ||
164 | selected: 0, | ||
165 | options: null, | ||
166 | }; | ||
167 | |||
168 | private componentRef = createRef<HTMLDivElement>(); | ||
169 | |||
170 | private inputRef = createRef<HTMLInputElement>(); | ||
171 | |||
172 | private searchInputRef = createRef<HTMLInputElement>(); | ||
173 | |||
174 | private scrollContainerRef = createRef<HTMLDivElement>(); | ||
175 | |||
176 | private activeOptionRef = createRef<HTMLDivElement>(); | ||
177 | |||
178 | private keyListener: any; | ||
179 | |||
180 | componentWillReceiveProps(nextProps: IProps) { | ||
181 | if (nextProps.value && nextProps.value !== this.props.value) { | ||
182 | this.setState({ | ||
183 | value: nextProps.value, | ||
184 | }); | ||
185 | } | ||
186 | } | ||
187 | |||
188 | componentDidUpdate() { | ||
189 | const { open } = this.state; | ||
190 | |||
191 | if (this.searchInputRef && this.searchInputRef.current && open) { | ||
192 | this.searchInputRef.current.focus(); | ||
193 | } | ||
194 | } | ||
195 | |||
196 | componentDidMount() { | ||
197 | if (this.inputRef && this.inputRef.current) { | ||
198 | const { data } = this.props; | ||
199 | |||
200 | if (data) { | ||
201 | Object.keys(data).map( | ||
202 | key => (this.inputRef.current!.dataset[key] = data[key]), | ||
203 | ); | ||
204 | } | ||
205 | } | ||
206 | |||
207 | window.addEventListener('keydown', this.arrowKeysHandler.bind(this), false); | ||
208 | } | ||
209 | |||
210 | componentWillMount() { | ||
211 | const { value } = this.props; | ||
212 | |||
213 | if (this.componentRef && this.componentRef.current) { | ||
214 | this.componentRef.current.removeEventListener( | ||
215 | 'keydown', | ||
216 | this.keyListener, | ||
217 | ); | ||
218 | } | ||
219 | |||
220 | if (value) { | ||
221 | this.setState({ | ||
222 | value, | ||
223 | }); | ||
224 | } | ||
225 | |||
226 | this.setFilter(); | ||
227 | } | ||
228 | |||
229 | componentWillUnmount() { | ||
230 | // eslint-disable-next-line unicorn/no-invalid-remove-event-listener | ||
231 | window.removeEventListener('keydown', this.arrowKeysHandler.bind(this)); | ||
232 | } | ||
233 | |||
234 | setFilter(needle = '') { | ||
235 | const { options } = this.props; | ||
236 | |||
237 | let filteredOptions = {}; | ||
238 | if (needle) { | ||
239 | Object.keys(options).map(key => { | ||
240 | if ( | ||
241 | key.toLocaleLowerCase().startsWith(needle.toLocaleLowerCase()) || | ||
242 | options[key] | ||
243 | .toLocaleLowerCase() | ||
244 | .startsWith(needle.toLocaleLowerCase()) | ||
245 | ) { | ||
246 | Object.assign(filteredOptions, { | ||
247 | [`${key}`]: options[key], | ||
248 | }); | ||
249 | } | ||
250 | }); | ||
251 | } else { | ||
252 | filteredOptions = options; | ||
253 | } | ||
254 | |||
255 | this.setState({ | ||
256 | needle, | ||
257 | options: filteredOptions, | ||
258 | selected: 0, | ||
259 | }); | ||
260 | } | ||
261 | |||
262 | select(key: string) { | ||
263 | this.setState(() => ({ | ||
264 | value: key, | ||
265 | open: false, | ||
266 | })); | ||
267 | |||
268 | this.setFilter(); | ||
269 | |||
270 | if (this.props.onChange) { | ||
271 | this.props.onChange(key as any); | ||
272 | } | ||
273 | } | ||
274 | |||
275 | arrowKeysHandler(e: KeyboardEvent) { | ||
276 | const { selected, open, options } = this.state; | ||
277 | |||
278 | if (!open) return; | ||
279 | |||
280 | if (e.keyCode === 38 || e.keyCode === 40) { | ||
281 | e.preventDefault(); | ||
282 | } | ||
283 | |||
284 | if (this.componentRef && this.componentRef.current) { | ||
285 | if (e.keyCode === 38 && selected > 0) { | ||
286 | this.setState((state: IState) => ({ | ||
287 | selected: state.selected - 1, | ||
288 | })); | ||
289 | } else if ( | ||
290 | e.keyCode === 40 && | ||
291 | selected < Object.keys(options!).length - 1 | ||
292 | ) { | ||
293 | this.setState((state: IState) => ({ | ||
294 | selected: state.selected + 1, | ||
295 | })); | ||
296 | } else if (e.keyCode === 13) { | ||
297 | this.select(Object.keys(options!)[selected]); | ||
298 | } | ||
299 | |||
300 | if ( | ||
301 | this.activeOptionRef && | ||
302 | this.activeOptionRef.current && | ||
303 | this.scrollContainerRef && | ||
304 | this.scrollContainerRef.current | ||
305 | ) { | ||
306 | const containerTopOffset = this.scrollContainerRef.current.offsetTop; | ||
307 | const optionTopOffset = this.activeOptionRef.current.offsetTop; | ||
308 | |||
309 | const topOffset = optionTopOffset - containerTopOffset; | ||
310 | |||
311 | this.scrollContainerRef.current.scrollTop = topOffset - 35; | ||
312 | } | ||
313 | } | ||
314 | |||
315 | switch (e.keyCode) { | ||
316 | case 37: | ||
317 | case 39: | ||
318 | case 38: | ||
319 | case 40: // Arrow keys | ||
320 | case 32: | ||
321 | break; // Space | ||
322 | default: | ||
323 | break; // do not block other keys | ||
324 | } | ||
325 | } | ||
326 | |||
327 | render() { | ||
328 | const { | ||
329 | actionText, | ||
330 | classes, | ||
331 | className, | ||
332 | defaultValue, | ||
333 | disabled, | ||
334 | error, | ||
335 | id, | ||
336 | inputClassName, | ||
337 | name, | ||
338 | label, | ||
339 | showLabel, | ||
340 | showSearch, | ||
341 | onChange, | ||
342 | required, | ||
343 | } = this.props; | ||
344 | |||
345 | const { open, needle, value, selected, options } = this.state; | ||
346 | |||
347 | let selection = ''; | ||
348 | if (!value && defaultValue && options![defaultValue]) { | ||
349 | selection = options![defaultValue]; | ||
350 | } else if (value && options![value]) { | ||
351 | selection = options![value]; | ||
352 | } else { | ||
353 | selection = actionText; | ||
354 | } | ||
355 | |||
356 | return ( | ||
357 | <Wrapper className={className} identifier="franz-select"> | ||
358 | <Label | ||
359 | title={label} | ||
360 | showLabel={showLabel} | ||
361 | htmlFor={id} | ||
362 | className={classes.label} | ||
363 | isRequired={required} | ||
364 | > | ||
365 | <div | ||
366 | className={classnames({ | ||
367 | [`${classes.hasError}`]: error, | ||
368 | [`${classes.disabled}`]: disabled, | ||
369 | })} | ||
370 | ref={this.componentRef} | ||
371 | > | ||
372 | <button | ||
373 | type="button" | ||
374 | className={classnames({ | ||
375 | [`${inputClassName}`]: inputClassName, | ||
376 | [`${classes.select}`]: true, | ||
377 | [`${classes.hasError}`]: error, | ||
378 | })} | ||
379 | onClick={ | ||
380 | !disabled | ||
381 | ? () => | ||
382 | this.setState((state: IState) => ({ | ||
383 | open: !state.open, | ||
384 | })) | ||
385 | : () => {} | ||
386 | } | ||
387 | > | ||
388 | {selection} | ||
389 | <Icon | ||
390 | path={mdiArrowRightDropCircleOutline} | ||
391 | size={0.8} | ||
392 | className={classnames({ | ||
393 | [`${classes.toggle}`]: true, | ||
394 | [`${classes.toggleOpened}`]: open, | ||
395 | })} | ||
396 | /> | ||
397 | </button> | ||
398 | {showSearch && open && ( | ||
399 | <div className={classes.searchContainer}> | ||
400 | <Icon path={mdiMagnify} size={0.8} /> | ||
401 | <input | ||
402 | type="text" | ||
403 | value={needle} | ||
404 | onChange={e => this.setFilter(e.currentTarget.value)} | ||
405 | placeholder="Search" | ||
406 | className={classes.search} | ||
407 | ref={this.searchInputRef} | ||
408 | /> | ||
409 | {needle && ( | ||
410 | <button | ||
411 | type="button" | ||
412 | className={classes.clearNeedle} | ||
413 | onClick={() => this.setFilter()} | ||
414 | > | ||
415 | <Icon path={mdiCloseCircle} size={0.7} /> | ||
416 | </button> | ||
417 | )} | ||
418 | </div> | ||
419 | )} | ||
420 | <div | ||
421 | className={classnames({ | ||
422 | [`${classes.popup}`]: true, | ||
423 | [`${classes.open}`]: open, | ||
424 | })} | ||
425 | ref={this.scrollContainerRef} | ||
426 | > | ||
427 | {Object.keys(options!).map((key, i) => ( | ||
428 | <div | ||
429 | key={key} | ||
430 | onClick={() => this.select(key)} | ||
431 | className={classnames({ | ||
432 | [`${classes.option}`]: true, | ||
433 | [`${classes.selected}`]: options![key] === selection, | ||
434 | [`${classes.focused}`]: selected === i, | ||
435 | })} | ||
436 | onMouseOver={() => this.setState({ selected: i })} | ||
437 | ref={selected === i ? this.activeOptionRef : null} | ||
438 | > | ||
439 | {options![key]} | ||
440 | </div> | ||
441 | ))} | ||
442 | </div> | ||
443 | </div> | ||
444 | <input | ||
445 | className={classes.input} | ||
446 | id={id} | ||
447 | name={name} | ||
448 | type="hidden" | ||
449 | defaultValue={value} | ||
450 | onChange={onChange} | ||
451 | disabled={disabled} | ||
452 | ref={this.inputRef} | ||
453 | /> | ||
454 | </Label> | ||
455 | {error && <Error message={error} />} | ||
456 | </Wrapper> | ||
457 | ); | ||
458 | } | ||
459 | } | ||
460 | |||
461 | export const Select = injectStyle(styles)(SelectComponent); | ||
diff --git a/src/components/ui/textarea/index.tsx b/src/components/ui/textarea/index.tsx new file mode 100644 index 000000000..1b16698eb --- /dev/null +++ b/src/components/ui/textarea/index.tsx | |||
@@ -0,0 +1,126 @@ | |||
1 | import classnames from 'classnames'; | ||
2 | import { Component, createRef, TextareaHTMLAttributes } from 'react'; | ||
3 | import injectSheet from 'react-jss'; | ||
4 | |||
5 | import { IFormField, IWithStyle } from '../typings/generic'; | ||
6 | |||
7 | import { Error } from '../error'; | ||
8 | import { Label } from '../label'; | ||
9 | import { Wrapper } from '../wrapper'; | ||
10 | |||
11 | import styles from './styles'; | ||
12 | |||
13 | interface IData { | ||
14 | [index: string]: string; | ||
15 | } | ||
16 | |||
17 | interface IProps | ||
18 | extends TextareaHTMLAttributes<HTMLTextAreaElement>, | ||
19 | IFormField, | ||
20 | IWithStyle { | ||
21 | focus?: boolean; | ||
22 | data: IData; | ||
23 | textareaClassName?: string; | ||
24 | } | ||
25 | |||
26 | class TextareaComponent extends Component<IProps> { | ||
27 | static defaultProps = { | ||
28 | focus: false, | ||
29 | onChange: () => {}, | ||
30 | onBlur: () => {}, | ||
31 | onFocus: () => {}, | ||
32 | showLabel: true, | ||
33 | disabled: false, | ||
34 | rows: 5, | ||
35 | }; | ||
36 | |||
37 | private textareaRef = createRef<HTMLTextAreaElement>(); | ||
38 | |||
39 | componentDidMount() { | ||
40 | const { data } = this.props; | ||
41 | |||
42 | if (this.textareaRef && this.textareaRef.current && data) { | ||
43 | Object.keys(data).map( | ||
44 | key => (this.textareaRef.current!.dataset[key] = data[key]), | ||
45 | ); | ||
46 | } | ||
47 | } | ||
48 | |||
49 | onChange(e: React.ChangeEvent<HTMLTextAreaElement>) { | ||
50 | const { onChange } = this.props; | ||
51 | |||
52 | if (onChange) { | ||
53 | onChange(e); | ||
54 | } | ||
55 | } | ||
56 | |||
57 | render() { | ||
58 | const { | ||
59 | classes, | ||
60 | className, | ||
61 | disabled, | ||
62 | error, | ||
63 | id, | ||
64 | textareaClassName, | ||
65 | label, | ||
66 | showLabel, | ||
67 | value, | ||
68 | name, | ||
69 | placeholder, | ||
70 | spellCheck, | ||
71 | onBlur, | ||
72 | onFocus, | ||
73 | minLength, | ||
74 | maxLength, | ||
75 | required, | ||
76 | rows, | ||
77 | noMargin, | ||
78 | } = this.props; | ||
79 | |||
80 | return ( | ||
81 | <Wrapper | ||
82 | className={className} | ||
83 | identifier="franz-textarea" | ||
84 | noMargin={noMargin} | ||
85 | > | ||
86 | <Label | ||
87 | title={label} | ||
88 | showLabel={showLabel} | ||
89 | htmlFor={id} | ||
90 | className={classes.label} | ||
91 | isRequired={required} | ||
92 | > | ||
93 | <div | ||
94 | className={classnames({ | ||
95 | [`${textareaClassName}`]: textareaClassName, | ||
96 | [`${classes.wrapper}`]: true, | ||
97 | [`${classes.disabled}`]: disabled, | ||
98 | [`${classes.hasError}`]: error, | ||
99 | })} | ||
100 | > | ||
101 | <textarea | ||
102 | id={id} | ||
103 | name={name} | ||
104 | placeholder={placeholder} | ||
105 | spellCheck={spellCheck} | ||
106 | className={classes.textarea} | ||
107 | ref={this.textareaRef} | ||
108 | onChange={this.onChange.bind(this)} | ||
109 | onFocus={onFocus} | ||
110 | onBlur={onBlur} | ||
111 | disabled={disabled} | ||
112 | minLength={minLength} | ||
113 | maxLength={maxLength} | ||
114 | rows={rows} | ||
115 | > | ||
116 | {value} | ||
117 | </textarea> | ||
118 | </div> | ||
119 | </Label> | ||
120 | {error && <Error message={error} />} | ||
121 | </Wrapper> | ||
122 | ); | ||
123 | } | ||
124 | } | ||
125 | |||
126 | export const Textarea = injectSheet(styles)(TextareaComponent); | ||
diff --git a/src/components/ui/textarea/styles.ts b/src/components/ui/textarea/styles.ts new file mode 100644 index 000000000..f2267e000 --- /dev/null +++ b/src/components/ui/textarea/styles.ts | |||
@@ -0,0 +1,54 @@ | |||
1 | import { Property } from 'csstype'; | ||
2 | |||
3 | import { Theme } from '@meetfranz/theme'; | ||
4 | |||
5 | export default (theme: Theme) => ({ | ||
6 | label: { | ||
7 | '& > div': { | ||
8 | marginTop: 5, | ||
9 | }, | ||
10 | }, | ||
11 | disabled: { | ||
12 | opacity: theme.inputDisabledOpacity, | ||
13 | }, | ||
14 | formModifier: { | ||
15 | background: 'none', | ||
16 | border: 0, | ||
17 | borderLeft: theme.inputBorder, | ||
18 | padding: '4px 20px 0', | ||
19 | outline: 'none', | ||
20 | |||
21 | '&:active': { | ||
22 | opacity: 0.5, | ||
23 | }, | ||
24 | |||
25 | '& svg': { | ||
26 | fill: theme.inputModifierColor, | ||
27 | }, | ||
28 | }, | ||
29 | textarea: { | ||
30 | background: 'none', | ||
31 | border: 0, | ||
32 | fontSize: theme.uiFontSize, | ||
33 | outline: 'none', | ||
34 | padding: 8, | ||
35 | width: '100%', | ||
36 | color: theme.inputColor, | ||
37 | |||
38 | '&::placeholder': { | ||
39 | color: theme.inputPlaceholderColor, | ||
40 | }, | ||
41 | }, | ||
42 | wrapper: { | ||
43 | background: theme.inputBackground, | ||
44 | border: theme.inputBorder, | ||
45 | borderRadius: theme.borderRadiusSmall, | ||
46 | boxSizing: 'border-box' as Property.BoxSizing, | ||
47 | display: 'flex', | ||
48 | order: 1, | ||
49 | width: '100%', | ||
50 | }, | ||
51 | hasError: { | ||
52 | borderColor: theme.brandDanger, | ||
53 | }, | ||
54 | }); | ||
diff --git a/src/components/ui/toggle/index.tsx b/src/components/ui/toggle/index.tsx new file mode 100644 index 000000000..67b6c3835 --- /dev/null +++ b/src/components/ui/toggle/index.tsx | |||
@@ -0,0 +1,125 @@ | |||
1 | import classnames from 'classnames'; | ||
2 | import { Property } from 'csstype'; | ||
3 | import { Component, InputHTMLAttributes } from 'react'; | ||
4 | import injectStyle from 'react-jss'; | ||
5 | import { Theme } from '@meetfranz/theme'; | ||
6 | |||
7 | import { IFormField, IWithStyle } from '../typings/generic'; | ||
8 | |||
9 | import { Error } from '../error'; | ||
10 | import { Label } from '../label'; | ||
11 | import { Wrapper } from '../wrapper'; | ||
12 | |||
13 | interface IProps | ||
14 | extends InputHTMLAttributes<HTMLInputElement>, | ||
15 | IFormField, | ||
16 | IWithStyle { | ||
17 | className?: string; | ||
18 | } | ||
19 | |||
20 | let buttonTransition: string = 'none'; | ||
21 | |||
22 | if (window && window.matchMedia('(prefers-reduced-motion: no-preference)')) { | ||
23 | buttonTransition = 'all .5s'; | ||
24 | } | ||
25 | |||
26 | const styles = (theme: Theme) => ({ | ||
27 | toggle: { | ||
28 | background: theme.toggleBackground, | ||
29 | borderRadius: theme.borderRadius, | ||
30 | height: theme.toggleHeight, | ||
31 | position: 'relative' as Property.Position, | ||
32 | width: theme.toggleWidth, | ||
33 | }, | ||
34 | button: { | ||
35 | background: theme.toggleButton, | ||
36 | borderRadius: '100%', | ||
37 | boxShadow: '0 1px 4px rgba(0, 0, 0, .3)', | ||
38 | width: theme.toggleHeight - 2, | ||
39 | height: theme.toggleHeight - 2, | ||
40 | left: 1, | ||
41 | top: 1, | ||
42 | position: 'absolute' as Property.Position, | ||
43 | transition: buttonTransition, | ||
44 | }, | ||
45 | buttonActive: { | ||
46 | background: theme.toggleButtonActive, | ||
47 | left: theme.toggleWidth - theme.toggleHeight + 1, | ||
48 | }, | ||
49 | input: { | ||
50 | visibility: 'hidden' as any, | ||
51 | }, | ||
52 | disabled: { | ||
53 | opacity: theme.inputDisabledOpacity, | ||
54 | }, | ||
55 | toggleLabel: { | ||
56 | display: 'flex', | ||
57 | alignItems: 'center', | ||
58 | |||
59 | '& > span': { | ||
60 | order: 1, | ||
61 | marginLeft: 15, | ||
62 | }, | ||
63 | }, | ||
64 | }); | ||
65 | |||
66 | class ToggleComponent extends Component<IProps> { | ||
67 | public static defaultProps = { | ||
68 | onChange: () => {}, | ||
69 | showLabel: true, | ||
70 | disabled: false, | ||
71 | error: '', | ||
72 | }; | ||
73 | |||
74 | render() { | ||
75 | const { | ||
76 | classes, | ||
77 | className, | ||
78 | disabled, | ||
79 | error, | ||
80 | id, | ||
81 | label, | ||
82 | showLabel, | ||
83 | checked, | ||
84 | value, | ||
85 | onChange, | ||
86 | } = this.props; | ||
87 | |||
88 | return ( | ||
89 | <Wrapper className={className} identifier="franz-toggle"> | ||
90 | <Label | ||
91 | title={label} | ||
92 | showLabel={showLabel} | ||
93 | htmlFor={id} | ||
94 | className={classes.toggleLabel} | ||
95 | > | ||
96 | <div | ||
97 | className={classnames({ | ||
98 | [`${classes.toggle}`]: true, | ||
99 | [`${classes.disabled}`]: disabled, | ||
100 | })} | ||
101 | > | ||
102 | <div | ||
103 | className={classnames({ | ||
104 | [`${classes.button}`]: true, | ||
105 | [`${classes.buttonActive}`]: checked, | ||
106 | })} | ||
107 | /> | ||
108 | <input | ||
109 | className={classes.input} | ||
110 | id={id} | ||
111 | type="checkbox" | ||
112 | checked={checked} | ||
113 | value={value} | ||
114 | onChange={onChange} | ||
115 | disabled={disabled} | ||
116 | /> | ||
117 | </div> | ||
118 | </Label> | ||
119 | {error && <Error message={error} />} | ||
120 | </Wrapper> | ||
121 | ); | ||
122 | } | ||
123 | } | ||
124 | |||
125 | export const Toggle = injectStyle(styles)(ToggleComponent); | ||
diff --git a/src/components/ui/typings/generic.ts b/src/components/ui/typings/generic.ts index ddce3f7c7..084e0e0a5 100644 --- a/src/components/ui/typings/generic.ts +++ b/src/components/ui/typings/generic.ts | |||
@@ -2,6 +2,14 @@ import { Classes } from 'jss'; | |||
2 | 2 | ||
3 | import { Theme } from '@meetfranz/theme'; | 3 | import { Theme } from '@meetfranz/theme'; |
4 | 4 | ||
5 | export interface IFormField { | ||
6 | showLabel?: boolean; | ||
7 | label?: string; | ||
8 | error?: string; | ||
9 | required?: boolean; | ||
10 | noMargin?: boolean; | ||
11 | } | ||
12 | |||
5 | export interface IWithStyle { | 13 | export interface IWithStyle { |
6 | classes: Classes; | 14 | classes: Classes; |
7 | theme: Theme; | 15 | theme: Theme; |
diff --git a/src/components/ui/wrapper/index.tsx b/src/components/ui/wrapper/index.tsx new file mode 100644 index 000000000..ffcd6fe0b --- /dev/null +++ b/src/components/ui/wrapper/index.tsx | |||
@@ -0,0 +1,37 @@ | |||
1 | import classnames from 'classnames'; | ||
2 | import { Component, ReactNode } from 'react'; | ||
3 | import injectStyle from 'react-jss'; | ||
4 | import { IWithStyle } from '../typings/generic'; | ||
5 | |||
6 | interface IProps extends IWithStyle { | ||
7 | children: ReactNode; | ||
8 | className?: string; | ||
9 | identifier: string; | ||
10 | noMargin?: boolean; | ||
11 | } | ||
12 | |||
13 | const styles = { | ||
14 | container: { | ||
15 | marginBottom: (props: IProps) => (props.noMargin ? 0 : 20), | ||
16 | }, | ||
17 | }; | ||
18 | |||
19 | class WrapperComponent extends Component<IProps> { | ||
20 | render() { | ||
21 | const { children, classes, className, identifier } = this.props; | ||
22 | |||
23 | return ( | ||
24 | <div | ||
25 | className={classnames({ | ||
26 | [`${classes.container}`]: true, | ||
27 | [`${className}`]: className, | ||
28 | })} | ||
29 | data-type={identifier} | ||
30 | > | ||
31 | {children} | ||
32 | </div> | ||
33 | ); | ||
34 | } | ||
35 | } | ||
36 | |||
37 | export const Wrapper = injectStyle(styles)(WrapperComponent); | ||