diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index fd5006f7..3aa34cb0 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -4,7 +4,7 @@ import { bind, linkRef, Fileish } from '../../lib/initial-util'; import * as style from './style.scss'; import { FileDropEvent } from './custom-els/FileDrop'; import './custom-els/FileDrop'; -import SnackBarElement from '../../lib/SnackBar'; +import SnackBarElement, { SnackOptions } from '../../lib/SnackBar'; import '../../lib/SnackBar'; import Intro from '../intro'; import '../custom-els/LoadingSpinner'; @@ -39,7 +39,7 @@ export default class App extends Component { import('../compress').then((module) => { this.setState({ Compress: module.default }); }).catch(() => { - this.showError('Failed to load app'); + this.showSnack('Failed to load app'); }); // In development, persist application state across hot reloads: @@ -66,9 +66,9 @@ export default class App extends Component { } @bind - private showError(error: string) { + private showSnack(message: string, options: SnackOptions = {}): Promise { if (!this.snackbar) throw Error('Snackbar missing'); - this.snackbar.showSnackbar({ message: error }); + return this.snackbar.showSnackbar(message, options); } render({}: Props, { file, Compress }: State) { @@ -76,9 +76,9 @@ export default class App extends Component {
{(!file) - ? + ? : (Compress) - ? + ? : } diff --git a/src/components/compress/index.tsx b/src/components/compress/index.tsx index 0e54ad09..ef5f19aa 100644 --- a/src/components/compress/index.tsx +++ b/src/components/compress/index.tsx @@ -35,6 +35,7 @@ import { VectorResizeOptions, BitmapResizeOptions } from '../../codecs/resize/pr import './custom-els/MultiPanel'; import Results from '../results'; import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons'; +import SnackBarElement from 'src/lib/SnackBar'; export interface SourceImage { file: File | Fileish; @@ -58,7 +59,7 @@ interface EncodedImage { interface Props { file: File | Fileish; - onError: (msg: string) => void; + showSnack: SnackBarElement['showSnackbar']; } interface State { @@ -250,12 +251,24 @@ export default class Compress extends Component { } } - private onCopyToOtherClick(index: 0 | 1) { + private async onCopyToOtherClick(index: 0 | 1) { const otherIndex = (index + 1) % 2; + const oldSettings = this.state.images[otherIndex]; this.setState({ images: cleanSet(this.state.images, otherIndex, this.state.images[index]), }); + + const result = await this.props.showSnack('Settings copied across', { + timeout: 5000, + actions: ['undo', 'dismiss'], + }); + + if (result !== 'undo') return; + + this.setState({ + images: cleanSet(this.state.images, otherIndex, oldSettings), + }); } @bind @@ -318,7 +331,7 @@ export default class Compress extends Component { console.error(err); // Another file has been opened before this one processed. if (this.state.loadingCounter !== loadingCounter) return; - this.props.onError('Invalid image'); + this.props.showSnack('Invalid image'); this.setState({ loading: false }); } } @@ -377,7 +390,7 @@ export default class Compress extends Component { } } catch (err) { if (err.name === 'AbortError') return; - this.props.onError(`Processing error (type=${image.encoderState.type}): ${err}`); + this.props.showSnack(`Processing error (type=${image.encoderState.type}): ${err}`); throw err; } } diff --git a/src/components/intro/index.tsx b/src/components/intro/index.tsx index f99d8b23..89dbb03a 100644 --- a/src/components/intro/index.tsx +++ b/src/components/intro/index.tsx @@ -12,6 +12,7 @@ import artworkIcon from './imgs/demos/artwork-icon.jpg'; import deviceScreenIcon from './imgs/demos/device-screen-icon.jpg'; import logoIcon from './imgs/demos/logo-icon.png'; import * as style from './style.scss'; +import SnackBarElement from '../../lib/SnackBar'; const demos = [ { @@ -42,7 +43,7 @@ const demos = [ interface Props { onFile: (file: File | Fileish) => void; - onError: (error: string) => void; + showSnack: SnackBarElement['showSnackbar']; } interface State { fetchingDemoIndex?: number; @@ -79,7 +80,7 @@ export default class Intro extends Component { this.props.onFile(file); } catch (err) { this.setState({ fetchingDemoIndex: undefined }); - this.props.onError("Couldn't fetch demo image"); + this.props.showSnack("Couldn't fetch demo image"); } } diff --git a/src/lib/SnackBar/index.ts b/src/lib/SnackBar/index.ts index 759306bc..8e55eabc 100644 --- a/src/lib/SnackBar/index.ts +++ b/src/lib/SnackBar/index.ts @@ -1,114 +1,96 @@ -import './styles.css'; - -const DEFAULT_TIMEOUT = 2750; +import * as style from './styles.css'; export interface SnackOptions { - message: string; timeout?: number; - actionText?: string; - actionHandler?: () => boolean | null; + actions?: string[]; } -export interface SnackShowResult { - action: boolean; -} +function createSnack(message: string, options: SnackOptions): [Element, Promise] { + const { + timeout = 0, + actions = [], + } = options; -class Snack { - private _onremove: ((result: SnackShowResult) => void)[] = []; - private _options: SnackOptions; - private _element: Element = document.createElement('div'); - private _text: Element = document.createElement('div'); - private _button: Element = document.createElement('button'); - private _showing = false; - private _closeTimer?: number; - private _result: SnackShowResult = { - action: false, - }; + // Provide a default 'dismiss' action + if (!timeout && actions.length === 0) actions.push('dismiss'); - constructor (options: SnackOptions, callback?: (result: SnackShowResult) => void) { - this._options = options; + const el = document.createElement('div'); + el.className = style.snackbar; + el.setAttribute('aria-live', 'assertive'); + el.setAttribute('aria-atomic', 'true'); + el.setAttribute('aria-hidden', 'false'); - this._element.className = 'snackbar'; - this._element.setAttribute('aria-live', 'assertive'); - this._element.setAttribute('aria-atomic', 'true'); - this._element.setAttribute('aria-hidden', 'true'); + const text = document.createElement('div'); + text.className = style.text; + text.textContent = message; + el.appendChild(text); - this._text.className = 'snackbar--text'; - this._text.textContent = options.message; - this._element.appendChild(this._text); + const result = new Promise((resolve) => { + let timeoutId: number; - if (options.actionText) { - this._button.className = 'snackbar--button'; - this._button.textContent = options.actionText; - this._button.addEventListener('click', () => { - if (this._showing) { - if (options.actionHandler && options.actionHandler() === false) return; - this._result.action = true; - } - this.hide(); + // Add action buttons + for (const action of actions) { + const button = document.createElement('button'); + button.className = style.button; + button.textContent = action; + button.addEventListener('click', () => { + clearTimeout(timeoutId); + resolve(action); }); - this._element.appendChild(this._button); + el.appendChild(button); } - if (callback) { - this._onremove.push(callback); + // Add timeout + if (timeout) { + timeoutId = self.setTimeout( + () => resolve(''), + timeout, + ); } - } + }); - cancelTimer () { - if (this._closeTimer != null) clearTimeout(this._closeTimer); - } - - show (parent: Element): Promise { - if (this._showing) return Promise.resolve(this._result); - this._showing = true; - this.cancelTimer(); - if (parent !== this._element.parentNode) { - parent.appendChild(this._element); - } - this._element.removeAttribute('aria-hidden'); - this._closeTimer = setTimeout(this.hide.bind(this), this._options.timeout || DEFAULT_TIMEOUT); - return new Promise((resolve) => { - this._onremove.push(resolve); - }); - } - - hide () { - if (!this._showing) return; - this._showing = false; - this.cancelTimer(); - this._element.addEventListener('animationend', this.remove.bind(this)); - this._element.setAttribute('aria-hidden', 'true'); - } - - remove () { - this.cancelTimer(); - const parent = this._element.parentNode; - if (parent) parent.removeChild(this._element); - this._onremove.forEach(f => f(this._result)); - this._onremove = []; - } + return [el, result]; } export default class SnackBarElement extends HTMLElement { - private _snackbars: Snack[] = []; - private _processingStack = false; + private _snackbars: [string, SnackOptions, (action: Promise) => void][] = []; + private _processingQueue = false; - showSnackbar (options: SnackOptions): Promise { - return new Promise((resolve) => { - const snack = new Snack(options, resolve); - this._snackbars.push(snack); - this._processStack(); + /** + * Show a snackbar. Returns a promise for the name of the action clicked, or an empty string if no + * action is clicked. + */ + showSnackbar(message: string, options: SnackOptions = {}): Promise { + return new Promise((resolve) => { + this._snackbars.push([message, options, resolve]); + if (!this._processingQueue) this._processQueue(); }); } - private async _processStack () { - if (this._processingStack === true || this._snackbars.length === 0) return; - this._processingStack = true; - await this._snackbars[0].show(this); - this._snackbars.shift(); - this._processingStack = false; - this._processStack(); + private async _processQueue() { + this._processingQueue = true; + + while (this._snackbars[0]) { + const [message, options, resolver] = this._snackbars[0]; + const [el, result] = createSnack(message, options); + // Pass the result back to the original showSnackbar call. + resolver(result); + this.appendChild(el); + + // Wait for the user to click an action, or for the snack to timeout. + await result; + + // Transition the snack away. + el.setAttribute('aria-hidden', 'true'); + await new Promise((resolve) => { + el.addEventListener('animationend', () => resolve()); + }); + el.remove(); + + this._snackbars.shift(); + } + + this._processingQueue = false; } } diff --git a/src/lib/SnackBar/styles.css b/src/lib/SnackBar/styles.css index be02ecd4..c1ece455 100644 --- a/src/lib/SnackBar/styles.css +++ b/src/lib/SnackBar/styles.css @@ -22,7 +22,6 @@ snack-bar { transform-origin: center; color: #eee; z-index: 100; - pointer-events: none; cursor: default; will-change: transform; animation: snackbar-show 300ms ease forwards 1; @@ -53,13 +52,13 @@ snack-bar { } } -.snackbar--text { +.text { flex: 1 1 auto; padding: 16px; font-size: 100%; } -.snackbar--button { +.button { position: relative; flex: 0 1 auto; padding: 8px; @@ -75,16 +74,15 @@ snack-bar { font-size: 100%; text-transform: uppercase; text-align: center; - pointer-events: all; cursor: pointer; overflow: hidden; transition: background-color 200ms ease; outline: none; } -.snackbar--button:hover { +.button:hover { background-color: rgba(0,0,0,0.15); } -.snackbar--button:focus:before { +.button:focus:before { content: ''; position: absolute; left: 50%; diff --git a/webpack.config.js b/webpack.config.js index ff942630..6b753b9f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -25,6 +25,7 @@ module.exports = function (_, env) { path.join(__dirname, 'src/components'), path.join(__dirname, 'src/codecs'), path.join(__dirname, 'src/custom-els'), + path.join(__dirname, 'src/lib'), ]; return { @@ -107,7 +108,7 @@ module.exports = function (_, env) { loader: 'typings-for-css-modules-loader', options: { modules: true, - localIdentName: '[local]__[hash:base64:5]', + localIdentName: isProd ? '[hash:base64:5]' : '[local]__[hash:base64:5]', namedExport: true, camelCase: true, importLoaders: 1, @@ -199,8 +200,11 @@ module.exports = function (_, env) { new OptimizeCssAssetsPlugin({ cssProcessorOptions: { - zindex: false, - discardComments: { removeAll: true } + postcssReduceIdents: { + counterStyle: false, + gridTemplate: false, + keyframes: false + } } }),