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