From c90db020b0c6be46a2386387aa01e0037dd40364 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Mon, 6 Aug 2018 09:32:48 -0400 Subject: [PATCH] Snackbar (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial swing * Finish up implementation and integrate it * Add missing types * Use shift() since we dont care about referential equality * Use `_` for private fields * Remove rogue handler * Remove impossible fallback value * Make `` actually contain its children * will-change for the button ripple * Guard against mutliple button action clicks * `onhide()` -> `onremove()` * remove transitionend * Replace inline ref callback with linkRef * showError only accepts strings * Remove undefined initialization * Throw on error * Add missing error type. * `SnackBar` ▶️ `Snack` * Avoid child retaining a reference to parent, make show() return a Promise. * async/await and avoid processing the stack if it is already being processed * Add a meaningful return value to showSnackbar() --- src/components/App/index.tsx | 24 ++++-- src/lib/SnackBar/index.ts | 115 ++++++++++++++++++++++++++++ src/lib/SnackBar/missing-types.d.ts | 13 ++++ src/lib/SnackBar/styles.css | 107 ++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 src/lib/SnackBar/index.ts create mode 100644 src/lib/SnackBar/missing-types.d.ts create mode 100644 src/lib/SnackBar/styles.css diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index c8034c2a..a1f58d5b 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -1,7 +1,7 @@ import { h, Component } from 'preact'; import { partial } from 'filesize'; -import { bind, bitmapToImageData } from '../../lib/util'; +import { bind, linkRef, bitmapToImageData } from '../../lib/util'; import * as style from './style.scss'; import Output from '../Output'; import Options from '../Options'; @@ -26,6 +26,8 @@ import { EncoderOptions, encoderMap, } from '../../codecs/encoders'; +import SnackBarElement from '../../lib/SnackBar'; +import '../../lib/SnackBar'; import { PreprocessorState, @@ -132,6 +134,8 @@ export default class App extends Component { ], }; + private snackbar?: SnackBarElement; + constructor() { super(); // In development, persist application state across hot reloads: @@ -234,12 +238,12 @@ export default class App extends Component { this.setState({ source: { data, bmp, file }, - error: undefined, loading: false, }); } catch (err) { console.error(err); - this.setState({ error: 'IMAGE_INVALID', loading: false }); + this.showError(`Invalid image`); + this.setState({ loading: false }); } } @@ -264,14 +268,13 @@ export default class App extends Component { source.preprocessed = await preprocessImage(source, image.preprocessorState); file = await compressImage(source, image.encoderState); } catch (err) { - this.setState({ error: `Encoding error (type=${image.encoderState.type}): ${err}` }); + this.showError(`Encoding error (type=${image.encoderState.type}): ${err}`); throw err; } const latestImage = this.state.images[index]; // If a later encode has landed before this one, return. if (loadingCounter < latestImage.loadedCounter) { - this.setState({ error: '' }); return; } @@ -294,10 +297,15 @@ export default class App extends Component { loadedCounter: loadingCounter, }; - this.setState({ images, error: '' }); + this.setState({ images }); } - render({ }: Props, { loading, error, images }: State) { + showError (error: string) { + if (!this.snackbar) throw Error('Snackbar missing'); + this.snackbar.showSnackbar({ message: error }); + } + + render({ }: Props, { loading, images }: State) { const [leftImageBmp, rightImageBmp] = images.map(i => i.bmp); const anyLoading = loading || images.some(image => image.loading); @@ -332,7 +340,7 @@ export default class App extends Component { /> ))} {anyLoading && Loading...} - {error && Error: {error}} + ); diff --git a/src/lib/SnackBar/index.ts b/src/lib/SnackBar/index.ts new file mode 100644 index 00000000..759306bc --- /dev/null +++ b/src/lib/SnackBar/index.ts @@ -0,0 +1,115 @@ +import './styles.css'; + +const DEFAULT_TIMEOUT = 2750; + +export interface SnackOptions { + message: string; + timeout?: number; + actionText?: string; + actionHandler?: () => boolean | null; +} + +export interface SnackShowResult { + action: boolean; +} + +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, + }; + + constructor (options: SnackOptions, callback?: (result: SnackShowResult) => void) { + this._options = options; + + this._element.className = 'snackbar'; + this._element.setAttribute('aria-live', 'assertive'); + this._element.setAttribute('aria-atomic', 'true'); + this._element.setAttribute('aria-hidden', 'true'); + + this._text.className = 'snackbar--text'; + this._text.textContent = options.message; + this._element.appendChild(this._text); + + 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(); + }); + this._element.appendChild(this._button); + } + + if (callback) { + this._onremove.push(callback); + } + } + + 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 = []; + } +} + +export default class SnackBarElement extends HTMLElement { + private _snackbars: Snack[] = []; + private _processingStack = false; + + showSnackbar (options: SnackOptions): Promise { + return new Promise((resolve) => { + const snack = new Snack(options, resolve); + this._snackbars.push(snack); + this._processStack(); + }); + } + + 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(); + } +} + +customElements.define('snack-bar', SnackBarElement); diff --git a/src/lib/SnackBar/missing-types.d.ts b/src/lib/SnackBar/missing-types.d.ts new file mode 100644 index 00000000..38bea912 --- /dev/null +++ b/src/lib/SnackBar/missing-types.d.ts @@ -0,0 +1,13 @@ +import { SnackOptions, SnackShowResult } from '.'; + +declare global { + namespace JSX { + interface IntrinsicElements { + 'snack-bar': SnackBarAttributes; + } + + interface SnackBarAttributes extends HTMLAttributes { + showSnackbar?: (options: SnackOptions) => Promise; + } + } +} diff --git a/src/lib/SnackBar/styles.css b/src/lib/SnackBar/styles.css new file mode 100644 index 00000000..be02ecd4 --- /dev/null +++ b/src/lib/SnackBar/styles.css @@ -0,0 +1,107 @@ +snack-bar { + display: block; + position: fixed; + left: 0; + bottom: 0; + width: 100%; + height: 0; + overflow: visible; +} + +.snackbar { + position: fixed; + display: flex; + box-sizing: border-box; + left: 50%; + bottom: 24px; + width: 344px; + margin-left: -172px; + background: #2a2a2a; + border-radius: 2px; + box-shadow: 0 1px 4px rgba(0,0,0,0.5); + transform-origin: center; + color: #eee; + z-index: 100; + pointer-events: none; + cursor: default; + will-change: transform; + animation: snackbar-show 300ms ease forwards 1; +} +.snackbar[aria-hidden="true"] { + animation: snackbar-hide 300ms ease forwards 1; +} +@keyframes snackbar-show { + from { + opacity: 0; + transform: scale(0.5); + } +} +@keyframes snackbar-hide { + to { + opacity: 0; + transform: translateY(100%); + } +} + +@media (max-width: 400px) { + .snackbar { + width: 100%; + bottom: 0; + left: 0; + margin-left: 0; + border-radius: 0; + } +} + +.snackbar--text { + flex: 1 1 auto; + padding: 16px; + font-size: 100%; +} + +.snackbar--button { + position: relative; + flex: 0 1 auto; + padding: 8px; + height: 36px; + margin: auto 8px auto -8px; + min-width: 5em; + background: none; + border: none; + border-radius: 3px; + color: lightgreen; + font-weight: inherit; + letter-spacing: 0.05em; + 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 { + background-color: rgba(0,0,0,0.15); +} +.snackbar--button:focus:before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 120%; + height: 0; + padding: 0 0 120%; + margin: -60% 0 0 -60%; + background: rgba(255,255,255,0.1); + border-radius: 50%; + transform-origin: center; + will-change: transform; + animation: focus-ring 300ms ease-out forwards 1; + pointer-events: none; +} +@keyframes focus-ring { + from { + transform: scale(0.01); + } +}