Snackbar (#99)

* Initial swing

* Finish up <snack-bar> 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 `<snack-bar>` 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()
This commit is contained in:
Jason Miller
2018-08-06 09:32:48 -04:00
committed by Jake Archibald
parent ef4094885e
commit c90db020b0
4 changed files with 251 additions and 8 deletions

View File

@@ -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<Props, State> {
],
};
private snackbar?: SnackBarElement;
constructor() {
super();
// In development, persist application state across hot reloads:
@@ -234,12 +238,12 @@ export default class App extends Component<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
/>
))}
{anyLoading && <span style={{ position: 'fixed', top: 0, left: 0 }}>Loading...</span>}
{error && <span style={{ position: 'fixed', top: 0, left: 0 }}>Error: {error}</span>}
<snack-bar ref={linkRef(this, 'snackbar')} />
</div>
</file-drop>
);

115
src/lib/SnackBar/index.ts Normal file
View File

@@ -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<SnackShowResult> {
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<SnackShowResult> {
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);

13
src/lib/SnackBar/missing-types.d.ts vendored Normal file
View File

@@ -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<SnackShowResult>;
}
}
}

107
src/lib/SnackBar/styles.css Normal file
View File

@@ -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);
}
}