From ec15f83b947fb2daf4ca1a72e3af527dc89512a3 Mon Sep 17 00:00:00 2001 From: Vijay Aravamudhan Date: Fri, 15 Oct 2021 16:22:25 +0530 Subject: chore: move 'packages/forms' into 'src' (no longer an injected package) (#2079) --- src/components/auth/SetupAssistant.js | 3 +- .../settings/recipes/RecipesDashboard.js | 3 +- src/components/settings/user/EditUserForm.js | 3 +- src/components/ui/button/index.tsx | 265 ++++++++++++ src/components/ui/error/index.tsx | 20 + src/components/ui/error/styles.ts | 9 + src/components/ui/input/index.tsx | 208 ++++++++++ src/components/ui/input/scorePassword.ts | 42 ++ src/components/ui/input/styles.ts | 102 +++++ src/components/ui/label/index.tsx | 52 +++ src/components/ui/label/styles.ts | 12 + src/components/ui/select/index.tsx | 461 +++++++++++++++++++++ src/components/ui/textarea/index.tsx | 126 ++++++ src/components/ui/textarea/styles.ts | 54 +++ src/components/ui/toggle/index.tsx | 125 ++++++ src/components/ui/typings/generic.ts | 8 + src/components/ui/wrapper/index.tsx | 37 ++ src/features/quickSwitch/Component.js | 2 +- .../workspaces/components/CreateWorkspaceForm.js | 4 +- .../workspaces/components/EditWorkspaceForm.js | 5 +- .../components/WorkspaceServiceListItem.tsx | 2 +- 21 files changed, 1534 insertions(+), 9 deletions(-) create mode 100644 src/components/ui/button/index.tsx create mode 100644 src/components/ui/error/index.tsx create mode 100644 src/components/ui/error/styles.ts create mode 100644 src/components/ui/input/index.tsx create mode 100644 src/components/ui/input/scorePassword.ts create mode 100644 src/components/ui/input/styles.ts create mode 100644 src/components/ui/label/index.tsx create mode 100644 src/components/ui/label/styles.ts create mode 100644 src/components/ui/select/index.tsx create mode 100644 src/components/ui/textarea/index.tsx create mode 100644 src/components/ui/textarea/styles.ts create mode 100644 src/components/ui/toggle/index.tsx create mode 100644 src/components/ui/wrapper/index.tsx (limited to 'src') 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'; import injectSheet from 'react-jss'; import classnames from 'classnames'; -import { Input, Button } from '@meetfranz/forms'; +import { Input } from '../ui/input/index'; +import { Button } from '../ui/button/index'; import { Badge } from '../ui/badge'; import Modal from '../ui/Modal'; 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'; import { defineMessages, injectIntl } from 'react-intl'; import { Link } from 'react-router'; -import { Button, Input } from '@meetfranz/forms'; import injectSheet from 'react-jss'; +import { Button } from '../../ui/button/index'; +import { Input } from '../../ui/input/index'; import { H3, H2 } from '../../ui/headline'; import SearchInput from '../../ui/SearchInput'; 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'; import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; import { defineMessages, injectIntl } from 'react-intl'; import { Link } from 'react-router'; -import { Input } from '@meetfranz/forms'; +import { Input } from '../../ui/input/index'; import Form from '../../../lib/Form'; -// import Input from '../../ui/Input'; import Button from '../../ui/Button'; import Radio from '../../ui/Radio'; 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 @@ +import Icon from '@mdi/react'; +import classnames from 'classnames'; +import { Property } from 'csstype'; +import { Component, MouseEvent } from 'react'; +import injectStyle, { withTheme } from 'react-jss'; +import Loader from 'react-loader'; +import { Theme } from '@meetfranz/theme'; + +import { IFormField, IWithStyle } from '../typings/generic'; + +type ButtonType = + | 'primary' + | 'secondary' + | 'success' + | 'danger' + | 'warning' + | 'inverted'; + +interface IProps extends IFormField, IWithStyle { + className?: string; + disabled?: boolean; + id?: string; + type?: 'button' | 'reset' | 'submit' | undefined; + onClick: ( + event: MouseEvent | MouseEvent, + ) => void; + buttonType?: ButtonType; + stretch?: boolean; + loaded?: boolean; + busy?: boolean; + icon?: string; + href?: string; + target?: string; +} + +let buttonTransition: string = 'none'; +let loaderContainerTransition: string = 'none'; + +if (window && window.matchMedia('(prefers-reduced-motion: no-preference)')) { + buttonTransition = 'background .5s, opacity 0.3s'; + loaderContainerTransition = 'all 0.3s'; +} + +const styles = (theme: Theme) => ({ + button: { + borderRadius: theme.borderRadiusSmall, + border: 'none', + display: 'inline-flex', + position: 'relative' as Property.Position, + transition: buttonTransition, + textAlign: 'center' as Property.TextAlign, + outline: 'none', + alignItems: 'center', + padding: 0, + width: (props: IProps) => + (props.stretch ? '100%' : 'auto') as Property.Width, + fontSize: theme.uiFontSize, + textDecoration: 'none', + + '&:hover': { + opacity: 0.8, + }, + '&:active': { + opacity: 0.5, + transition: 'none', + }, + }, + label: { + margin: '10px 20px', + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + primary: { + background: theme.buttonPrimaryBackground, + color: theme.buttonPrimaryTextColor, + + '& svg': { + fill: theme.buttonPrimaryTextColor, + }, + }, + secondary: { + background: theme.buttonSecondaryBackground, + color: theme.buttonSecondaryTextColor, + + '& svg': { + fill: theme.buttonSecondaryTextColor, + }, + }, + success: { + background: theme.buttonSuccessBackground, + color: theme.buttonSuccessTextColor, + + '& svg': { + fill: theme.buttonSuccessTextColor, + }, + }, + danger: { + background: theme.buttonDangerBackground, + color: theme.buttonDangerTextColor, + + '& svg': { + fill: theme.buttonDangerTextColor, + }, + }, + warning: { + background: theme.buttonWarningBackground, + color: theme.buttonWarningTextColor, + + '& svg': { + fill: theme.buttonWarningTextColor, + }, + }, + inverted: { + background: theme.buttonInvertedBackground, + color: theme.buttonInvertedTextColor, + border: theme.buttonInvertedBorder, + + '& svg': { + fill: theme.buttonInvertedTextColor, + }, + }, + disabled: { + opacity: theme.inputDisabledOpacity, + }, + loader: { + position: 'relative' as Property.Position, + width: 20, + height: 18, + zIndex: 9999, + }, + loaderContainer: { + width: (props: IProps): string => (!props.busy ? '0' : '40px'), + height: 20, + overflow: 'hidden', + transition: loaderContainerTransition, + marginLeft: (props: IProps): number => (!props.busy ? 10 : 20), + marginRight: (props: IProps): number => (!props.busy ? -10 : -20), + position: (props: IProps): Property.Position => + props.stretch ? 'absolute' : 'inherit', + }, + icon: { + margin: [1, 10, 0, -5], + }, +}); + +class ButtonComponent extends Component { + public static defaultProps = { + type: 'button', + disabled: false, + onClick: () => null, + buttonType: 'primary' as ButtonType, + stretch: false, + busy: false, + }; + + state = { + busy: false, + }; + + componentWillMount() { + this.setState({ busy: this.props.busy }); + } + + componentWillReceiveProps(nextProps: IProps) { + if (nextProps.busy !== this.props.busy) { + if (this.props.busy) { + setTimeout(() => { + this.setState({ busy: nextProps.busy }); + }, 300); + } else { + this.setState({ busy: nextProps.busy }); + } + } + } + + render() { + const { + classes, + className, + theme, + disabled, + id, + label, + type, + onClick, + buttonType, + loaded, + icon, + href, + target, + } = this.props; + + const { busy } = this.state; + + let showLoader = false; + if (loaded) { + showLoader = !loaded; + console.warn( + 'Ferdi Button prop `loaded` will be deprecated in the future. Please use `busy` instead', + ); + } + if (busy) { + showLoader = busy; + } + + const content = ( + <> +
+ {showLoader && ( + + )} +
+
+ {icon && } + {label} +
+ + ); + + const wrapperComponent = !href ? ( + + ) : ( + + {content} + + ); + + return wrapperComponent; + } +} + +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 @@ +import { Classes } from 'jss'; +import { Component } from 'react'; +import injectSheet from 'react-jss'; + +import styles from './styles'; + +interface IProps { + classes: Classes; + message: string; +} + +class ErrorComponent extends Component { + render() { + const { classes, message } = this.props; + + return

{message}

; + } +} + +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 @@ +import { Theme } from '@meetfranz/theme'; + +export default (theme: Theme) => ({ + message: { + color: theme.brandDanger, + margin: '5px 0 0', + fontSize: theme.uiFontSize, + }, +}); 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 @@ +import { mdiEye, mdiEyeOff } from '@mdi/js'; +import Icon from '@mdi/react'; +import classnames from 'classnames'; +import { Component, createRef, InputHTMLAttributes } from 'react'; +import injectSheet from 'react-jss'; + +import { IFormField, IWithStyle } from '../typings/generic'; + +import { Error } from '../error'; +import { Label } from '../label'; +import { Wrapper } from '../wrapper'; +import { scorePasswordFunc } from './scorePassword'; + +import styles from './styles'; + +interface IData { + [index: string]: string; +} + +interface IProps + extends InputHTMLAttributes, + IFormField, + IWithStyle { + focus?: boolean; + prefix?: string; + suffix?: string; + scorePassword?: boolean; + showPasswordToggle?: boolean; + data: IData; + inputClassName?: string; + onEnterKey?: Function; +} + +interface IState { + showPassword: boolean; + passwordScore: number; +} + +class InputComponent extends Component { + static defaultProps = { + focus: false, + onChange: () => {}, + onBlur: () => {}, + onFocus: () => {}, + scorePassword: false, + showLabel: true, + showPasswordToggle: false, + type: 'text', + disabled: false, + }; + + state = { + passwordScore: 0, + showPassword: false, + }; + + private inputRef = createRef(); + + componentDidMount() { + const { focus, data } = this.props; + + if (this.inputRef && this.inputRef.current) { + if (focus) { + this.inputRef.current.focus(); + } + + if (data) { + Object.keys(data).map( + key => (this.inputRef.current!.dataset[key] = data[key]), + ); + } + } + } + + onChange(e: React.ChangeEvent) { + const { scorePassword, onChange } = this.props; + + if (onChange) { + onChange(e); + } + + if (this.inputRef && this.inputRef.current && scorePassword) { + this.setState({ + passwordScore: scorePasswordFunc(this.inputRef.current.value), + }); + } + } + + onInputKeyPress(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + const { onEnterKey } = this.props; + onEnterKey && onEnterKey(); + } + } + + render() { + const { + classes, + className, + disabled, + error, + id, + inputClassName, + label, + prefix, + scorePassword, + suffix, + showLabel, + showPasswordToggle, + type, + value, + name, + placeholder, + spellCheck, + onBlur, + onFocus, + min, + max, + step, + required, + noMargin, + } = this.props; + + const { showPassword, passwordScore } = this.state; + + const inputType = type === 'password' && showPassword ? 'text' : type; + + return ( + + + {error && } + + ); + } +} + +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 @@ +interface ILetters { + [key: string]: number; +} + +interface IVariations { + [index: string]: boolean; + digits: boolean; + lower: boolean; + nonWords: boolean; + upper: boolean; +} + +export function scorePasswordFunc(password: string): number { + let score = 0; + if (!password) { + return score; + } + + // award every unique letter until 5 repetitions + const letters: ILetters = {}; + for (const element of password) { + letters[element] = (letters[element] || 0) + 1; + score += 5 / letters[element]; + } + + // bonus points for mixing it up + const variations: IVariations = { + digits: /\d/.test(password), + lower: /[a-z]/.test(password), + nonWords: /\W/.test(password), + upper: /[A-Z]/.test(password), + }; + + let variationCount = 0; + for (const key of Object.keys(variations)) { + variationCount += variations[key] === true ? 1 : 0; + } + + score += (variationCount - 1) * 10; + + return Math.round(score); +} 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 @@ +import { Property } from 'csstype'; + +import { Theme } from '@meetfranz/theme'; + +const prefixStyles = (theme: Theme) => ({ + background: theme.inputPrefixBackground, + color: theme.inputPrefixColor, + lineHeight: `${theme.inputHeight}px`, + padding: '0 10px', + fontSize: theme.uiFontSize, +}); + +export default (theme: Theme) => ({ + label: { + '& > div': { + marginTop: 5, + }, + }, + disabled: { + opacity: theme.inputDisabledOpacity, + }, + formModifier: { + background: 'none', + border: 0, + borderLeft: theme.inputBorder, + padding: '4px 20px 0', + outline: 'none', + + '&:active': { + opacity: 0.5, + }, + + '& svg': { + fill: theme.inputModifierColor, + }, + }, + input: { + background: 'none', + border: 0, + fontSize: theme.uiFontSize, + outline: 'none', + padding: 8, + width: '100%', + color: theme.inputColor, + + '&::placeholder': { + color: theme.inputPlaceholderColor, + }, + }, + passwordScore: { + background: theme.inputScorePasswordBackground, + border: theme.inputBorder, + borderTopWidth: 0, + borderBottomLeftRadius: theme.borderRadiusSmall, + borderBottomRightRadius: theme.borderRadiusSmall, + display: 'block', + flexBasis: '100%', + height: 5, + overflow: 'hidden', + + '& meter': { + display: 'block', + height: '100%', + width: '100%', + + '&::-webkit-meter-bar': { + background: 'none', + }, + + '&::-webkit-meter-even-less-good-value': { + background: theme.brandDanger, + }, + + '&::-webkit-meter-suboptimum-value': { + background: theme.brandWarning, + }, + + '&::-webkit-meter-optimum-value': { + background: theme.brandSuccess, + }, + }, + }, + prefix: prefixStyles(theme), + suffix: prefixStyles(theme), + wrapper: { + background: theme.inputBackground, + border: theme.inputBorder, + borderRadius: theme.borderRadiusSmall, + boxSizing: 'border-box' as Property.BoxSizing, + display: 'flex', + height: theme.inputHeight, + order: 1, + width: '100%', + }, + hasPasswordScore: { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + }, + hasError: { + borderColor: theme.brandDanger, + }, +}); 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 @@ +import classnames from 'classnames'; +import { Classes } from 'jss'; +import { Component, LabelHTMLAttributes } from 'react'; +import injectSheet from 'react-jss'; + +import { IFormField } from '../typings/generic'; + +import styles from './styles'; + +interface ILabel extends IFormField, LabelHTMLAttributes { + classes: Classes; + isRequired: boolean; +} + +class LabelComponent extends Component { + static defaultProps = { + showLabel: true, + }; + + render() { + const { + title, + showLabel, + classes, + className, + children, + htmlFor, + isRequired, + } = this.props; + + if (!showLabel) return children; + + return ( + + ); + } +} + +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 @@ +import { Theme } from '@meetfranz/theme'; + +export default (theme: Theme) => ({ + content: {}, + label: { + color: theme.labelColor, + fontSize: theme.uiFontSize, + }, + hasError: { + color: theme.brandDanger, + }, +}); 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 @@ +import { + mdiArrowRightDropCircleOutline, + mdiCloseCircle, + mdiMagnify, +} from '@mdi/js'; +import Icon from '@mdi/react'; +import classnames from 'classnames'; +import { ChangeEvent, Component, createRef } from 'react'; +import injectStyle from 'react-jss'; + +import { Theme } from '@meetfranz/theme'; + +import { IFormField, IWithStyle } from '../typings/generic'; + +import { Error } from '../error'; +import { Label } from '../label'; +import { Wrapper } from '../wrapper'; + +interface IOptions { + [index: string]: string; +} + +interface IData { + [index: string]: string; +} + +interface IProps extends IFormField, IWithStyle { + actionText: string; + className?: string; + inputClassName?: string; + defaultValue?: string; + disabled?: boolean; + id?: string; + name: string; + options: IOptions; + value: string; + onChange: (event: ChangeEvent) => void; + showSearch: boolean; + data: IData; +} + +interface IState { + open: boolean; + value: string; + needle: string; + selected: number; + options: IOptions; +} + +let popupTransition: string = 'none'; +let toggleTransition: string = 'none'; + +if (window && window.matchMedia('(prefers-reduced-motion: no-preference)')) { + popupTransition = 'all 0.3s'; + toggleTransition = 'transform 0.3s'; +} + +const styles = (theme: Theme) => ({ + select: { + background: theme.selectBackground, + border: theme.selectBorder, + borderRadius: theme.borderRadiusSmall, + height: theme.selectHeight, + fontSize: theme.uiFontSize, + width: '100%', + display: 'flex', + alignItems: 'center', + textAlign: 'left', + color: theme.selectColor, + }, + label: { + '& > div': { + marginTop: 5, + }, + }, + popup: { + opacity: 0, + height: 0, + overflowX: 'scroll', + border: theme.selectBorder, + borderTop: 0, + transition: popupTransition, + }, + open: { + opacity: 1, + height: 350, + background: theme.selectPopupBackground, + }, + option: { + padding: 10, + borderBottom: theme.selectOptionBorder, + color: theme.selectOptionColor, + + '&:hover': { + background: theme.selectOptionItemHover, + color: theme.selectOptionItemHoverColor, + }, + '&:active': { + background: theme.selectOptionItemActive, + color: theme.selectOptionItemActiveColor, + }, + }, + selected: { + background: theme.selectOptionItemActive, + color: theme.selectOptionItemActiveColor, + }, + toggle: { + marginLeft: 'auto', + fill: theme.selectToggleColor, + transition: toggleTransition, + }, + toggleOpened: { + transform: 'rotateZ(90deg)', + }, + searchContainer: { + display: 'flex', + background: theme.selectSearchBackground, + alignItems: 'center', + paddingLeft: 10, + color: theme.selectColor, + + '& svg': { + fill: theme.selectSearchColor, + }, + }, + search: { + border: 0, + width: '100%', + fontSize: theme.uiFontSize, + background: 'none', + marginLeft: 10, + padding: [10, 0], + color: theme.selectSearchColor, + }, + clearNeedle: { + background: 'none', + border: 0, + }, + focused: { + fontWeight: 'bold', + background: theme.selectOptionItemHover, + color: theme.selectOptionItemHoverColor, + }, + hasError: { + borderColor: theme.brandDanger, + }, + disabled: { + opacity: theme.selectDisabledOpacity, + }, +}); + +class SelectComponent extends Component { + public static defaultProps = { + onChange: () => {}, + showLabel: true, + disabled: false, + error: '', + }; + + state = { + open: false, + value: '', + needle: '', + selected: 0, + options: null, + }; + + private componentRef = createRef(); + + private inputRef = createRef(); + + private searchInputRef = createRef(); + + private scrollContainerRef = createRef(); + + private activeOptionRef = createRef(); + + private keyListener: any; + + componentWillReceiveProps(nextProps: IProps) { + if (nextProps.value && nextProps.value !== this.props.value) { + this.setState({ + value: nextProps.value, + }); + } + } + + componentDidUpdate() { + const { open } = this.state; + + if (this.searchInputRef && this.searchInputRef.current && open) { + this.searchInputRef.current.focus(); + } + } + + componentDidMount() { + if (this.inputRef && this.inputRef.current) { + const { data } = this.props; + + if (data) { + Object.keys(data).map( + key => (this.inputRef.current!.dataset[key] = data[key]), + ); + } + } + + window.addEventListener('keydown', this.arrowKeysHandler.bind(this), false); + } + + componentWillMount() { + const { value } = this.props; + + if (this.componentRef && this.componentRef.current) { + this.componentRef.current.removeEventListener( + 'keydown', + this.keyListener, + ); + } + + if (value) { + this.setState({ + value, + }); + } + + this.setFilter(); + } + + componentWillUnmount() { + // eslint-disable-next-line unicorn/no-invalid-remove-event-listener + window.removeEventListener('keydown', this.arrowKeysHandler.bind(this)); + } + + setFilter(needle = '') { + const { options } = this.props; + + let filteredOptions = {}; + if (needle) { + Object.keys(options).map(key => { + if ( + key.toLocaleLowerCase().startsWith(needle.toLocaleLowerCase()) || + options[key] + .toLocaleLowerCase() + .startsWith(needle.toLocaleLowerCase()) + ) { + Object.assign(filteredOptions, { + [`${key}`]: options[key], + }); + } + }); + } else { + filteredOptions = options; + } + + this.setState({ + needle, + options: filteredOptions, + selected: 0, + }); + } + + select(key: string) { + this.setState(() => ({ + value: key, + open: false, + })); + + this.setFilter(); + + if (this.props.onChange) { + this.props.onChange(key as any); + } + } + + arrowKeysHandler(e: KeyboardEvent) { + const { selected, open, options } = this.state; + + if (!open) return; + + if (e.keyCode === 38 || e.keyCode === 40) { + e.preventDefault(); + } + + if (this.componentRef && this.componentRef.current) { + if (e.keyCode === 38 && selected > 0) { + this.setState((state: IState) => ({ + selected: state.selected - 1, + })); + } else if ( + e.keyCode === 40 && + selected < Object.keys(options!).length - 1 + ) { + this.setState((state: IState) => ({ + selected: state.selected + 1, + })); + } else if (e.keyCode === 13) { + this.select(Object.keys(options!)[selected]); + } + + if ( + this.activeOptionRef && + this.activeOptionRef.current && + this.scrollContainerRef && + this.scrollContainerRef.current + ) { + const containerTopOffset = this.scrollContainerRef.current.offsetTop; + const optionTopOffset = this.activeOptionRef.current.offsetTop; + + const topOffset = optionTopOffset - containerTopOffset; + + this.scrollContainerRef.current.scrollTop = topOffset - 35; + } + } + + switch (e.keyCode) { + case 37: + case 39: + case 38: + case 40: // Arrow keys + case 32: + break; // Space + default: + break; // do not block other keys + } + } + + render() { + const { + actionText, + classes, + className, + defaultValue, + disabled, + error, + id, + inputClassName, + name, + label, + showLabel, + showSearch, + onChange, + required, + } = this.props; + + const { open, needle, value, selected, options } = this.state; + + let selection = ''; + if (!value && defaultValue && options![defaultValue]) { + selection = options![defaultValue]; + } else if (value && options![value]) { + selection = options![value]; + } else { + selection = actionText; + } + + return ( + + + {error && } + + ); + } +} + +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 @@ +import classnames from 'classnames'; +import { Component, createRef, TextareaHTMLAttributes } from 'react'; +import injectSheet from 'react-jss'; + +import { IFormField, IWithStyle } from '../typings/generic'; + +import { Error } from '../error'; +import { Label } from '../label'; +import { Wrapper } from '../wrapper'; + +import styles from './styles'; + +interface IData { + [index: string]: string; +} + +interface IProps + extends TextareaHTMLAttributes, + IFormField, + IWithStyle { + focus?: boolean; + data: IData; + textareaClassName?: string; +} + +class TextareaComponent extends Component { + static defaultProps = { + focus: false, + onChange: () => {}, + onBlur: () => {}, + onFocus: () => {}, + showLabel: true, + disabled: false, + rows: 5, + }; + + private textareaRef = createRef(); + + componentDidMount() { + const { data } = this.props; + + if (this.textareaRef && this.textareaRef.current && data) { + Object.keys(data).map( + key => (this.textareaRef.current!.dataset[key] = data[key]), + ); + } + } + + onChange(e: React.ChangeEvent) { + const { onChange } = this.props; + + if (onChange) { + onChange(e); + } + } + + render() { + const { + classes, + className, + disabled, + error, + id, + textareaClassName, + label, + showLabel, + value, + name, + placeholder, + spellCheck, + onBlur, + onFocus, + minLength, + maxLength, + required, + rows, + noMargin, + } = this.props; + + return ( + + + {error && } + + ); + } +} + +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 @@ +import { Property } from 'csstype'; + +import { Theme } from '@meetfranz/theme'; + +export default (theme: Theme) => ({ + label: { + '& > div': { + marginTop: 5, + }, + }, + disabled: { + opacity: theme.inputDisabledOpacity, + }, + formModifier: { + background: 'none', + border: 0, + borderLeft: theme.inputBorder, + padding: '4px 20px 0', + outline: 'none', + + '&:active': { + opacity: 0.5, + }, + + '& svg': { + fill: theme.inputModifierColor, + }, + }, + textarea: { + background: 'none', + border: 0, + fontSize: theme.uiFontSize, + outline: 'none', + padding: 8, + width: '100%', + color: theme.inputColor, + + '&::placeholder': { + color: theme.inputPlaceholderColor, + }, + }, + wrapper: { + background: theme.inputBackground, + border: theme.inputBorder, + borderRadius: theme.borderRadiusSmall, + boxSizing: 'border-box' as Property.BoxSizing, + display: 'flex', + order: 1, + width: '100%', + }, + hasError: { + borderColor: theme.brandDanger, + }, +}); 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 @@ +import classnames from 'classnames'; +import { Property } from 'csstype'; +import { Component, InputHTMLAttributes } from 'react'; +import injectStyle from 'react-jss'; +import { Theme } from '@meetfranz/theme'; + +import { IFormField, IWithStyle } from '../typings/generic'; + +import { Error } from '../error'; +import { Label } from '../label'; +import { Wrapper } from '../wrapper'; + +interface IProps + extends InputHTMLAttributes, + IFormField, + IWithStyle { + className?: string; +} + +let buttonTransition: string = 'none'; + +if (window && window.matchMedia('(prefers-reduced-motion: no-preference)')) { + buttonTransition = 'all .5s'; +} + +const styles = (theme: Theme) => ({ + toggle: { + background: theme.toggleBackground, + borderRadius: theme.borderRadius, + height: theme.toggleHeight, + position: 'relative' as Property.Position, + width: theme.toggleWidth, + }, + button: { + background: theme.toggleButton, + borderRadius: '100%', + boxShadow: '0 1px 4px rgba(0, 0, 0, .3)', + width: theme.toggleHeight - 2, + height: theme.toggleHeight - 2, + left: 1, + top: 1, + position: 'absolute' as Property.Position, + transition: buttonTransition, + }, + buttonActive: { + background: theme.toggleButtonActive, + left: theme.toggleWidth - theme.toggleHeight + 1, + }, + input: { + visibility: 'hidden' as any, + }, + disabled: { + opacity: theme.inputDisabledOpacity, + }, + toggleLabel: { + display: 'flex', + alignItems: 'center', + + '& > span': { + order: 1, + marginLeft: 15, + }, + }, +}); + +class ToggleComponent extends Component { + public static defaultProps = { + onChange: () => {}, + showLabel: true, + disabled: false, + error: '', + }; + + render() { + const { + classes, + className, + disabled, + error, + id, + label, + showLabel, + checked, + value, + onChange, + } = this.props; + + return ( + +