mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-14 01:37:26 +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 { h, Component } from 'preact';
|
||||||
import { partial } from 'filesize';
|
import { partial } from 'filesize';
|
||||||
|
|
||||||
import { bind, bitmapToImageData } from '../../lib/util';
|
import { bind, linkRef, bitmapToImageData } from '../../lib/util';
|
||||||
import * as style from './style.scss';
|
import * as style from './style.scss';
|
||||||
import Output from '../Output';
|
import Output from '../Output';
|
||||||
import Options from '../Options';
|
import Options from '../Options';
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
EncoderOptions,
|
EncoderOptions,
|
||||||
encoderMap,
|
encoderMap,
|
||||||
} from '../../codecs/encoders';
|
} from '../../codecs/encoders';
|
||||||
|
import SnackBarElement from '../../lib/SnackBar';
|
||||||
|
import '../../lib/SnackBar';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PreprocessorState,
|
PreprocessorState,
|
||||||
@@ -132,6 +134,8 @@ export default class App extends Component<Props, State> {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private snackbar?: SnackBarElement;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
// In development, persist application state across hot reloads:
|
// In development, persist application state across hot reloads:
|
||||||
@@ -234,12 +238,12 @@ export default class App extends Component<Props, State> {
|
|||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
source: { data, bmp, file },
|
source: { data, bmp, file },
|
||||||
error: undefined,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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);
|
source.preprocessed = await preprocessImage(source, image.preprocessorState);
|
||||||
file = await compressImage(source, image.encoderState);
|
file = await compressImage(source, image.encoderState);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: `Encoding error (type=${image.encoderState.type}): ${err}` });
|
this.showError(`Encoding error (type=${image.encoderState.type}): ${err}`);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestImage = this.state.images[index];
|
const latestImage = this.state.images[index];
|
||||||
// If a later encode has landed before this one, return.
|
// If a later encode has landed before this one, return.
|
||||||
if (loadingCounter < latestImage.loadedCounter) {
|
if (loadingCounter < latestImage.loadedCounter) {
|
||||||
this.setState({ error: '' });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,10 +297,15 @@ export default class App extends Component<Props, State> {
|
|||||||
loadedCounter: loadingCounter,
|
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 [leftImageBmp, rightImageBmp] = images.map(i => i.bmp);
|
||||||
const anyLoading = loading || images.some(image => image.loading);
|
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>}
|
{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>
|
</div>
|
||||||
</file-drop>
|
</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