diff --git a/CHANGELOG.md b/CHANGELOG.md index 2acb5718fc..43f33124ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Features - Add `target` prop to `Popup` @kuzhelog ([#356](https://github.com/stardust-ui/react/pull/356)) +- Add new `Input` component with `wrapper` prop @Bugaa92 ([#326](https://github.com/stardust-ui/react/pull/326)) ## [v0.9.1](https://github.com/stardust-ui/react/tree/v0.9.1) (2018-10-11) diff --git a/docs/src/examples/components/Input/Variations/InputExampleClearable.shorthand.tsx b/docs/src/examples/components/Input/Variations/InputExampleClearable.shorthand.tsx index d8a61b7f16..ee6267fadf 100644 --- a/docs/src/examples/components/Input/Variations/InputExampleClearable.shorthand.tsx +++ b/docs/src/examples/components/Input/Variations/InputExampleClearable.shorthand.tsx @@ -1,6 +1,6 @@ import React from 'react' import { Input } from '@stardust-ui/react' -const InputExampleClearableShorthand = () => +const InputExampleClearable = () => -export default InputExampleClearableShorthand +export default InputExampleClearable diff --git a/docs/src/examples/components/Input/Variations/InputExampleFluid.shorthand.tsx b/docs/src/examples/components/Input/Variations/InputExampleFluid.shorthand.tsx index 2c1c9bcd0b..daee65cd4c 100644 --- a/docs/src/examples/components/Input/Variations/InputExampleFluid.shorthand.tsx +++ b/docs/src/examples/components/Input/Variations/InputExampleFluid.shorthand.tsx @@ -1,6 +1,6 @@ import React from 'react' import { Input } from '@stardust-ui/react' -const InputExample = () => +const InputExampleFluid = () => -export default InputExample +export default InputExampleFluid diff --git a/docs/src/examples/components/Input/Variations/InputExampleInlineIconClearable.shorthand.tsx b/docs/src/examples/components/Input/Variations/InputExampleInlineIconClearable.shorthand.tsx new file mode 100644 index 0000000000..55e7e29704 --- /dev/null +++ b/docs/src/examples/components/Input/Variations/InputExampleInlineIconClearable.shorthand.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { Input } from '@stardust-ui/react' + +const InputExampleInline = () => ( +
+ Some text inline with the and + more text. +
+) + +export default InputExampleInline diff --git a/docs/src/examples/components/Input/Variations/InputExampleInputSlot.shorthand.tsx b/docs/src/examples/components/Input/Variations/InputExampleInputSlot.shorthand.tsx new file mode 100644 index 0000000000..f492c13d3a --- /dev/null +++ b/docs/src/examples/components/Input/Variations/InputExampleInputSlot.shorthand.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { Grid, Input, Text } from '@stardust-ui/react' + +const inputStyles = { color: 'blue', background: 'yellow' } +const InputExampleInputSlot = () => ( + + + + + + + + + + } + /> + + + } + /> + +) + +export default InputExampleInputSlot diff --git a/docs/src/examples/components/Input/Variations/InputExampleTargeting.shorthand.tsx b/docs/src/examples/components/Input/Variations/InputExampleTargeting.shorthand.tsx new file mode 100644 index 0000000000..798b1dca3f --- /dev/null +++ b/docs/src/examples/components/Input/Variations/InputExampleTargeting.shorthand.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { Grid, Input, Text } from '@stardust-ui/react' + +// This object contains properties that will be applied to the input +const propsForInput = { placeholder: 'Search...', id: 'inputId', role: 'checkbox' } +const propsTargettingWrapper = { + placeholder: 'Wrapper placeholder...', + id: 'wrapperId', + role: 'presentation', +} + +// This object contains properties that will be applied to the wrapper +const propsForWrapper = { dir: 'ltr', tabIndex: -1, styles: { padding: '5px', background: 'red' } } +const propsTargettingInput = { + dir: 'rtl', + tabIndex: 0, + styles: { color: 'blue', background: 'yellow' }, +} + +const InputExampleTargeting = () => ( + + + + + + + + + + + + + +) + +export default InputExampleTargeting diff --git a/docs/src/examples/components/Input/Variations/InputExampleWrapperSlot.shorthand.tsx b/docs/src/examples/components/Input/Variations/InputExampleWrapperSlot.shorthand.tsx new file mode 100644 index 0000000000..d6a65cda35 --- /dev/null +++ b/docs/src/examples/components/Input/Variations/InputExampleWrapperSlot.shorthand.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { Grid, Input, Text } from '@stardust-ui/react' + +const InputExampleWrapperSlot = () => ( + + + + + + + + + } + /> + + + } + /> + +) + +export default InputExampleWrapperSlot diff --git a/docs/src/examples/components/Input/Variations/index.tsx b/docs/src/examples/components/Input/Variations/index.tsx index 5b2f69527d..b43476677b 100644 --- a/docs/src/examples/components/Input/Variations/index.tsx +++ b/docs/src/examples/components/Input/Variations/index.tsx @@ -29,6 +29,26 @@ const Variations = () => ( description="An input can be used inline with text." examplePath="components/Input/Variations/InputExampleInline" /> + + + + ) diff --git a/docs/src/examples/components/RadioGroup/Types/RadioGroupExample.shorthand.tsx b/docs/src/examples/components/RadioGroup/Types/RadioGroupExample.shorthand.tsx index ff1580da8e..8bf469347c 100644 --- a/docs/src/examples/components/RadioGroup/Types/RadioGroupExample.shorthand.tsx +++ b/docs/src/examples/components/RadioGroup/Types/RadioGroupExample.shorthand.tsx @@ -34,7 +34,8 @@ class RadioGroupVerticalExample extends React.Component { key: 'Custom', label: ( - Choose your own + Choose your own{' '} + ), value: 'custom', diff --git a/docs/src/examples/components/RadioGroup/Types/RadioGroupVerticalExample.shorthand.tsx b/docs/src/examples/components/RadioGroup/Types/RadioGroupVerticalExample.shorthand.tsx index c771af972e..7ed24ff27d 100644 --- a/docs/src/examples/components/RadioGroup/Types/RadioGroupVerticalExample.shorthand.tsx +++ b/docs/src/examples/components/RadioGroup/Types/RadioGroupVerticalExample.shorthand.tsx @@ -35,7 +35,8 @@ class RadioGroupVerticalExample extends React.Component { key: 'Custom', label: ( - Choose your own + Choose your own{' '} + ), value: 'custom', diff --git a/docs/src/prototypes/chatPane/chatPaneHeader.tsx b/docs/src/prototypes/chatPane/chatPaneHeader.tsx index ca3f1ef893..441a51ed30 100644 --- a/docs/src/prototypes/chatPane/chatPaneHeader.tsx +++ b/docs/src/prototypes/chatPane/chatPaneHeader.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import { Avatar, Button, Divider, Icon, Layout, Segment, Text } from '@stardust-ui/react' -import { pxToRem } from 'src/lib' import { IChat } from './services' import { Sizes } from 'src/lib/enums' @@ -86,8 +85,8 @@ class ChatPaneHeader extends React.PureComponent { tabIndex={0} styles={{ fontWeight: 100, - ...(!index && { marginRight: '1.6rem' }), - marginTop: pxToRem(8), + margin: 'auto', + ...(!index && { margin: 'auto 1.6rem auto auto' }), }} variables={siteVars => ({ color: siteVars.gray04 })} /> diff --git a/docs/src/prototypes/chatPane/composeMessage.tsx b/docs/src/prototypes/chatPane/composeMessage.tsx index 248ebdc3b7..c9d5cb043a 100644 --- a/docs/src/prototypes/chatPane/composeMessage.tsx +++ b/docs/src/prototypes/chatPane/composeMessage.tsx @@ -21,6 +21,7 @@ class ComposeMessage extends React.Component { ({ backgroundColor: siteVars.white })} /> ) diff --git a/package.json b/package.json index 70a75f1979..b0c383a9ab 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ }, "devDependencies": { "@types/classnames": "^2.2.4", + "@types/enzyme": "^3.1.14", "@types/faker": "^4.1.3", "@types/gulp-load-plugins": "^0.0.31", "@types/jest": "^23.1.0", diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 0f91ded013..7db66e2cb2 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -1,41 +1,47 @@ -import * as PropTypes from 'prop-types' import * as React from 'react' +import * as PropTypes from 'prop-types' +import * as cx from 'classnames' import * as _ from 'lodash' import { AutoControlledComponent, - createHTMLInput, customPropTypes, - getUnhandledProps, + IRenderResultConfig, partitionHTMLProps, } from '../../lib' -import Icon from '../Icon' -import { ComponentVariablesInput, ComponentPartStyle } from '../../../types/theme' import { - ComponentEventHandler, Extendable, - ReactChildren, - ShorthandRenderFunction, ShorthandValue, + ShorthandRenderFunction, + ComponentEventHandler, } from '../../../types/utils' +import { ComponentPartStyle, ComponentVariablesInput } from '../../../types/theme' +import Icon from '../Icon' +import Slot from '../Slot' +import Ref from '../Ref' export interface IInputProps { as?: any - children?: ReactChildren className?: string clearable?: boolean - defaultValue?: string | number + defaultValue?: React.ReactText fluid?: boolean icon?: ShorthandValue inline?: boolean input?: ShorthandValue onChange?: ComponentEventHandler - value?: string | number - type?: string renderIcon?: ShorthandRenderFunction renderInput?: ShorthandRenderFunction - styles?: ComponentPartStyle + renderWrapper?: ShorthandRenderFunction + styles?: ComponentPartStyle + type?: string + value?: React.ReactText variables?: ComponentVariablesInput + wrapper?: ShorthandValue +} + +export interface IInputState { + value?: React.ReactText } /** @@ -43,12 +49,12 @@ export interface IInputProps { * @accessibility * For good screen reader experience set aria-label or aria-labelledby attribute for input. * - * * Other considerations: * - if input is search, then use "role='search'" - * */ -class Input extends AutoControlledComponent, any> { +class Input extends AutoControlledComponent, IInputState> { + private inputDomElement: HTMLInputElement + static className = 'ui-input' static displayName = 'Input' @@ -57,10 +63,10 @@ class Input extends AutoControlledComponent, any> { /** An element type to render as (string or function). */ as: customPropTypes.as, - /** Additional CSS class name(s) to apply. */ + /** Additional CSS class name(s) to apply. */ className: PropTypes.string, - /** A property that will change the icon on the input and clear the input on click on Cancel */ + /** A property that will change the icon on the input and clear the input on click on Cancel. */ clearable: PropTypes.bool, /** The default value of the input. */ @@ -72,7 +78,10 @@ class Input extends AutoControlledComponent, any> { /** Optional Icon to display inside the Input. */ icon: customPropTypes.itemShorthand, - /** An input can be used inline with text */ + /** Shorthand for the input component. */ + input: customPropTypes.itemShorthand, + + /** An input can be used inline with text. */ inline: PropTypes.bool, /** @@ -83,9 +92,6 @@ class Input extends AutoControlledComponent, any> { */ onChange: PropTypes.func, - /** The HTML input type. */ - type: PropTypes.string, - /** * A custom render function the icon slot. * @@ -104,28 +110,99 @@ class Input extends AutoControlledComponent, any> { */ renderInput: PropTypes.func, - /** Additional CSS styles to apply to the component instance. */ + /** + * A custom render function the wrapper slot. + * + * @param { React.ReactType } Component - The computed component for this slot. + * @param { object } props - The computed props for this slot. + * @param { ReactNode | ReactNodeArray } children - The computed children for this slot. + */ + renderWrapper: PropTypes.func, + + /** Additional CSS styles to apply to the component instance. */ styles: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + /** The HTML input type. */ + type: PropTypes.string, + /** The value of the input. */ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), /** Override for theme site variables to allow modifications of component styling via themes. */ variables: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + + /** Shorthand for the wrapper component. */ + wrapper: customPropTypes.wrapperShorthand, } static defaultProps = { as: 'div', type: 'text', + wrapper: 'div', } static autoControlledProps = ['value'] - inputRef: any + renderComponent({ + ElementType, + classes, + rest: restProps, + styles, + variables, + }: IRenderResultConfig) { + const { className, input, renderIcon, renderInput, renderWrapper, type, wrapper } = this.props + const { value = '' } = this.state + const [htmlInputProps, rest] = partitionHTMLProps(restProps) + + return Slot.create(wrapper, { + defaultProps: { + as: ElementType, + className: cx(Input.className, className), + children: ( + <> + + (this.inputDomElement = inputDomElement as HTMLInputElement) + } + > + {Slot.createHTMLInput(input || type, { + defaultProps: { + ...htmlInputProps, + type, + value, + className: classes.input, + onChange: this.handleChange, + }, + render: renderInput, + })} + + {Icon.create(this.computeIcon(), { + defaultProps: { + styles: styles.icon, + variables: variables.icon, + }, + overrideProps: this.handleIconOverrides, + render: renderIcon, + })} + + ), + styles: styles.root, + ...rest, + }, + render: renderWrapper, + }) + } - state: any = { value: this.props.value || this.props.defaultValue || '' } + private handleIconOverrides = predefinedProps => ({ + onClick: (e: React.SyntheticEvent) => { + this.handleOnClear() + this.inputDomElement.focus() + _.invoke(predefinedProps, 'onClick', e, this.props) + }, + ...(predefinedProps.onClick && { tabIndex: '0' }), + }) - handleChange = e => { + private handleChange = (e: React.SyntheticEvent) => { const value = _.get(e, 'target.value') _.invoke(this.props, 'onChange', e, { ...this.props, value }) @@ -133,91 +210,21 @@ class Input extends AutoControlledComponent, any> { this.trySetState({ value }) } - handleChildOverrides = (child, defaultProps) => ({ - ...defaultProps, - ...child.props, - }) - - handleInputRef = c => (this.inputRef = c) - - handleOnClear = e => { - const { clearable } = this.props - - if (clearable) { + private handleOnClear = () => { + if (this.props.clearable) { this.trySetState({ value: '' }) } } - partitionProps = () => { - const { type } = this.props - const { value } = this.state - - const unhandled = getUnhandledProps(Input, this.props) - const [htmlInputProps, rest] = partitionHTMLProps(unhandled) - - return [ - { - ...htmlInputProps, - onChange: this.handleChange, - type, - value: value || '', - }, - rest, - ] - } - - computeIcon = () => { + private computeIcon = (): ShorthandValue => { const { clearable, icon } = this.props const { value } = this.state - if (clearable && value.length !== 0) { + if (clearable && (value as string).length !== 0) { return 'close' } - if (!_.isNil(icon)) return icon - - return null - } - - handleIconOverrides = predefinedProps => { - return { - onClick: e => { - this.handleOnClear(e) - - this.inputRef.focus() - _.invoke(predefinedProps, 'onClick', e, this.props) - }, - ...(predefinedProps.onClick && { tabIndex: '0' }), - } - } - - renderComponent({ ElementType, classes, styles, variables }) { - const { renderIcon, renderInput, type } = this.props - const [htmlInputProps, restProps] = this.partitionProps() - - const inputClasses = classes.input - - return ( - - {createHTMLInput(type, { - defaultProps: htmlInputProps, - overrideProps: { - className: inputClasses, - ref: this.handleInputRef, - }, - render: renderInput, - })} - {this.computeIcon() && - Icon.create(this.computeIcon(), { - defaultProps: { - styles: styles.icon, - variables: variables.icon, - }, - overrideProps: this.handleIconOverrides, - render: renderIcon, - })} - - ) + return icon || null } } diff --git a/src/components/Slot/Slot.tsx b/src/components/Slot/Slot.tsx index 0228b1fd61..48121b842c 100644 --- a/src/components/Slot/Slot.tsx +++ b/src/components/Slot/Slot.tsx @@ -1,7 +1,13 @@ import * as React from 'react' import * as PropTypes from 'prop-types' -import { customPropTypes, UIComponent, childrenExist, createShorthandFactory } from '../../lib' -import { Extendable } from '../../../types/utils' +import { + customPropTypes, + UIComponent, + childrenExist, + IRenderResultConfig, + createShorthand, +} from '../../lib' +import { Extendable, MapValueToProps, IProps } from '../../../types/utils' import { ComponentVariablesInput, ComponentPartStyle } from '../../../types/theme' export interface ISlotProps { @@ -12,12 +18,18 @@ export interface ISlotProps { variables?: ComponentVariablesInput } +export const createSlotFactory = (as: any, mapValueToProps: MapValueToProps) => ( + val, + options: IProps = {}, +) => { + options.defaultProps = { as, ...options.defaultProps } + return createShorthand(Slot, mapValueToProps, val, options) +} + /** * A Slot is a basic component (no default styles) */ class Slot extends UIComponent, any> { - static create: Function - static className = 'ui-slot' static displayName = 'Slot' @@ -43,7 +55,10 @@ class Slot extends UIComponent, any> { as: 'div', } - renderComponent({ ElementType, classes, rest }) { + static create = createSlotFactory(Slot.defaultProps.as, content => ({ content })) + static createHTMLInput = createSlotFactory('input', type => ({ type })) + + renderComponent({ ElementType, classes, rest }: IRenderResultConfig) { const { children, content } = this.props return ( @@ -54,6 +69,4 @@ class Slot extends UIComponent, any> { } } -Slot.create = createShorthandFactory(Slot, content => ({ content })) - export default Slot diff --git a/src/index.ts b/src/index.ts index 28c50034b1..de86865a17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,29 +5,23 @@ export { themes } export { default as Accordion } from './components/Accordion' export { default as Attachment } from './components/Attachment' export { default as Avatar } from './components/Avatar' -export { default as Button } from './components/Button' -export { ButtonGroup } from './components/Button' +export { default as Button, ButtonGroup } from './components/Button' export { default as Chat } from './components/Chat' -export { ChatItem } from './components/Chat' -export { ChatMessage } from './components/Chat' +export { ChatItem, ChatMessage } from './components/Chat' export { default as Divider } from './components/Divider' export { default as Grid } from './components/Grid' -export { default as Header } from './components/Header' -export { HeaderDescription } from './components/Header' +export { default as Header, HeaderDescription } from './components/Header' export { default as Icon } from './components/Icon' export { default as Image } from './components/Image' export { default as Input } from './components/Input' export { default as ItemLayout } from './components/ItemLayout' export { default as Label } from './components/Label' export { default as Layout } from './components/Layout' -export { default as List } from './components/List' -export { ListItem } from './components/List' -export { default as Menu } from './components/Menu' -export { MenuItem } from './components/Menu' +export { default as List, ListItem } from './components/List' +export { default as Menu, MenuItem } from './components/Menu' export { default as Provider } from './components/Provider' export { default as ProviderConsumer } from './components/Provider/ProviderConsumer' -export { default as RadioGroup } from './components/RadioGroup' -export { RadioGroupItem } from './components/RadioGroup' +export { default as RadioGroup, RadioGroupItem } from './components/RadioGroup' export { default as Segment } from './components/Segment' export { default as Status } from './components/Status' export { default as TabBehavior } from './lib/accessibility/Behaviors/Tab/tabBehavior' @@ -55,5 +49,4 @@ export { } from './lib/accessibility/Behaviors/Chat/chatMessageEnterEscBehavior' export { default as Portal } from './components/Portal' -export { default as Popup } from './components/Popup' -export { PopupContent } from './components/Popup' +export { default as Popup, PopupContent } from './components/Popup' diff --git a/src/lib/customPropTypes.tsx b/src/lib/customPropTypes.tsx index 82a15a7c7b..396c55c90c 100644 --- a/src/lib/customPropTypes.tsx +++ b/src/lib/customPropTypes.tsx @@ -336,6 +336,12 @@ export const multipleProp = possible => (props, propName, componentName) => { */ export const contentShorthand = every([disallow(['children']), PropTypes.node]) +export const wrapperShorthand = PropTypes.oneOfType([ + PropTypes.node, + PropTypes.object, + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.node, PropTypes.object])), +]) + /** * Item shorthand is a description of a component that can be a literal, * a props object, or an element. diff --git a/src/lib/factories.tsx b/src/lib/factories.tsx index 1204293550..eaaf09eb11 100644 --- a/src/lib/factories.tsx +++ b/src/lib/factories.tsx @@ -6,12 +6,9 @@ import { ShorthandPrimitive, ShorthandRenderFunction, ShorthandValue, + IProps, } from '../../types/utils' -interface IProps { - [key: string]: any -} - interface ICreateShorthandOptions { /** Override the default render implementation. */ render?: ShorthandRenderFunction diff --git a/src/themes/teams/components/Input/inputStyles.ts b/src/themes/teams/components/Input/inputStyles.ts index f37ad16842..37051a4b64 100644 --- a/src/themes/teams/components/Input/inputStyles.ts +++ b/src/themes/teams/components/Input/inputStyles.ts @@ -1,46 +1,38 @@ import { IComponentPartStylesInput, ICSSInJSStyle } from '../../../../../types/theme' import { IInputProps } from '../../../../components/Input/Input' +import { IInputVariables } from './inputVariables' +import { PositionProperty } from 'csstype' -const inputStyles: IComponentPartStylesInput = { - root: ({ props, variables }): ICSSInJSStyle => { - const { fluid } = props +const inputStyles: IComponentPartStylesInput = { + root: ({ props: p }): ICSSInJSStyle => ({ + display: 'inline-flex', + position: 'relative', + alignItems: 'center', + outline: 0, + ...(p.fluid && { width: '100%' }), + }), - return { - display: 'inline-flex', - position: 'relative', - alignItems: 'center', - outline: 0, - ...(fluid && { width: '100%' }), - } - }, + input: ({ props: p, variables: v }): ICSSInJSStyle => ({ + outline: 0, + border: 0, + borderRadius: v.borderRadius, + borderBottom: v.borderBottom, + color: v.fontColor, + backgroundColor: v.backgroundColor, + padding: v.inputPadding, + ...(p.fluid && { width: '100%' }), + ...(p.inline && { float: 'left' }), + ':focus': { + borderColor: v.inputFocusBorderColor, + borderRadius: v.inputFocusBorderRadius, + }, + }), - input: ({ props, variables }): ICSSInJSStyle => { - const { fluid, inline } = props - - return { - outline: 0, - border: 0, - borderRadius: variables.borderRadius, - borderBottom: variables.borderBottom, - color: variables.fontColor, - backgroundColor: variables.backgroundColor, - padding: variables.inputPadding, - ...(fluid && { width: '100%' }), - ...(inline && { float: 'left' }), - ':focus': { - borderColor: variables.inputFocusBorderColor, - borderRadius: variables.inputFocusBorderRadius, - }, - } - }, - - icon: ({ props, variables }): ICSSInJSStyle => { - return { - position: variables.iconPosition, - right: variables.iconRight, - outline: 0, - } - }, + icon: ({ variables: v }): ICSSInJSStyle => ({ + position: v.iconPosition as PositionProperty, + right: v.iconRight, + outline: 0, + }), } export default inputStyles diff --git a/src/themes/teams/components/Input/inputVariables.ts b/src/themes/teams/components/Input/inputVariables.ts index 060bd6b50d..5f65f7188d 100644 --- a/src/themes/teams/components/Input/inputVariables.ts +++ b/src/themes/teams/components/Input/inputVariables.ts @@ -1,21 +1,33 @@ import { pxToRem } from '../../../../lib' +export interface IInputVariables { + borderRadius: string + borderBottom: string + backgroundColor: string + fontColor: string + fontSize: string + iconPosition: string + iconRight: string + inputPadding: string + inputFocusBorderColor: string + inputFocusBorderRadius: string +} -export default (siteVars: any) => { - const vars: any = {} - - vars.borderRadius = `${pxToRem(3)}` - vars.borderBottom = `${pxToRem(2)} solid transparent` - vars.backgroundColor = siteVars.gray10 +const [_2px_asRem, _3px_asRem, _6px_asRem, _12px_asRem, _24px_asRem] = [2, 3, 6, 12, 24].map(v => + pxToRem(v), +) - vars.fontColor = siteVars.bodyColor - vars.fontSize = siteVars.fontSizes.medium +export default (siteVars): IInputVariables => ({ + borderRadius: _3px_asRem, + borderBottom: `${_2px_asRem} solid transparent`, + backgroundColor: siteVars.gray10, - vars.inputPadding = `${pxToRem(6)} ${pxToRem(24)} ${pxToRem(6)} ${pxToRem(12)}` - vars.inputFocusBorderColor = siteVars.brand - vars.inputFocusBorderRadius = `${pxToRem(3)} ${pxToRem(3)} ${pxToRem(2)} ${pxToRem(2)}` + fontColor: siteVars.bodyColor, + fontSize: siteVars.fontSizes.medium, - vars.iconPosition = 'absolute' - vars.iconRight = `${pxToRem(2)}` + iconPosition: 'absolute', + iconRight: _2px_asRem, - return vars -} + inputPadding: `${_6px_asRem} ${_24px_asRem} ${_6px_asRem} ${_12px_asRem}`, + inputFocusBorderColor: siteVars.brand, + inputFocusBorderRadius: `${_3px_asRem} ${_3px_asRem} ${_2px_asRem} ${_2px_asRem}`, +}) diff --git a/test/specs/commonTests/implementsShorthandProp.tsx b/test/specs/commonTests/implementsShorthandProp.tsx index 5873f2ca62..5a8fea42c4 100644 --- a/test/specs/commonTests/implementsShorthandProp.tsx +++ b/test/specs/commonTests/implementsShorthandProp.tsx @@ -1,5 +1,6 @@ import * as React from 'react' -import { mount } from './isConformant' +import { mount, ReactWrapper } from 'enzyme' +import { IProps } from '../../../types/utils' export type ShorthandTestOptions = { mapsValueToProp?: string @@ -18,34 +19,35 @@ export default Component => { const { mapsValueToProp } = options const { displayName } = ShorthandComponent + const checkPropsMatch = (props: IProps, matchedProps: IProps) => + Object.keys(matchedProps).every(propName => matchedProps[propName] === props[propName]) + + const expectContainsSingleShorthandElement = (wrapper: ReactWrapper, withProps: IProps) => + expect( + wrapper.findWhere( + node => node.type() === ShorthandComponent && checkPropsMatch(node.props(), withProps), + ).length, + ).toEqual(1) + + const expectShorthandPropsAreHandled = (withProps: IProps | string) => { + const props = { [shorthandProp]: withProps } + const matchedProps = + typeof withProps === 'string' ? { [mapsValueToProp]: withProps } : withProps + + expectContainsSingleShorthandElement(mount(), matchedProps) + } + describe(`shorthand property '${shorthandProp}' with default value of '${displayName}' component`, () => { test(`is defined`, () => { expect(Component.propTypes[shorthandProp]).toBeTruthy() }) test(`string value is handled as ${displayName}'s ${mapsValueToProp}`, () => { - const props = { [shorthandProp]: 'some value' } - const wrapper = mount() - - const shorthandComponentProps = wrapper.find(displayName).props() - expect(shorthandComponentProps[mapsValueToProp]).toEqual('some value') + expectShorthandPropsAreHandled('shorthand prop value') }) test(`object value is spread as ${displayName}'s props`, () => { - const ShorthandValue = { foo: 'foo value', bar: 'bar value' } - - const props = { [shorthandProp]: ShorthandValue } - const wrapper = mount() - - const shorthandComponentProps = wrapper.find(displayName).props() - - const allShorthandPropertiesArePassedToShorthandComponent = Object.keys( - ShorthandValue, - ).every( - propertyName => ShorthandValue[propertyName] === shorthandComponentProps[propertyName], - ) - - expect(allShorthandPropertiesArePassedToShorthandComponent).toBe(true) + expectShorthandPropsAreHandled({ foo: 'foo value', bar: 'bar value' }) }) }) } diff --git a/test/specs/commonTests/implementsWrapperProp.tsx b/test/specs/commonTests/implementsWrapperProp.tsx new file mode 100644 index 0000000000..3efbccaa78 --- /dev/null +++ b/test/specs/commonTests/implementsWrapperProp.tsx @@ -0,0 +1,38 @@ +import * as React from 'react' +import { mount, ReactWrapper } from 'enzyme' + +import Slot from 'src/components/Slot' +import { ShorthandValue } from 'utils' + +export interface ImplementsWrapperPropOptions { + wrapppedComponentSelector: any + wrappperComponentSelector?: any +} + +const implementsWrapperProp =

( + Component: React.ReactType

, + options: ImplementsWrapperPropOptions, +) => { + const { wrapppedComponentSelector, wrappperComponentSelector = Slot.defaultProps.as } = options + + const wrapperTests = (wrapper: ReactWrapper) => { + expect(wrapper.length).toBeGreaterThan(0) + expect(wrapper.find(wrapppedComponentSelector).length).toBeGreaterThan(0) + } + + describe('"wrapper" prop', () => { + it('wraps the component by default', () => { + wrapperTests(mount().find(wrappperComponentSelector)) + }) + + it('wraps the component with a custom element', () => { + wrapperTests(mount(} />).find('span')) + }) + + it('wraps the component with a custom element using "as" prop', () => { + wrapperTests(mount().find('p')) + }) + }) +} + +export default implementsWrapperProp diff --git a/test/specs/commonTests/index.ts b/test/specs/commonTests/index.ts index 9234d5fe96..cc420b2043 100644 --- a/test/specs/commonTests/index.ts +++ b/test/specs/commonTests/index.ts @@ -4,6 +4,7 @@ export { default as hasUIClassName } from './hasUIClassName' export * from './implementsClassNameProps' export { default as implementsCreateMethod } from './implementsCreateMethod' export { default as implementsShorthandProp } from './implementsShorthandProp' +export { default as implementsWrapperProp } from './implementsWrapperProp' export { default as handlesAccessibility, getRenderedAttribute } from './handlesAccessibility' diff --git a/test/specs/commonTests/isConformant.tsx b/test/specs/commonTests/isConformant.tsx index cae25f0cc5..e6a0f4ad52 100644 --- a/test/specs/commonTests/isConformant.tsx +++ b/test/specs/commonTests/isConformant.tsx @@ -1,6 +1,6 @@ import * as _ from 'lodash' import * as React from 'react' -import { mount as enzymeMount } from 'enzyme' +import { mount as enzymeMount, ReactWrapper } from 'enzyme' import * as ReactDOMServer from 'react-dom/server' import { ThemeProvider } from 'react-fela' @@ -32,7 +32,7 @@ export const mount = (node, options?) => { * @param {React.Component|Function} Component A component that should conform. * @param {Object} [options={}] * @param {Object} [options.eventTargets={}] Map of events and the child component to target. - * @param {boolean} [options.exportedAtTopLevel=false] Is this component exported as top level API + * @param {boolean} [options.exportedAtTopLevel=false] Is this component exported as top level API? * @param {boolean} [options.rendersPortal=false] Does this component render a Portal powered component? * @param {Object} [options.requiredProps={}] Props required to render Component without errors or warnings. */ @@ -48,7 +48,7 @@ export default (Component, options: IConformant = {}) => { const componentType = typeof Component // This is added because the component is mounted - const getComponent = wrapper => { + const getComponent = (wrapper: ReactWrapper) => { // FelaTheme wrapper and the component itself: let component = wrapper .childAt(0) diff --git a/test/specs/components/Input/Input-test.tsx b/test/specs/components/Input/Input-test.tsx index 12eb1675ab..595d13e1d8 100644 --- a/test/specs/components/Input/Input-test.tsx +++ b/test/specs/components/Input/Input-test.tsx @@ -1,30 +1,101 @@ import * as React from 'react' -import { isConformant, implementsShorthandProp } from 'test/specs/commonTests' +import { mount, ReactWrapper } from 'enzyme' +import { + isConformant, + implementsShorthandProp, + implementsWrapperProp, +} from 'test/specs/commonTests' import Input from 'src/components/Input/Input' import Icon from 'src/components/Icon/Icon' -import { mountWithProvider } from 'test/utils' +import Slot from 'src/components/Slot' + +const testValue = 'test value' +const htmlInputAttrs = ['id', 'name', 'pattern', 'placeholder', 'type', 'value'] + +const getInputAttrsObject = (testValue: string) => + htmlInputAttrs.reduce((acc, attr) => { + acc[attr] = testValue + return acc + }, {}) + +const getInputDomNode = (inputComp: ReactWrapper): HTMLInputElement => + inputComp.find('input').getDOMNode() as HTMLInputElement + +const setUserInputValue = (inputComp: ReactWrapper, value: string) => { + inputComp.find('input').simulate('change', { target: { value } }) +} describe('Input', () => { - isConformant(Input, { - eventTargets: { - onChange: 'input', - }, + describe('conformance', () => { + isConformant(Input, { + eventTargets: { onChange: 'input' }, + }) }) + + implementsShorthandProp(Input)('input', Slot, { mapsValueToProp: 'type' }) implementsShorthandProp(Input)('icon', Icon, { mapsValueToProp: 'name' }) - describe('input', () => { - it('renders a text by default', () => { - const input = mountWithProvider().find('input[type="text"]') - expect(input).not.toBe(undefined) + describe('wrapper', () => { + implementsShorthandProp(Input)('wrapper', Slot, { mapsValueToProp: 'content' }) + implementsWrapperProp(Input, { wrapppedComponentSelector: 'input' }) + }) + + it('renders a text by default', () => { + const inputComp = mount() + expect(inputComp.find('input[type="text"]').length).toBeGreaterThan(0) + }) + + describe('input related HTML attribute', () => { + const inputAttrsObject = getInputAttrsObject(testValue) + const domNode = getInputDomNode(mount()) + + htmlInputAttrs.forEach(attr => { + it(`'${attr}' is set correctly to '${testValue}'`, () => { + expect(domNode[attr]).toEqual(testValue) + }) + }) + }) + + describe('auto-controlled', () => { + it('sets input value from user when the value prop is not set (non-controlled mode)', () => { + const inputComp = mount() + const domNode = getInputDomNode(inputComp) + setUserInputValue(inputComp, testValue) + + expect(domNode.value).toEqual(testValue) + }) + + it('cannot set input value from user when the value prop is already set (controlled mode)', () => { + const controlledInputValue = 'controlled input value' + const inputComp = mount() + const domNode = getInputDomNode(inputComp) + setUserInputValue(inputComp, testValue) + + expect(domNode.value).toEqual(controlledInputValue) }) }) describe('icon', () => { it('creates the Icon component when the icon shorthand is provided', () => { - const input = mountWithProvider().find('Icon[name="close"]') - expect(input).not.toBe(undefined) + const inputComp = mount() + expect(inputComp.find('Icon[name="search"]').length).toBeGreaterThan(0) + }) + + it('creates the "close" Icon component when the clearable prop is provided and the input has content, removes the icon and value when the icon is clicked', () => { + const inputComp = mount() + const domNode = getInputDomNode(inputComp) + setUserInputValue(inputComp, testValue) // user types into the input + const iconComp = inputComp.find('Icon[name="close"]') + + expect(domNode.value).toEqual(testValue) // input value is the one typed by the user + expect(iconComp.length).toBeGreaterThan(0) // the 'x' icon appears + + iconComp.simulate('click') // user clicks on 'x' icon + + expect(domNode.value).toEqual('') // input value gets cleared + expect(inputComp.find('Icon[name="close"]').length).toEqual(0) // the 'x' icon disappears }) }) }) diff --git a/test/specs/components/Slot/Slot-test.ts b/test/specs/components/Slot/Slot-test.ts index 6354dcac11..1241df7774 100644 --- a/test/specs/components/Slot/Slot-test.ts +++ b/test/specs/components/Slot/Slot-test.ts @@ -1,6 +1,31 @@ -import { isConformant } from 'test/specs/commonTests' +import { mount } from 'enzyme' + import Slot from 'src/components/Slot' +import { isConformant } from 'test/specs/commonTests' describe('Slot', () => { - isConformant(Slot, { exportedAtTopLevel: false }) + const createSlot = (factoryFn: Function, val, options?) => + mount(factoryFn(val, options)).find(Slot) + + describe('is conformant', () => { + isConformant(Slot, { exportedAtTopLevel: false }) + }) + + it(`create renders a ${Slot.defaultProps.as} element with content prop`, () => { + const testContent = 'test content' + const slot = createSlot(Slot.create, testContent) + const { as, content } = slot.props() + + expect(as).toEqual(Slot.defaultProps.as) + expect(content).toEqual(testContent) + }) + + it(`createHTMLInput renders an input element with type prop`, () => { + const testType = 'test type' + const slot = createSlot(Slot.createHTMLInput, testType) + const { as, type } = slot.props() + + expect(as).toEqual('input') + expect(type).toEqual(testType) + }) }) diff --git a/yarn.lock b/yarn.lock index 61ca87d589..e61fac43ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -52,11 +52,22 @@ dependencies: any-observable "^0.3.0" +"@types/cheerio@*": + version "0.22.9" + resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.9.tgz#b5990152604c2ada749b7f88cab3476f21f39d7b" + "@types/classnames@^2.2.4": version "2.2.4" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.4.tgz#d3ee9ebf714aa34006707b8f4a58fd46b642305a" integrity sha512-UWUmNYhaIGDx8Kv0NSqFRwP6HWnBMXam4nBacOrjIiPBKKCdWMCe77+Nbn6rI9+Us9c+BhE26u84xeYQv2bKeA== +"@types/enzyme@^3.1.14": + version "3.1.14" + resolved "https://registry.yarnpkg.com/@types/enzyme/-/enzyme-3.1.14.tgz#379c26205f6e0e272f3a51d6bbdd50071a9d03a6" + dependencies: + "@types/cheerio" "*" + "@types/react" "*" + "@types/faker@^4.1.3": version "4.1.3" resolved "https://registry.yarnpkg.com/@types/faker/-/faker-4.1.3.tgz#544398268b37248300dc428316daa6a7521bbd19"