Repository: zeit-ui/react
Pull request #118 (2020-04-21 04:20:36)

test(input): add testcase
## PR Checklist

- [x] Fix linting errors
- [x] Label has been added


## Change information





Patch:
diff --git a/components/input/input-icon-clear.tsx b/components/input/input-icon-clear.tsx
index 05f4d9b..73168ac 100644
--- a/components/input/input-icon-clear.tsx
+++ b/components/input/input-icon-clear.tsx
@@ -22,7 +22,7 @@ const InputIconClear: React.FC<Props> = ({
     onClick && onClick(event)
   }
   return (
-    <div onClick={clickHandler} className={`${visibale ? 'visibale' : ''}`}>
+    <div onClick={clickHandler} className={`clear-icon ${visibale ? 'visibale' : ''}`}>
       <svg viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
         strokeLinejoin="round" fill="none" shapeRendering="geometricPrecision">
         <path d="M18 6L6 18" />
@@ -30,7 +30,7 @@ const InputIconClear: React.FC<Props> = ({
       </svg>
   
       <style jsx>{`
-        div {
+        .clear-icon {
           padding: 0 ${theme.layout.gapHalf};
           margin: 0;
           display: inline-flex;
@@ -49,7 +49,7 @@ const InputIconClear: React.FC<Props> = ({
           opacity: 1;
         }
         
-        div:hover {
+        .clear-icon:hover {
           color: ${disabled ? theme.palette.accents_3 : theme.palette.foreground};
         }
         
diff --git a/components/input/input.tsx b/components/input/input.tsx
index bc133b4..74bf0b6 100644
--- a/components/input/input.tsx
+++ b/components/input/input.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from 'react'
+import React, { useEffect, useMemo, useRef, useState } from 'react'
 import useTheme from '../styles/use-theme'
 import InputLabel from './input-label'
 import InputBlockLabel from './input-block-label'
@@ -46,12 +46,24 @@ const defaultProps = {
 type NativeAttrs = Omit<React.InputHTMLAttributes<any>, keyof Props>
 export type InputProps = Props & typeof defaultProps & NativeAttrs
 
+const simulateChangeEvent = (
+  el: HTMLInputElement,
+  event: React.MouseEvent<HTMLDivElement>
+): React.ChangeEvent<HTMLInputElement> => {
+  return {
+    ...event,
+    target: el,
+    currentTarget: el,
+  }
+}
+
 const Input: React.FC<React.PropsWithChildren<InputProps>> = ({
   placeholder, label, labelRight, size, status, disabled,
   icon, iconRight, initialValue, onChange, readOnly, value,
   onClearClick, clearable, width, className, onBlur, onFocus,
   autoComplete, children, ...props
 }) => {
+  const ref = useRef<HTMLInputElement>(null)
   const theme = useTheme()
   const [selfValue, setSelfValue] = useState<string>(initialValue)
   const [hover, setHover] = useState<boolean>(false)
@@ -78,6 +90,11 @@ const Input: React.FC<React.PropsWithChildren<InputProps>> = ({
   const clearHandler = (event: React.MouseEvent<HTMLDivElement>) => {
     setSelfValue('')
     onClearClick && onClearClick(event)
+    if (!ref.current) return
+    
+    const changeEvent = simulateChangeEvent(ref.current, event)
+    changeEvent.target.value = ''
+    onChange && onChange(changeEvent)
   }
   
   const focusHandler = (e: React.FocusEvent<HTMLInputElement>) => {
@@ -101,7 +118,8 @@ const Input: React.FC<React.PropsWithChildren<InputProps>> = ({
         {label && <InputLabel fontSize={fontSize}>{label}</InputLabel>}
         <div className={`input-wrapper ${hover ? 'hover' : ''} ${disabled ? 'disabled' : ''} ${labelClasses}`}>
           {icon && <InputIcon icon={icon} ratio={heightRatio} />}
-          <input type="text" className={`${disabled ? 'disabled' : ''} ${iconClasses}`}
+          <input type="text" ref={ref}
+            className={`${disabled ? 'disabled' : ''} ${iconClasses}`}
             value={selfValue}
             placeholder={placeholder}
             disabled={disabled}
diff --git a/components/modal/modal-context.ts b/components/modal/modal-context.ts
index 0b6db51..fb97453 100644
--- a/components/modal/modal-context.ts
+++ b/components/modal/modal-context.ts
@@ -2,12 +2,10 @@ import React from 'react'
 
 export interface ModalConfig {
   close: () => void
-  open: () => void
 }
 
 const defaultContext = {
   close: () => {},
-  open: () => {},
 }
 
 export const ModalContext = React.createContext<ModalConfig>(defaultContext)
diff --git a/components/modal/modal.tsx b/components/modal/modal.tsx
index 015210d..ba9d53e 100644
--- a/components/modal/modal.tsx
+++ b/components/modal/modal.tsx
@@ -14,16 +14,13 @@ import useBodyScroll from '../utils/use-body-scroll'
 
 interface Props {
   disableBackdropClick?: boolean
-  onClose: () => void
-  onOpen: () => void
+  onClose?: () => void
+  onOpen?: () => void
   open?: boolean
 }
 
 const defaultProps = {
   disableBackdropClick: false,
-  onClose: () => {},
-  onOpen: () => {},
-  open: false,
 }
 
 type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
@@ -34,7 +31,7 @@ const Modal: React.FC<React.PropsWithChildren<ModalProps>> = React.memo(({
 }) => {
   const portal = usePortal('modal')
   const [, setBodyHidden] = useBodyScroll()
-  const [visible, setVisible] = useState<boolean>(open)
+  const [visible, setVisible] = useState<boolean>(false)
   const [withoutActionsChildren, ActionsChildren] = pickChild(children, ModalAction)
   const hasActions = ActionsChildren && React.Children.count(ActionsChildren) > 0
 
@@ -42,17 +39,19 @@ const Modal: React.FC<React.PropsWithChildren<ModalProps>> = React.memo(({
     setVisible(false)
     onClose && onClose()
   }
-  const openModal = () => {
-    setVisible(true)
-    
-    onOpen && onOpen()
-  }
 
   useEffect(() => {
+    if (open === undefined) return
     setVisible(open)
     setBodyHidden(open)
+  
+    if (open) {
+      onOpen && onOpen()
+    } else {
+      onClose && onClose()
+    }
   }, [open])
-
+  
   const closeFromBackdrop = () => {
     if (disableBackdropClick && hasActions) return
     closeModal()
@@ -60,7 +59,6 @@ const Modal: React.FC<React.PropsWithChildren<ModalProps>> = React.memo(({
 
   const modalConfig: ModalConfig = useMemo(() => ({
     close: closeModal,
-    open: openModal,
   }), [])
   
   if (!portal) return null


Test patch:
diff --git a/components/input/__tests__/__snapshots__/index.test.tsx.snap b/components/input/__tests__/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000..9c907be
--- /dev/null
+++ b/components/input/__tests__/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,1031 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Input should be work with icon 1`] = `
+"<div><div class=\\"with-label\\"><div class=\\"input-container \\"><div class=\\"input-wrapper   \\"><span class=\\"input-icon\\"><span>test-icon</span><style>
+        .input-icon {
+          box-sizing: content-box;
+          display: flex;
+          width: calc(1.687 * 16pt * .42);
+          height: calc(1.687 * 16pt * .42);
+          align-items: center;
+          vertical-align: center;
+          pointer-events: none;
+          margin: 0;
+          padding: 0 calc(1.687 * 16pt * .3);
+          line-height: 1;
+          position: relative;
+        }
+      </style></span><input type=\\"text\\" class=\\" left-icon\\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"></div></div><style>
+        .with-label {
+          display: inline-block;
+          width: initial;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: initial;
+          height: calc(1.687 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #666;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: .875rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div><div class=\\"with-label\\"><div class=\\"input-container \\"><div class=\\"input-wrapper   \\"><input type=\\"text\\" class=\\" right-icon\\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"><span class=\\"input-icon\\"><span>test-icon</span><style>
+        .input-icon {
+          box-sizing: content-box;
+          display: flex;
+          width: calc(1.687 * 16pt * .42);
+          height: calc(1.687 * 16pt * .42);
+          align-items: center;
+          vertical-align: center;
+          pointer-events: none;
+          margin: 0;
+          padding: 0 calc(1.687 * 16pt * .3);
+          line-height: 1;
+          position: relative;
+        }
+      </style></span></div></div><style>
+        .with-label {
+          display: inline-block;
+          width: initial;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: initial;
+          height: calc(1.687 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #666;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: .875rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div></div>"
+`;
+
+exports[`Input should be work with label 1`] = `
+"<div><div class=\\"with-label\\"><div class=\\"input-container \\"><span class=\\"\\">label<style>
+        span {
+          display: inline-flex;
+          width: initial;
+          height: 100%;
+          align-items: center;
+          pointer-events: none;
+          margin: 0;
+          padding: 0 8pt;
+          color: #888;
+          background-color: #fafafa;
+          border-top-left-radius: 5px;
+          border-bottom-left-radius: 5px;
+          border-top: 1px solid #eaeaea;
+          border-left: 1px solid #eaeaea;
+          border-bottom: 1px solid #eaeaea;
+          font-size: .875rem;
+          line-height: 1;
+        }
+        
+        span.right {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+          border-top-right-radius: 5px;
+          border-bottom-right-radius: 5px;
+          border-left: 0;
+          border-right: 1px solid #eaeaea;
+        }
+      </style></span><div class=\\"input-wrapper   left-label\\"><input type=\\"text\\" class=\\" \\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"></div></div><style>
+        .with-label {
+          display: inline-block;
+          width: initial;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: initial;
+          height: calc(1.687 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #666;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: .875rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div><div class=\\"with-label\\"><div class=\\"input-container \\"><div class=\\"input-wrapper   right-label\\"><input type=\\"text\\" class=\\" \\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"></div><span class=\\"right\\">label<style>
+        span {
+          display: inline-flex;
+          width: initial;
+          height: 100%;
+          align-items: center;
+          pointer-events: none;
+          margin: 0;
+          padding: 0 8pt;
+          color: #888;
+          background-color: #fafafa;
+          border-top-left-radius: 5px;
+          border-bottom-left-radius: 5px;
+          border-top: 1px solid #eaeaea;
+          border-left: 1px solid #eaeaea;
+          border-bottom: 1px solid #eaeaea;
+          font-size: .875rem;
+          line-height: 1;
+        }
+        
+        span.right {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+          border-top-right-radius: 5px;
+          border-bottom-right-radius: 5px;
+          border-left: 0;
+          border-right: 1px solid #eaeaea;
+        }
+      </style></span></div><style>
+        .with-label {
+          display: inline-block;
+          width: initial;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: initial;
+          height: calc(1.687 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #666;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: .875rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div><div class=\\"with-label\\"><label><span>Block Label</span><style>
+        label {
+          display: block;
+          font-weight: normal;
+          color: #444;
+          padding: 0 0 0 1px;
+          margin-bottom: 8pt;
+          font-size: 1rem;
+          line-height: 1.5;
+        }
+        
+        label > :global(*:first-child) {
+          margin-top: 0;
+        }
+        
+        label > :global(*:last-child) {
+          margin-bottom: 0;
+        }
+      </style></label><div class=\\"input-container \\"><div class=\\"input-wrapper   \\"><input type=\\"text\\" class=\\" \\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"></div></div><style>
+        .with-label {
+          display: inline-block;
+          width: initial;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: initial;
+          height: calc(1.687 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #666;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: .875rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div></div>"
+`;
+
+exports[`Input should work with different sizes 1`] = `
+"<div><div class=\\"with-label\\"><div class=\\"input-container \\"><div class=\\"input-wrapper   \\"><input type=\\"text\\" class=\\" \\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"></div></div><style>
+        .with-label {
+          display: inline-block;
+          width: initial;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: initial;
+          height: calc(1.313 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #666;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: .75rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div><div class=\\"with-label\\"><div class=\\"input-container \\"><div class=\\"input-wrapper   \\"><input type=\\"text\\" class=\\" \\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"></div></div><style>
+        .with-label {
+          display: inline-block;
+          width: initial;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: initial;
+          height: calc(1.5 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #666;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: .75rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div><div class=\\"with-label\\"><div class=\\"input-container \\"><div class=\\"input-wrapper   \\"><input type=\\"text\\" class=\\" \\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"></div></div><style>
+        .with-label {
+          display: inline-block;
+          width: initial;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: initial;
+          height: calc(1.875 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #666;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: 1rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div><div class=\\"with-label\\"><div class=\\"input-container \\"><div class=\\"input-wrapper   \\"><input type=\\"text\\" class=\\" \\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"></div></div><style>
+        .with-label {
+          display: inline-block;
+          width: 50%;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: 50%;
+          height: calc(1.687 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #666;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: .875rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div></div>"
+`;
+
+exports[`Input should work with different status 1`] = `
+"<div><div class=\\"with-label\\"><div class=\\"input-container \\"><div class=\\"input-wrapper   \\"><input type=\\"text\\" class=\\" \\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"></div></div><style>
+        .with-label {
+          display: inline-block;
+          width: initial;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: initial;
+          height: calc(1.687 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #666;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #666;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: .875rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div><div class=\\"with-label\\"><div class=\\"input-container \\"><div class=\\"input-wrapper   \\"><input type=\\"text\\" class=\\" \\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"></div></div><style>
+        .with-label {
+          display: inline-block;
+          width: initial;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: initial;
+          height: calc(1.687 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #3291ff;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #0070f3;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: .875rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div><div class=\\"with-label\\"><div class=\\"input-container \\"><div class=\\"input-wrapper   \\"><input type=\\"text\\" class=\\" \\" placeholder=\\"\\" autocomplete=\\"off\\" value=\\"\\"></div></div><style>
+        .with-label {
+          display: inline-block;
+          width: initial;
+          box-sizing: border-box;
+          -webkit-box-align: center;
+        }
+        
+        .input-container {
+          display: inline-flex;
+          align-items: center;
+          width: initial;
+          height: calc(1.687 * 16pt);
+        }
+
+        .input-wrapper {
+          display: inline-flex;
+          vertical-align: middle;
+          align-items: center;
+          height: 100%;
+          flex: 1;
+          user-select: none;
+          border-radius: 5px;
+          border: 1px solid #f7b955;
+          transition: border 0.2s ease 0s, color 0.2s ease 0s;
+        }
+        
+        .input-wrapper.left-label {
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0;
+        }
+        
+        .input-wrapper.right-label {
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0;
+        }
+        
+        .input-wrapper.disabled {
+          background-color: #fafafa;
+          border-color: #eaeaea;
+          cursor: not-allowed;
+        }
+        
+        input.disabled {
+          cursor: not-allowed;
+        }
+        
+        .input-wrapper.hover {
+          border-color: #f5a623;
+        }
+
+        input {
+          margin: 4px 10px;
+          padding: 0;
+          box-shadow: none;
+          font-size: .875rem;
+          background-color: transparent;
+          border: none;
+          color: #000;
+          outline: none;
+          border-radius: 0;
+          width: 100%;
+          -webkit-appearance: none;
+        }
+        
+        input.left-icon {
+          margin-left: 0;
+        }
+        
+        input.right-icon {
+          margin-right: 0;
+        }
+        
+        ::placeholder, ::-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
+          color: #999;
+        }
+      </style></div></div>"
+`;
diff --git a/components/input/__tests__/index.test.tsx b/components/input/__tests__/index.test.tsx
new file mode 100644
index 0000000..5220bf7
--- /dev/null
+++ b/components/input/__tests__/index.test.tsx
@@ -0,0 +1,125 @@
+import React from 'react'
+import { mount } from 'enzyme'
+import { Input } from 'components'
+import { nativeEvent } from 'tests/utils'
+
+describe('Input', () => {
+  it('should render correctly', () => {
+    const wrapper = mount(<Input placeholder="placeholder" />)
+    expect(() => wrapper.unmount()).not.toThrow()
+  })
+  
+  it('should work with different sizes', () => {
+    const wrapper = mount(
+      <div>
+        <Input size="mini" />
+        <Input size="small" />
+        <Input size="large" />
+        <Input width="50%" />
+      </div>
+    )
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+  
+  it('should work with different status', () => {
+    const wrapper = mount(
+      <div>
+        <Input status="secondary" />
+        <Input status="success" />
+        <Input status="warning" />
+      </div>
+    )
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+  
+  it('should be work with label', () => {
+    const wrapper = mount(
+      <div>
+        <Input label="label" />
+        <Input labelRight="label" />
+        <Input><span>Block Label</span></Input>
+      </div>
+    )
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+  it('should be work with icon', () => {
+    const wrapper = mount(
+      <div>
+        <Input icon={<span>test-icon</span>} />
+        <Input iconRight={<span>test-icon</span>} />
+      </div>
+    )
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+  
+  it('should set input from value', () => {
+    let wrapper = mount(<Input initialValue="test" />)
+    let input = wrapper.find('input').getDOMNode() as HTMLInputElement
+    expect(input.value).toEqual('test')
+  
+    wrapper = mount(<Input value="test2" />)
+    input = wrapper.find('input').getDOMNode() as HTMLInputElement
+    expect(input.value).toEqual('test2')
+  
+    wrapper.setProps({ value: 'test3' })
+    input = wrapper.find('input').getDOMNode() as HTMLInputElement
+    expect(input.value).toEqual('test3')
+  })
+  
+  it('should trigger event when input changed', () => {
+    let value = ''
+    const callback = jest.fn()
+      .mockImplementation((e: React.ChangeEvent<HTMLInputElement>) => value = e.target.value)
+    const wrapper = mount(<Input onChange={callback} />)
+    wrapper.find('input').at(0)
+      .simulate('change', { target: { value: 'test' } })
+    expect(callback).toHaveBeenCalled()
+    expect(value).toEqual('test')
+  })
+  
+  it('should ignore event when input disabled', () => {
+    const callback = jest.fn()
+    const wrapper = mount(<Input onChange={callback} disabled />)
+    wrapper.find('input').at(0)
+      .simulate('change', { target: { value: 'test' } })
+    expect(callback).not.toHaveBeenCalled()
+  })
+  
+  it('should ignore event when input readonly', () => {
+    const callback = jest.fn()
+    const wrapper = mount(<Input onChange={callback} readOnly />)
+    wrapper.find('input').at(0)
+      .simulate('change', { target: { value: 'test' } })
+    expect(callback).not.toHaveBeenCalled()
+  })
+  
+  it('should clear text', () => {
+    let value = ''
+    const callback = jest.fn()
+      .mockImplementation((e: React.ChangeEvent<HTMLInputElement>) => value = e.target.value)
+    const clearHandler = jest.fn()
+    const wrapper = mount(<Input onChange={callback} clearable onClearClick={clearHandler} />)
+    
+    wrapper.find('input').at(0)
+      .simulate('change', { target: { value: 'test' } })
+    expect(callback).toHaveBeenCalled()
+    expect(value).toEqual('test')
+  
+    wrapper.find('.clear-icon').at(0)
+      .simulate('click', nativeEvent)
+    expect(clearHandler).toHaveBeenCalled()
+    expect(value).toEqual('')
+  })
+  
+  it('should trigger focus correctly', () => {
+    const focus = jest.fn()
+    const blur = jest.fn()
+    const wrapper = mount(<Input onFocus={focus} onBlur={blur} />)
+    
+    const input = wrapper.find('input').at(0)
+    input.simulate('focus')
+    expect(focus).toHaveBeenCalled()
+    input.simulate('blur')
+    expect(blur).toHaveBeenCalled()
+  })
+})
diff --git a/components/input/__tests__/use-input.test.tsx b/components/input/__tests__/use-input.test.tsx
new file mode 100644
index 0000000..86b9934
--- /dev/null
+++ b/components/input/__tests__/use-input.test.tsx
@@ -0,0 +1,56 @@
+import React, { useEffect } from 'react'
+import { mount } from 'enzyme'
+import { Input, useInput } from 'components'
+
+describe('UseInput', () => {
+  it('should follow change with use-input', () => {
+    let log = ''
+    const logSpy = jest.spyOn(console, 'log')
+      .mockImplementation(msg => log = msg)
+    const MockInput: React.FC<{ value?: string }> = ({ value }) => {
+      const { state, setState, bindings } = useInput('')
+      useEffect(() => {
+        if (value) setState(value)
+      }, [value])
+      useEffect(() => {
+        if (state) console.log(state)
+      }, [state])
+      return <Input {...bindings} />
+    }
+    
+    const wrapper = mount(<MockInput />)
+    wrapper.setProps({ value: 'test' })
+    const input = wrapper.find('input').at(0).getDOMNode() as HTMLInputElement
+    
+    expect(input.value).toEqual('test')
+    expect(log).toContain('test')
+  
+    log = ''
+    wrapper.find('input').at(0)
+      .simulate('change', { target: { value: 'test-change' }})
+    expect(log).toContain('test-change')
+    logSpy.mockRestore()
+  })
+  
+  it('should follow change with use-input', () => {
+    const MockInput: React.FC<{ value?: string, resetValue?: boolean }> = ({ value, resetValue }) => {
+      const { reset, setState, bindings } = useInput('')
+      useEffect(() => {
+        if (value) setState(value)
+      }, [value])
+      useEffect(() => {
+        if (resetValue) reset()
+      }, [resetValue])
+      return <Input {...bindings} />
+    }
+    
+    const wrapper = mount(<MockInput />)
+    wrapper.setProps({ value: 'test' })
+    let input = wrapper.find('input').at(0).getDOMNode() as HTMLInputElement
+    expect(input.value).toEqual('test')
+  
+    wrapper.setProps({ resetValue: true })
+    input = wrapper.find('input').at(0).getDOMNode() as HTMLInputElement
+    expect(input.value).toEqual('')
+  })
+})
diff --git a/components/keyboard/__tests__/__snapshots__/index.test.tsx.snap b/components/keyboard/__tests__/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000..4f84775
--- /dev/null
+++ b/components/keyboard/__tests__/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,336 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Keyboard should render correctly 1`] = `
+"<kbd class=\\"\\"><span>F</span><style>
+        kbd {
+          width: fit-content;
+          line-height: 2em;
+          text-align: center;
+          display: inline-block;
+          color: #666;
+          background-color: #fafafa;
+          font-family: -apple-system, BlinkMacSystemFont, \\"Segoe UI\\", \\"Roboto\\", \\"Oxygen\\", \\"Ubuntu\\", \\"Cantarell\\", \\"Fira Sans\\", \\"Droid Sans\\", \\"Helvetica Neue\\", sans-serif;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          padding: 0 4pt;
+          min-width: calc(1.5 * 16pt);
+          font-size: 0.875rem;
+        }
+        
+        kbd + kbd {
+          margin-left: 4pt;
+        }
+        
+        span {
+          line-height: 2em;
+          font-size: 0.875rem;
+          text-align: center;
+        }
+        
+        span + span {
+          margin-left: 4pt;
+        }
+      </style></kbd>"
+`;
+
+exports[`Keyboard should work with modifiers 1`] = `
+"<div><kbd class=\\"\\"><span>⌘</span><span>F</span><style>
+        kbd {
+          width: fit-content;
+          line-height: 2em;
+          text-align: center;
+          display: inline-block;
+          color: #666;
+          background-color: #fafafa;
+          font-family: -apple-system, BlinkMacSystemFont, \\"Segoe UI\\", \\"Roboto\\", \\"Oxygen\\", \\"Ubuntu\\", \\"Cantarell\\", \\"Fira Sans\\", \\"Droid Sans\\", \\"Helvetica Neue\\", sans-serif;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          padding: 0 4pt;
+          min-width: calc(1.5 * 16pt);
+          font-size: 0.875rem;
+        }
+        
+        kbd + kbd {
+          margin-left: 4pt;
+        }
+        
+        span {
+          line-height: 2em;
+          font-size: 0.875rem;
+          text-align: center;
+        }
+        
+        span + span {
+          margin-left: 4pt;
+        }
+      </style></kbd><kbd class=\\"\\"><span>⇧</span><span>F</span><style>
+        kbd {
+          width: fit-content;
+          line-height: 2em;
+          text-align: center;
+          display: inline-block;
+          color: #666;
+          background-color: #fafafa;
+          font-family: -apple-system, BlinkMacSystemFont, \\"Segoe UI\\", \\"Roboto\\", \\"Oxygen\\", \\"Ubuntu\\", \\"Cantarell\\", \\"Fira Sans\\", \\"Droid Sans\\", \\"Helvetica Neue\\", sans-serif;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          padding: 0 4pt;
+          min-width: calc(1.5 * 16pt);
+          font-size: 0.875rem;
+        }
+        
+        kbd + kbd {
+          margin-left: 4pt;
+        }
+        
+        span {
+          line-height: 2em;
+          font-size: 0.875rem;
+          text-align: center;
+        }
+        
+        span + span {
+          margin-left: 4pt;
+        }
+      </style></kbd><kbd class=\\"\\"><span>⌥</span><span>F</span><style>
+        kbd {
+          width: fit-content;
+          line-height: 2em;
+          text-align: center;
+          display: inline-block;
+          color: #666;
+          background-color: #fafafa;
+          font-family: -apple-system, BlinkMacSystemFont, \\"Segoe UI\\", \\"Roboto\\", \\"Oxygen\\", \\"Ubuntu\\", \\"Cantarell\\", \\"Fira Sans\\", \\"Droid Sans\\", \\"Helvetica Neue\\", sans-serif;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          padding: 0 4pt;
+          min-width: calc(1.5 * 16pt);
+          font-size: 0.875rem;
+        }
+        
+        kbd + kbd {
+          margin-left: 4pt;
+        }
+        
+        span {
+          line-height: 2em;
+          font-size: 0.875rem;
+          text-align: center;
+        }
+        
+        span + span {
+          margin-left: 4pt;
+        }
+      </style></kbd><kbd class=\\"\\"><span>⌃</span><span>F</span><style>
+        kbd {
+          width: fit-content;
+          line-height: 2em;
+          text-align: center;
+          display: inline-block;
+          color: #666;
+          background-color: #fafafa;
+          font-family: -apple-system, BlinkMacSystemFont, \\"Segoe UI\\", \\"Roboto\\", \\"Oxygen\\", \\"Ubuntu\\", \\"Cantarell\\", \\"Fira Sans\\", \\"Droid Sans\\", \\"Helvetica Neue\\", sans-serif;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          padding: 0 4pt;
+          min-width: calc(1.5 * 16pt);
+          font-size: 0.875rem;
+        }
+        
+        kbd + kbd {
+          margin-left: 4pt;
+        }
+        
+        span {
+          line-height: 2em;
+          font-size: 0.875rem;
+          text-align: center;
+        }
+        
+        span + span {
+          margin-left: 4pt;
+        }
+      </style></kbd></div>"
+`;
+
+exports[`Keyboard should work with small size 1`] = `
+initialize {
+  "0": Object {
+    "attribs": Object {
+      "class": "",
+    },
+    "children": Array [
+      Object {
+        "attribs": Object {},
+        "children": Array [
+          Object {
+            "data": "F",
+            "next": null,
+            "parent": [Circular],
+            "prev": null,
+            "type": "text",
+          },
+        ],
+        "name": "span",
+        "namespace": "http://www.w3.org/1999/xhtml",
+        "next": Object {
+          "attribs": Object {},
+          "children": Array [
+            Object {
+              "data": "
+        kbd {
+          width: fit-content;
+          line-height: 2em;
+          text-align: center;
+          display: inline-block;
+          color: #666;
+          background-color: #fafafa;
+          font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, &quot;Roboto&quot;, &quot;Oxygen&quot;, &quot;Ubuntu&quot;, &quot;Cantarell&quot;, &quot;Fira Sans&quot;, &quot;Droid Sans&quot;, &quot;Helvetica Neue&quot;, sans-serif;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          padding: 0 0;
+          min-width: 16pt;
+          font-size: .75rem;
+        }
+        
+        kbd + kbd {
+          margin-left: 4pt;
+        }
+        
+        span {
+          line-height: 2em;
+          font-size: 0.875rem;
+          text-align: center;
+        }
+        
+        span + span {
+          margin-left: 4pt;
+        }
+      ",
+              "next": null,
+              "parent": [Circular],
+              "prev": null,
+              "type": "text",
+            },
+          ],
+          "name": "style",
+          "namespace": "http://www.w3.org/1999/xhtml",
+          "next": null,
+          "parent": [Circular],
+          "prev": [Circular],
+          "type": "style",
+          "x-attribsNamespace": Object {},
+          "x-attribsPrefix": Object {},
+        },
+        "parent": [Circular],
+        "prev": null,
+        "type": "tag",
+        "x-attribsNamespace": Object {},
+        "x-attribsPrefix": Object {},
+      },
+      Object {
+        "attribs": Object {},
+        "children": Array [
+          Object {
+            "data": "
+        kbd {
+          width: fit-content;
+          line-height: 2em;
+          text-align: center;
+          display: inline-block;
+          color: #666;
+          background-color: #fafafa;
+          font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, &quot;Roboto&quot;, &quot;Oxygen&quot;, &quot;Ubuntu&quot;, &quot;Cantarell&quot;, &quot;Fira Sans&quot;, &quot;Droid Sans&quot;, &quot;Helvetica Neue&quot;, sans-serif;
+          border-radius: 5px;
+          border: 1px solid #eaeaea;
+          padding: 0 0;
+          min-width: 16pt;
+          font-size: .75rem;
+        }
+        
+        kbd + kbd {
+          margin-left: 4pt;
+        }
+        
+        span {
+          line-height: 2em;
+          font-size: 0.875rem;
+          text-align: center;
+        }
+        
+        span + span {
+          margin-left: 4pt;
+        }
+      ",
+            "next": null,
+            "parent": [Circular],
+            "prev": null,
+            "type": "text",
+          },
+        ],
+        "name": "style",
+        "namespace": "http://www.w3.org/1999/xhtml",
+        "next": null,
+        "parent": [Circular],
+        "prev": Object {
+          "attribs": Object {},
+          "children": Array [
+            Object {
+              "data": "F",
+              "next": null,
+              "parent": [Circular],
+              "prev": null,
+              "type": "text",
+            },
+          ],
+          "name": "span",
+          "namespace": "http://www.w3.org/1999/xhtml",
+          "next": [Circular],
+          "parent": [Circular],
+          "prev": null,
+          "type": "tag",
+          "x-attribsNamespace": Object {},
+          "x-attribsPrefix": Object {},
+        },
+        "type": "style",
+        "x-attribsNamespace": Object {},
+        "x-attribsPrefix": Object {},
+      },
+    ],
+    "name": "kbd",
+    "namespace": "http://www.w3.org/1999/xhtml",
+    "next": null,
+    "parent": null,
+    "prev": null,
+    "root": Object {
+      "attribs": Object {},
+      "children": Array [
+        [Circular],
+      ],
+      "name": "root",
+      "namespace": "http://www.w3.org/1999/xhtml",
+      "next": null,
+      "parent": null,
+      "prev": null,
+      "type": "root",
+      "x-attribsNamespace": Object {},
+      "x-attribsPrefix": Object {},
+    },
+    "type": "tag",
+    "x-attribsNamespace": Object {
+      "class": undefined,
+    },
+    "x-attribsPrefix": Object {
+      "class": undefined,
+    },
+  },
+  "_root": [Circular],
+  "length": 1,
+  "options": Object {
+    "decodeEntities": true,
+    "normalizeWhitespace": false,
+    "withDomLvl1": true,
+    "xml": false,
+  },
+}
+`;
diff --git a/components/keyboard/__tests__/index.test.tsx b/components/keyboard/__tests__/index.test.tsx
new file mode 100644
index 0000000..3a9c30f
--- /dev/null
+++ b/components/keyboard/__tests__/index.test.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import { mount, render } from 'enzyme'
+import { Keyboard } from 'components'
+
+describe('Keyboard', () => {
+  it('should render correctly', () => {
+    const wrapper = mount(<Keyboard>F</Keyboard>)
+  
+    expect(wrapper.html()).toMatchSnapshot()
+    expect(() => wrapper.unmount()).not.toThrow()
+  })
+  
+  it('should work with modifiers', () => {
+    const wrapper = mount(
+      <div>
+        <Keyboard command>F</Keyboard>
+        <Keyboard shift>F</Keyboard>
+        <Keyboard option>F</Keyboard>
+        <Keyboard ctrl>F</Keyboard>
+      </div>
+    )
+    expect(wrapper.html()).toMatchSnapshot()
+    expect(() => wrapper.unmount()).not.toThrow()
+  })
+  
+  it('should work with small size', () => {
+    const wrapper = render(<Keyboard small>F</Keyboard>)
+    expect(wrapper).toMatchSnapshot()
+  })
+})
diff --git a/components/link/__tests__/__snapshots__/index.test.tsx.snap b/components/link/__tests__/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000..a76dce6
--- /dev/null
+++ b/components/link/__tests__/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,124 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Link should render correctly 1`] = `
+"<div><a class=\\"link \\" href=\\"https://react.zeit-ui.co\\">link<svg viewBox=\\"0 0 24 24\\" width=\\"1em\\" height=\\"1em\\" stroke=\\"currentColor\\" stroke-width=\\"1.5\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\" fill=\\"none\\" shape-rendering=\\"geometricPrecision\\" class=\\"icon\\"><path d=\\"M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6\\"></path><path d=\\"M15 3h6v6\\"></path><path d=\\"M10 14L21 3\\"></path><style>
+        .icon {
+          margin: 0 5px;
+          display: inline-flex;
+          align-self: center;
+        }
+      </style></svg><style>
+        .link {
+          display: inline-flex;
+          align-items: baseline;
+          line-height: inherit;
+          color: inherit;
+          text-decoration: none;
+          padding: calc(0 * .8) calc(0 * 1.7);
+          border-radius: 0;
+          width: fit-content;
+        }
+        
+        .link:hover, .link:active, .link:focus {
+          text-decoration: none;
+        }
+        
+        .link:hover {
+          background-color: unset;
+        }
+      </style></a><a class=\\"link \\" href=\\"https://react.zeit-ui.co\\">link<svg viewBox=\\"0 0 24 24\\" width=\\"1em\\" height=\\"1em\\" stroke=\\"currentColor\\" stroke-width=\\"1.5\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\" fill=\\"none\\" shape-rendering=\\"geometricPrecision\\" style=\\"color: rgb(0, 112, 243);\\" class=\\"icon\\"><path d=\\"M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6\\"></path><path d=\\"M15 3h6v6\\"></path><path d=\\"M10 14L21 3\\"></path><style>
+        .icon {
+          margin: 0 5px;
+          display: inline-flex;
+          align-self: center;
+        }
+      </style></svg><style>
+        .link {
+          display: inline-flex;
+          align-items: baseline;
+          line-height: inherit;
+          color: #0070f3;
+          text-decoration: none;
+          padding: calc(0 * .8) calc(0 * 1.7);
+          border-radius: 0;
+          width: fit-content;
+        }
+        
+        .link:hover, .link:active, .link:focus {
+          text-decoration: none;
+        }
+        
+        .link:hover {
+          background-color: unset;
+        }
+      </style></a><a class=\\"link \\" href=\\"https://react.zeit-ui.co\\">link<style>
+        .link {
+          display: inline-flex;
+          align-items: baseline;
+          line-height: inherit;
+          color: inherit;
+          text-decoration: none;
+          padding: calc(0 * .8) calc(0 * 1.7);
+          border-radius: 0;
+          width: fit-content;
+        }
+        
+        .link:hover, .link:active, .link:focus {
+          text-decoration: none;
+        }
+        
+        .link:hover {
+          background-color: unset;
+        }
+      </style></a><a class=\\"link \\" href=\\"https://react.zeit-ui.co\\">link<svg viewBox=\\"0 0 24 24\\" width=\\"1em\\" height=\\"1em\\" stroke=\\"currentColor\\" stroke-width=\\"1.5\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\" fill=\\"none\\" shape-rendering=\\"geometricPrecision\\" class=\\"icon\\"><path d=\\"M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6\\"></path><path d=\\"M15 3h6v6\\"></path><path d=\\"M10 14L21 3\\"></path><style>
+        .icon {
+          margin: 0 5px;
+          display: inline-flex;
+          align-self: center;
+        }
+      </style></svg><style>
+        .link {
+          display: inline-flex;
+          align-items: baseline;
+          line-height: inherit;
+          color: inherit;
+          text-decoration: none;
+          padding: calc(0 * .8) calc(0 * 1.7);
+          border-radius: 0;
+          width: fit-content;
+        }
+        
+        .link:hover, .link:active, .link:focus {
+          text-decoration: underline;
+        }
+        
+        .link:hover {
+          background-color: unset;
+        }
+      </style></a><a class=\\"link \\" href=\\"https://react.zeit-ui.co\\">link<svg viewBox=\\"0 0 24 24\\" width=\\"1em\\" height=\\"1em\\" stroke=\\"currentColor\\" stroke-width=\\"1.5\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\" fill=\\"none\\" shape-rendering=\\"geometricPrecision\\" style=\\"color: rgb(0, 112, 243);\\" class=\\"icon\\"><path d=\\"M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6\\"></path><path d=\\"M15 3h6v6\\"></path><path d=\\"M10 14L21 3\\"></path><style>
+        .icon {
+          margin: 0 5px;
+          display: inline-flex;
+          align-self: center;
+        }
+      </style></svg><style>
+        .link {
+          display: inline-flex;
+          align-items: baseline;
+          line-height: inherit;
+          color: #0070f3;
+          text-decoration: none;
+          padding: calc(4pt * .8) calc(4pt * 1.7);
+          border-radius: 5px;
+          width: fit-content;
+        }
+        
+        .link:hover, .link:active, .link:focus {
+          text-decoration: none;
+        }
+        
+        .link:hover {
+          background-color: #0076ff1a;
+        }
+      </style></a></div>"
+`;
diff --git a/components/link/__tests__/index.test.tsx b/components/link/__tests__/index.test.tsx
new file mode 100644
index 0000000..ce29adc
--- /dev/null
+++ b/components/link/__tests__/index.test.tsx
@@ -0,0 +1,37 @@
+import React from 'react'
+import { mount } from 'enzyme'
+import { Link } from 'components'
+
+describe('Link', () => {
+  it('should render correctly', () => {
+    const wrapper = mount(
+      <div>
+        <Link href="https://react.zeit-ui.co">link</Link>
+        <Link href="https://react.zeit-ui.co" color>link</Link>
+        <Link href="https://react.zeit-ui.co" pure>link</Link>
+        <Link href="https://react.zeit-ui.co" underline>link</Link>
+        <Link href="https://react.zeit-ui.co" block>link</Link>
+      </div>
+    )
+    expect(wrapper.html()).toMatchSnapshot()
+    expect(() => wrapper.unmount()).not.toThrow()
+  })
+  
+  it('should be no errors when href missing', () => {
+    const errorSpy = jest.spyOn(console, 'error')
+    const wrapper = mount(<Link />)
+    expect(errorSpy).not.toHaveBeenCalled()
+    expect(() => wrapper.unmount()).not.toThrow()
+    errorSpy.mockRestore()
+  })
+  
+  it('should forward ref', () => {
+    let ref = React.createRef<HTMLAnchorElement>()
+    const errorSpy = jest.spyOn(console, 'error')
+    const wrapper = mount(<Link ref={ref} />)
+    
+    expect(errorSpy).not.toHaveBeenCalled()
+    expect(ref.current).not.toBeNull()
+    expect(() => wrapper.unmount()).not.toThrow()
+  })
+})
diff --git a/components/loading/__tests__/__snapshots__/index.test.tsx.snap b/components/loading/__tests__/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000..b2020af
--- /dev/null
+++ b/components/loading/__tests__/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,793 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Loading should render correctly 1`] = `
+"<div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 4px;
+          height: 4px;
+          border-radius: 50%;
+          background-color: #444;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div>"
+`;
+
+exports[`Loading should render correctly 2`] = `[Function]`;
+
+exports[`Loading should work with custom styles 1`] = `
+"<div><div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 4px;
+          height: 4px;
+          border-radius: 50%;
+          background-color: #fff;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div><div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 4px;
+          height: 4px;
+          border-radius: 50%;
+          background-color: #444;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div><div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 4px;
+          height: 4px;
+          border-radius: 50%;
+          background-color: #444;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div></div>"
+`;
+
+exports[`Loading should work with custom styles 2`] = `[Function]`;
+
+exports[`Loading should work with different sizes 1`] = `
+"<div><div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 2px;
+          height: 2px;
+          border-radius: 50%;
+          background-color: #444;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div><div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 3px;
+          height: 3px;
+          border-radius: 50%;
+          background-color: #444;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div><div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 4px;
+          height: 4px;
+          border-radius: 50%;
+          background-color: #444;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div><div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 5px;
+          height: 5px;
+          border-radius: 50%;
+          background-color: #444;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div></div>"
+`;
+
+exports[`Loading should work with different sizes 2`] = `[Function]`;
+
+exports[`Loading should work with different types 1`] = `
+"<div><div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 4px;
+          height: 4px;
+          border-radius: 50%;
+          background-color: #0070f3;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div><div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 4px;
+          height: 4px;
+          border-radius: 50%;
+          background-color: #666;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div><div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 4px;
+          height: 4px;
+          border-radius: 50%;
+          background-color: #f5a623;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div><div class=\\"loading-container\\"><span class=\\"loading\\"><i></i><i></i><i></i></span><style>
+        .loading-container {
+          display: inline-flex;
+          align-items: center;
+          position: relative;
+          width: 100%;
+          height: 100%;
+        }
+        
+        label {
+          margin-right: 8pt;
+          color: #666;
+        }
+        
+        label :global(*) {
+          margin: 0;
+        }
+        
+        .loading {
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          background-color: transparent;
+          user-select: none;
+        }
+
+        i {
+          width: 4px;
+          height: 4px;
+          border-radius: 50%;
+          background-color: #ff0000;
+          margin: 0 1px;
+          display: inline-block;
+          animation: loading-blink 1.4s infinite both;
+        }
+        
+        i:nth-child(2) {
+          animation-delay: 0.2s;
+        }
+        
+        i:nth-child(3) {
+          animation-delay: 0.4s;
+        }
+        
+        @keyframes loading-blink {
+          0% {
+            opacity: 0.2;
+          }
+          
+          20% {
+            opacity: 1;
+          }
+          
+          100% {
+            opacity: 0.2;
+          }
+        }
+      </style></div></div>"
+`;
+
+exports[`Loading should work with different types 2`] = `[Function]`;
diff --git a/components/loading/__tests__/index.test.tsx b/components/loading/__tests__/index.test.tsx
new file mode 100644
index 0000000..3ef79e7
--- /dev/null
+++ b/components/loading/__tests__/index.test.tsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import { mount } from 'enzyme'
+import { Loading } from 'components'
+
+describe('Loading', () => {
+  it('should render correctly', () => {
+    const wrapper = mount(<Loading />)
+    expect(wrapper.html()).toMatchSnapshot()
+    expect(() => wrapper.unmount()).toMatchSnapshot()
+  })
+  
+  it('should work with different types', () => {
+    const wrapper = mount(
+      <div>
+        <Loading type="success" />
+        <Loading type="secondary" />
+        <Loading type="warning" />
+        <Loading type="error" />
+      </div>
+    )
+    expect(wrapper.html()).toMatchSnapshot()
+    expect(() => wrapper.unmount()).toMatchSnapshot()
+  })
+  
+  it('should work with different sizes', () => {
+    const wrapper = mount(
+      <div>
+        <Loading size="mini" />
+        <Loading size="small" />
+        <Loading size="medium" />
+        <Loading size="large" />
+      </div>
+    )
+    expect(wrapper.html()).toMatchSnapshot()
+    expect(() => wrapper.unmount()).toMatchSnapshot()
+  })
+  
+  it('should work with custom styles', () => {
+    const wrapper = mount(
+      <div>
+        <Loading color="#fff" />
+        <Loading width="20%" />
+        <Loading height="10px" />
+      </div>
+    )
+    expect(wrapper.html()).toMatchSnapshot()
+    expect(() => wrapper.unmount()).toMatchSnapshot()
+  })
+  
+  it('should work with children', () => {
+    const wrapper = mount(<Loading>test-children</Loading>)
+    expect(wrapper.find('.loading').text())
+      .toContain('test-children')
+  })
+  
+})
diff --git a/components/modal/__tests__/__snapshots__/index.test.tsx.snap b/components/modal/__tests__/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000..d6f23e1
--- /dev/null
+++ b/components/modal/__tests__/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,189 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Modal should render correctly 1`] = `
+"<div class=\\"backdrop  transition-enter\\"><div class=\\"layer\\"></div><div class=\\"content\\"><div class=\\"wrapper   wrapper-enter\\"><h2 class=\\"\\">Modal</h2><style>
+        h2 {
+          font-size: 1.5rem;
+          line-height: 1.6;
+          font-weight: normal;
+          text-align: center;
+          margin: 0;
+          display: inline-flex;
+          justify-content: center;
+          align-items: center;
+          word-break: break-word;
+          text-transform: uppercase;
+          color: #000;
+        }
+      </style><p class=\\"\\">This is a modal</p><style>
+        p {
+          font-size: .875rem;
+          line-height: 1.6;
+          font-weight: normal;
+          text-align: center;
+          margin: 0;
+          display: inline-flex;
+          justify-content: center;
+          align-items: center;
+          word-break: break-word;
+          text-transform: uppercase;
+          color: #666;
+        }
+      </style><div class=\\"content \\"><p>Some content contained within the modal.</p></div><style>
+        .content {
+          margin: 0;
+          padding: 16pt 0 8pt 0;
+        }
+        
+        .content :global(p) {
+          margin: 0;
+        }
+      </style><div></div><footer><button class=\\"\\">Cancel</button><style>
+        button {
+          font-size: .75rem;
+          text-transform: uppercase;
+          display: flex;
+          -webkit-box-align: center;
+          align-items: center;
+          -webkit-box-pack: center;
+          justify-content: center;
+          outline: none;
+          text-decoration: none;
+          transition: all 200ms ease-in-out 0s;
+          border: none;
+          color: #666;
+          background-color: #fff;
+          cursor: pointer;
+          flex: 1;
+        }
+        
+        button:hover {
+          color: #000;
+          background-color: #fafafa;
+        }
+      </style><button class=\\"\\">Submit</button><style>
+        button {
+          font-size: .75rem;
+          text-transform: uppercase;
+          display: flex;
+          -webkit-box-align: center;
+          align-items: center;
+          -webkit-box-pack: center;
+          justify-content: center;
+          outline: none;
+          text-decoration: none;
+          transition: all 200ms ease-in-out 0s;
+          border: none;
+          color: #000;
+          background-color: #fff;
+          cursor: pointer;
+          flex: 1;
+        }
+        
+        button:hover {
+          color: #000;
+          background-color: #fafafa;
+        }
+      </style></footer><style>
+        footer {
+          display: flex;
+          overflow: hidden;
+          width: 100%;
+          height: 3.625rem;
+          position: absolute;
+          bottom: 0;
+          left: 0;
+          right: 0;
+          border-top: 1px solid #eaeaea;
+          border-bottom-left-radius: 5px;
+          border-bottom-right-radius: 5px;
+        }
+        
+        footer > :global(button+button) {
+          border-left: 1px solid #eaeaea;
+        }
+        
+        div {
+          height: 3.625rem;
+        }
+      </style><style>
+        .wrapper {
+          max-width: 90%;
+          width: 26rem;
+          overflow: hidden;
+          display: flex;
+          flex-direction: column;
+          position: relative;
+          box-sizing: border-box;
+          background-color: #fff;
+          color: #000;
+          border-radius: 5px;
+          padding: 16pt;
+          opacity: 0;
+          transform: translate3d(0px, -40px, 0px);
+          transition: opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1) 0s, transform 0.35s cubic-bezier(0.4, 0, 0.2, 1) 0s;
+        }
+        
+        .wrapper-enter {
+          opacity: 0;
+          transform: translate3d(0px, -40px, 0px);
+        }
+        
+        .wrapper-enter-active {
+          opacity: 1;
+          transform: translate3d(0px, 0px, 0px);
+        }
+        
+        .wrapper-leave {
+          opacity: 1;
+          transform: translate3d(0px, 0px, 0px);
+        }
+
+        .wrapper-leave-active {
+          opacity: 0;
+          transform: translate3d(0px, -50px, 0px);
+        }
+      </style></div></div><div class=\\"offset\\"></div><style>
+        .backdrop {
+          position: fixed;
+          top: 0;
+          left: 0;
+          display: flex;
+          align-content: center;
+          align-items: center;
+          flex-direction: column;
+          justify-content: center;
+          height: 100vh;
+          width: 100vw;
+          overflow: auto;
+          z-index: 2000;
+        }
+        
+        .content {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+        }
+        
+        .offset {
+          height: 25vh;
+          opacity: 0;
+          background-color: transparent;
+        }
+        
+        .layer {
+          position: fixed;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          width: 100%;
+          height: 100%;
+          opacity: 0.25;
+          background-color: #000;
+          transition: opacity .35s cubic-bezier(.4, 0, .2, 1);
+          pointer-events: none;
+          z-index: -1;
+        }
+      </style></div>"
+`;
diff --git a/components/modal/__tests__/index.test.tsx b/components/modal/__tests__/index.test.tsx
new file mode 100644
index 0000000..84b39ec
--- /dev/null
+++ b/components/modal/__tests__/index.test.tsx
@@ -0,0 +1,102 @@
+import React from 'react'
+import { mount } from 'enzyme'
+import { Modal } from 'components'
+import { nativeEvent, updateWrapper } from 'tests/utils'
+import { expectModalIsClosed, expectModalIsOpened } from './use-modal.test'
+
+describe('Modal', () => {
+  it('should render correctly', () => {
+    const wrapper = mount(
+      <Modal open={true}>
+        <Modal.Title>Modal</Modal.Title>
+        <Modal.Subtitle>This is a modal</Modal.Subtitle>
+        <Modal.Content>
+          <p>Some content contained within the modal.</p>
+        </Modal.Content>
+        <Modal.Action passive>Cancel</Modal.Action>
+        <Modal.Action>Submit</Modal.Action>
+      </Modal>
+    )
+    expect(wrapper.html()).toMatchSnapshot()
+    expect(() => wrapper.unmount()).not.toThrow()
+  })
+  
+  it('should trigger event when modal changed', async () => {
+    const openHandler = jest.fn()
+    const closeHandler = jest.fn()
+    const wrapper = mount(
+      <Modal onOpen={openHandler} onClose={closeHandler}>
+        <Modal.Title>Modal</Modal.Title>
+      </Modal>
+    )
+    expectModalIsClosed(wrapper)
+    
+    wrapper.setProps({ open: true })
+    await updateWrapper(wrapper, 350)
+    expectModalIsOpened(wrapper)
+    expect(openHandler).toHaveBeenCalled()
+  
+    wrapper.find('.backdrop').simulate('click', nativeEvent)
+    await updateWrapper(wrapper, 500)
+    expectModalIsClosed(wrapper)
+    expect(closeHandler).toHaveBeenCalled()
+  })
+  
+  it('should disable backdrop event', async () => {
+    const closeHandler = jest.fn()
+    const wrapper = mount(
+      <Modal open={true} disableBackdropClick onClose={closeHandler}>
+        <Modal.Title>Modal</Modal.Title>
+        <Modal.Action>Submit</Modal.Action>
+      </Modal>
+    )
+    wrapper.find('.backdrop').simulate('click', nativeEvent)
+    await updateWrapper(wrapper, 500)
+    expectModalIsOpened(wrapper)
+    expect(closeHandler).not.toHaveBeenCalled()
+  })
+  
+  it('should ignore backdrop disabled when actions missing', async () => {
+    const closeHandler = jest.fn()
+    const wrapper = mount(
+      <Modal open={true} disableBackdropClick onClose={closeHandler}>
+        <Modal.Title>Modal</Modal.Title>
+      </Modal>
+    )
+    wrapper.find('.backdrop').simulate('click', nativeEvent)
+    await updateWrapper(wrapper, 500)
+    expectModalIsClosed(wrapper)
+    expect(closeHandler).toHaveBeenCalled()
+  })
+  
+  it('should ignore event when action disabled', () => {
+    const actions1 = jest.fn()
+    const actions2 = jest.fn()
+    const wrapper = mount(
+      <Modal open={true}>
+        <Modal.Title>Modal</Modal.Title>
+        <Modal.Action passive onClick={actions1}>Submit</Modal.Action>
+        <Modal.Action disabled onClick={actions2}>Submit</Modal.Action>
+      </Modal>
+    )
+    wrapper.find('button').at(0).simulate('click', nativeEvent)
+    wrapper.find('button').at(1).simulate('click', nativeEvent)
+    
+    expect(actions1).toHaveBeenCalled()
+    expect(actions2).not.toHaveBeenCalled()
+  })
+  
+  it('should be close modal through action event', async () => {
+    const closeHandler = jest.fn()
+    const wrapper = mount(
+      <Modal open={true} onClose={closeHandler}>
+        <Modal.Title>Modal</Modal.Title>
+        <Modal.Action passive onClick={e => e.close()}>Close</Modal.Action>
+      </Modal>
+    )
+    wrapper.find('button').at(0).simulate('click', nativeEvent)
+    await updateWrapper(wrapper, 500)
+    expectModalIsClosed(wrapper)
+    expect(closeHandler).toHaveBeenCalled()
+  })
+})
diff --git a/components/modal/__tests__/use-modal.test.tsx b/components/modal/__tests__/use-modal.test.tsx
new file mode 100644
index 0000000..66c03c0
--- /dev/null
+++ b/components/modal/__tests__/use-modal.test.tsx
@@ -0,0 +1,37 @@
+import React, { useEffect } from 'react'
+import { mount, ReactWrapper } from 'enzyme'
+import { Modal, useModal } from 'components'
+import { updateWrapper } from 'tests/utils'
+
+export const expectModalIsOpened = (wrapper: ReactWrapper) => {
+  expect(wrapper.find('.content').length).not.toBe(0)
+}
+
+export const expectModalIsClosed = (wrapper: ReactWrapper) => {
+  expect(wrapper.find('.content').length).toBe(0)
+}
+
+describe('UseModal', () => {
+  it('should follow change with use-modal', async () => {
+    const MockModal: React.FC<{ show?: boolean }> = ({ show }) => {
+      const { setVisible, bindings } = useModal(false)
+      useEffect(() => {
+        if (show !== undefined) setVisible(show)
+      }, [show])
+      return (
+        <Modal {...bindings}>
+          <Modal.Title>Modal</Modal.Title>
+        </Modal>
+      )
+    }
+    
+    const wrapper = mount(<MockModal />)
+    wrapper.setProps({ show: true })
+    await updateWrapper(wrapper, 300)
+    expectModalIsOpened(wrapper)
+  
+    wrapper.setProps({ show: false })
+    await updateWrapper(wrapper, 500)
+    expectModalIsClosed(wrapper)
+  })
+})