rework toast with React

This commit is contained in:
Pig Fang 2020-02-04 12:23:14 +08:00
parent d9f55d610c
commit 54b948d01e
12 changed files with 190 additions and 183 deletions

View File

@ -23,6 +23,7 @@
"admin-lte": "^3.0.1",
"echarts": "^4.6.0",
"jquery": "^3.4.1",
"nanoid": "^2.1.11",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.18",
@ -47,6 +48,7 @@
"@types/jest": "^24.0.25",
"@types/jquery": "^3.3.29",
"@types/js-yaml": "^3.12.2",
"@types/nanoid": "^2.1.0",
"@types/react": "^16.9.17",
"@types/react-dom": "^16.9.4",
"@types/tween.js": "^17.2.0",

View File

@ -0,0 +1,12 @@
.toast {
position: fixed;
right: calc((100% - 350px) / 2);
width: 350px;
z-index: 1050;
transition-property: top;
transition-duration: 0.3s;
}
.shadow {
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
}

View File

@ -0,0 +1,61 @@
import React, { useState, useEffect } from 'react'
import styles from './Toast.scss'
export type ToastType = 'success' | 'info' | 'warning' | 'error'
interface Props {
type: ToastType
distance: number
onClose(): void | Promise<void>
}
const icons = new Map<ToastType, string>([
['success', 'check'],
['info', 'info'],
['warning', 'exclamation-triangle'],
['error', 'times-circle'],
])
const Toast: React.FC<Props> = props => {
const [show, setShow] = useState(false)
useEffect(() => {
setTimeout(() => setShow(true), 100)
setTimeout(() => setShow(false), 3000)
setTimeout(props.onClose, 3100)
}, [props.onClose])
const type = props.type === 'error' ? 'danger' : props.type
const classes = [
`alert alert-${type}`,
'd-flex justify-content-between',
'fade',
styles.shadow,
]
if (show) {
classes.push('show')
}
return (
<div className={styles.toast} style={{ top: `${props.distance}px` }}>
<div className={classes.join(' ')}>
<span className="mr-auto">
<i className={`icon fas fa-${icons.get(props.type)}`}></i>
<span>{props.children}</span>
</span>
<button
type="button"
className="mr-2 ml-1 close"
onClick={props.onClose}
>
&times;
</button>
</div>
</div>
)
}
Toast.displayName = 'Toast'
export default Toast

View File

@ -26,10 +26,10 @@ export default Vue.extend<{
{ tid: this.tid, name: value },
)
if (code === 0) {
toast.success(message)
toast.success(message!)
this.$emit('like-toggled', true)
} else {
toast.error(message)
toast.error(message!)
}
},
},

View File

@ -19,9 +19,9 @@ export default Vue.extend<{
const { code, message } = await this.$http.post(`/user/closet/remove/${this.tid}`)
if (code === 0) {
this.$emit('item-removed')
toast.success(message)
toast.success(message!)
} else {
toast.error(message)
toast.error(message!)
}
},
},

View File

@ -20,7 +20,7 @@ export default Vue.extend<{
{ tid: this.tid },
)
if (code === 0) {
toast.success(message)
toast.success(message!)
Array
.from(
@ -28,7 +28,7 @@ export default Vue.extend<{
)
.forEach(el => (el.src += `?${new Date().getTime()}`))
} else {
toast.error(message)
toast.error(message!)
}
},
},

View File

@ -1,96 +0,0 @@
import $ from 'jquery'
import 'bootstrap'
const toastIcons: Record<ToastType, string> = {
success: 'check',
info: 'info',
warning: 'exclamation-triangle',
error: 'times-circle',
}
export type ToastQueue = QueueElement[]
type QueueElement = { el: HTMLDivElement, height: number }
type ToastType =
| 'success'
| 'info'
| 'warning'
| 'error'
export function showToast(
queue: ToastQueue,
type: ToastType,
message = '',
): void {
const alertType = type === 'error' ? 'danger' : type
const container = document.createElement('div')
container.className = 'alert-toast'
const last = queue[queue.length - 1]
if (last) {
container.style.top = `${last.el.offsetTop + last.el.offsetHeight + 12}px`
} else {
container.style.top = '35px'
}
const toast = document.createElement('div')
toast.className = `alert alert-${alertType} d-flex justify-content-between fade`
const icon = document.createElement('i')
icon.className = `icon fas fa-${toastIcons[type]}`
const text = document.createElement('span')
text.textContent = message
const span = document.createElement('span')
span.className = 'mr-auto'
span.appendChild(icon)
span.appendChild(text)
toast.appendChild(span)
const button = document.createElement('button')
button.type = 'button'
button.className = 'ml-2 mr-1 close'
button.dataset.dismiss = 'alert'
button.textContent = '×'
toast.appendChild(button)
container.appendChild(toast)
document.body.appendChild(container)
queue.push({ el: container, height: container.offsetHeight })
setTimeout(() => toast.classList.add('show'), 100)
setTimeout(() => $(toast).alert('close'), 3000)
$(toast).on('closed.bs.alert', () => {
container.remove()
let i = queue.findIndex(({ el }) => el === container)
const distance = queue[i].height + 12
for (i += 1; i < queue.length; i += 1) {
const element = queue[i].el
element.style.top = `${element.offsetTop - distance}px`
}
})
}
export class Toast {
private queue: ToastQueue
constructor() {
this.queue = []
}
success(message = '') {
return showToast(this.queue, 'success', message)
}
info(message = '') {
return showToast(this.queue, 'info', message)
}
warning(message = '') {
return showToast(this.queue, 'warning', message)
}
error(message = '') {
return showToast(this.queue, 'error', message)
}
}

View File

@ -0,0 +1,69 @@
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
import nanoid from 'nanoid'
import * as emitter from './event'
import ToastBox, { ToastType } from '../components/Toast'
type QueueElement = { id: string; type: ToastType; message: string }
type ToastQueue = QueueElement[]
const TOAST_EVENT = Symbol('toast')
export const ToastContainer: React.FC = () => {
const [queue, setQueue] = useState<ToastQueue>([])
useEffect(() => {
const off = emitter.on(TOAST_EVENT, (toast: QueueElement) => {
setQueue(queue => {
queue.push(toast)
return queue.slice()
})
})
return off
}, [])
const handleClose = (id: string) => {
setQueue(queue => queue.filter(el => el.id !== id))
}
return (
<>
{queue.map((el, i) => (
<ToastBox
key={el.id}
type={el.type}
distance={50 + i * 70}
onClose={() => handleClose(el.id)}
>
{el.message}
</ToastBox>
))}
</>
)
}
export class Toast {
constructor() {
const container = document.createElement('div')
document.body.appendChild(container)
ReactDOM.render(<ToastContainer />, container)
}
success(message: string) {
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'success', message })
}
info(message: string) {
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'info', message })
}
warning(message: string) {
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'warning', message })
}
error(message: string) {
emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'error', message })
}
}

View File

@ -30,13 +30,3 @@
.user-panel .info
cursor default
.alert-toast
position fixed
right calc((100% - 350px) / 2)
width 350px
z-index 1050
transition-property top
transition-duration 0.3s
.alert
box-shadow 0 .25rem .75rem rgba(0,0,0,.1)

View File

@ -1,71 +0,0 @@
import {
showToast, Toast, ToastQueue,
} from '@/scripts/toast'
beforeEach(() => {
document.body.innerHTML = ''
})
test('"Toast" class', () => {
const toast = new Toast()
toast.success('success')
expect(
document.querySelector('.alert-success')!.textContent,
).toContain('success')
toast.info('info')
expect(
document.querySelector('.alert-info')!.textContent,
).toContain('info')
toast.warning('warning')
expect(
document.querySelector('.alert-warning')!.textContent,
).toContain('warning')
toast.error('error')
expect(
document.querySelector('.alert-danger')!.textContent,
).toContain('error')
// Should pass.
toast.success()
toast.info()
toast.warning()
toast.error()
})
test('top position', () => {
showToast([], 'info')
expect(
document.querySelector<HTMLDivElement>('.alert-info')!.parentElement!.style.top,
).toBe('35px')
showToast(
[{ height: 20, el: { offsetTop: 30, offsetHeight: 20 } as HTMLDivElement }],
'error',
)
expect(
document.querySelector<HTMLDivElement>('.alert-danger')!.parentElement!.style.top,
).toBe('62px')
})
test('delay show', () => {
const queue: ToastQueue = []
showToast(queue, 'info')
jest.advanceTimersByTime(100)
expect(document.querySelector('.fade')!.classList.contains('show')).toBeTrue()
expect(queue).toHaveLength(1)
})
test('move queue', () => {
const queue: ToastQueue = []
const fake = { offsetTop: 30, style: {} } as HTMLDivElement
showToast(queue, 'info')
queue[0].height = 10
queue.push({ height: 0, el: fake })
jest.runAllTimers()
expect(fake.style.top).toBe('8px')
})

View File

@ -0,0 +1,28 @@
import React from 'react'
import { render } from '@testing-library/react'
import { Toast, ToastContainer } from '@/scripts/toast'
test('"Toast" class', () => {
render(<ToastContainer />)
const toast = new Toast()
toast.success('success')
expect(document.querySelector('.alert-success')!.textContent).toContain(
'success',
)
toast.info('info')
expect(document.querySelector('.alert-info')!.textContent).toContain('info')
toast.warning('warning')
expect(document.querySelector('.alert-warning')!.textContent).toContain(
'warning',
)
toast.error('error')
expect(document.querySelector('.alert-danger')!.textContent).toContain(
'error',
)
jest.runAllTimers()
})

View File

@ -1340,6 +1340,13 @@
resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
"@types/nanoid@^2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@types/nanoid/-/nanoid-2.1.0.tgz#41edfda78986e9127d0dc14de982de766f994020"
integrity sha512-xdkn/oRTA0GSNPLIKZgHWqDTWZsVrieKomxJBOQUK9YDD+zfSgmwD5t4WJYra5S7XyhTw7tfvwznW+pFexaepQ==
dependencies:
"@types/node" "*"
"@types/node@*":
version "12.7.5"
resolved "https://registry.npmjs.org/@types/node/-/node-12.7.5.tgz#e19436e7f8e9b4601005d73673b6dc4784ffcc2f"
@ -6991,6 +6998,11 @@ nan@^2.12.1:
resolved "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
nanoid@^2.1.11:
version "2.1.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280"
integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==
nanomatch@^1.2.9:
version "1.2.13"
resolved "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"