rework toast with React
This commit is contained in:
parent
d9f55d610c
commit
54b948d01e
|
|
@ -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",
|
||||
|
|
|
|||
12
resources/assets/src/components/Toast.scss
Normal file
12
resources/assets/src/components/Toast.scss
Normal 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);
|
||||
}
|
||||
61
resources/assets/src/components/Toast.tsx
Normal file
61
resources/assets/src/components/Toast.tsx
Normal 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}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Toast.displayName = 'Toast'
|
||||
|
||||
export default Toast
|
||||
|
|
@ -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!)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
69
resources/assets/src/scripts/toast.tsx
Normal file
69
resources/assets/src/scripts/toast.tsx
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
28
resources/assets/tests/scripts/toast.test.tsx
Normal file
28
resources/assets/tests/scripts/toast.test.tsx
Normal 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()
|
||||
})
|
||||
12
yarn.lock
12
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user