mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-13 17:27:09 +00:00
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:
committed by
Jake Archibald
parent
ef4094885e
commit
c90db020b0
@@ -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
115
src/lib/SnackBar/index.ts
Normal 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
13
src/lib/SnackBar/missing-types.d.ts
vendored
Normal 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
107
src/lib/SnackBar/styles.css
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user