Merge pull request #897 from GoogleChromeLabs/cli-invoc-2

Flyouts
This commit is contained in:
Jason Miller
2020-12-10 23:46:54 -05:00
committed by GitHub
8 changed files with 241 additions and 105 deletions

View File

@@ -6,51 +6,127 @@ import {
createRef, createRef,
ComponentChildren, ComponentChildren,
ComponentProps, ComponentProps,
Fragment,
render,
} from 'preact'; } from 'preact';
import { ClickOutsideDetector } from '../ClickOutsideDetector';
import * as style from './style.css'; import * as style from './style.css';
import 'add-css:./style.css'; import 'add-css:./style.css';
type Anchor = 'left' | 'right' | 'top' | 'bottom'; type Anchor = 'left' | 'right' | 'top' | 'bottom';
type Direction = 'left' | 'right' | 'up' | 'down'; type Direction = 'left' | 'right' | 'up' | 'down';
interface Props extends ComponentProps<'aside'> { const has = (haystack: string | string[] | undefined, needle: string) =>
Array.isArray(haystack) ? haystack.includes(needle) : haystack === needle;
interface Props extends Omit<ComponentProps<'aside'>, 'ref'> {
showing?: boolean; showing?: boolean;
direction?: Direction | Direction[]; direction?: Direction | Direction[];
anchor?: Anchor | Anchor[]; anchor?: Anchor;
toggle?: VNode; toggle?: VNode;
children?: ComponentChildren; children?: ComponentChildren;
} }
interface State { interface State {
showing: boolean; showing: boolean;
hasShown: boolean;
} }
export default class Flyout extends Component<Props, State> { export default class Flyout extends Component<Props, State> {
state = { state = {
showing: this.props.showing === true, showing: this.props.showing === true,
hasShown: this.props.showing === true,
}; };
private wrap = createRef<HTMLElement>();
private menu = createRef<HTMLElement>(); private menu = createRef<HTMLElement>();
private hide = () => { private resizeObserver?: ResizeObserver;
this.setState({ showing: false });
private shown?: number;
private dismiss = (event: Event) => {
if (this.menu.current && this.menu.current.contains(event.target as Node))
return;
// prevent toggle buttons from immediately dismissing:
if (this.shown && Date.now() - this.shown < 10) return;
this.setShowing(false);
}; };
private toggle = () => { hide = () => {
this.setState({ showing: !this.state.showing }); this.setShowing(false);
};
show = () => {
this.setShowing(true);
};
toggle = () => {
this.setShowing(!this.state.showing);
};
private setShowing = (showing?: boolean) => {
this.shown = Date.now();
if (showing) this.setState({ showing: true, hasShown: true });
else this.setState({ showing: false });
};
private reposition = () => {
const menu = this.menu.current;
const wrap = this.wrap.current;
if (!menu || !wrap || !this.state.showing) return;
const bbox = wrap.getBoundingClientRect();
const { direction = 'down', anchor = 'right' } = this.props;
const { innerWidth, innerHeight } = window;
const anchorX = has(anchor, 'left') ? bbox.left : bbox.right;
menu.style.left = menu.style.right = menu.style.top = menu.style.bottom =
'';
if (has(direction, 'left')) {
menu.style.right = innerWidth - anchorX + 'px';
} else {
menu.style.left = anchorX + 'px';
}
if (has(direction, 'up')) {
const anchorY = has(anchor, 'bottom') ? bbox.bottom : bbox.top;
menu.style.bottom = innerHeight - anchorY + 'px';
} else {
const anchorY = has(anchor, 'top') ? bbox.top : bbox.bottom;
menu.style.top = anchorY + 'px';
}
}; };
componentWillReceiveProps({ showing }: Props) { componentWillReceiveProps({ showing }: Props) {
if (showing !== this.props.showing) { if (showing !== this.props.showing) {
this.setState({ showing }); this.setShowing(showing);
} }
} }
componentDidMount() {
addEventListener('click', this.dismiss, { passive: true });
addEventListener('resize', this.reposition, { passive: true });
if (typeof ResizeObserver === 'function' && this.wrap.current) {
this.resizeObserver = new ResizeObserver(this.reposition);
this.resizeObserver.observe(this.wrap.current);
}
if (this.props.showing) this.setShowing(true);
}
componentWillUnmount() {
removeEventListener('click', this.dismiss);
removeEventListener('resize', this.reposition);
if (this.resizeObserver) this.resizeObserver.disconnect();
}
componentDidUpdate(prevProps: Props, prevState: State) { componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.showing && !prevState.showing) { if (this.state.showing && !prevState.showing) {
const menu = this.menu.current; const menu = this.menu.current;
if (menu) { if (menu) {
this.reposition();
let toFocus = menu.firstElementChild; let toFocus = menu.firstElementChild;
for (let child of menu.children) { for (let child of menu.children) {
if (child.hasAttribute('autofocus')) { if (child.hasAttribute('autofocus')) {
@@ -79,21 +155,56 @@ export default class Flyout extends Component<Props, State> {
const anchorText = Array.isArray(anchor) ? anchor.join(' ') : anchor; const anchorText = Array.isArray(anchor) ? anchor.join(' ') : anchor;
return ( return (
<span class={style.wrap} data-flyout-open={showing ? '' : undefined}> <span
<ClickOutsideDetector onClick={this.hide}> class={style.wrap}
{toggle && cloneElement(toggle, toggleProps)} ref={this.wrap}
data-flyout-open={showing ? '' : undefined}
>
{toggle && cloneElement(toggle, toggleProps)}
<aside {showing &&
{...props} createPortal(
ref={this.menu} <aside
hidden={!showing} {...props}
data-anchor={anchorText} class={`${style.flyout} ${props.class || props.className || ''}`}
data-direction={directionText} ref={this.menu}
> data-anchor={anchorText}
{children} data-direction={directionText}
</aside> >
</ClickOutsideDetector> {children}
</aside>,
document.body,
)}
</span> </span>
); );
} }
} }
// not worth pulling in compat
function createPortal(children: ComponentChildren, parent: Element) {
return <Portal parent={parent}>{children}</Portal>;
}
// this is probably overly careful, since it works directly rendering into parent
function createPersistentFragment(parent: Element) {
const frag = {
nodeType: 11,
childNodes: [],
appendChild: parent.appendChild.bind(parent),
insertBefore: parent.insertBefore.bind(parent),
removeChild: parent.removeChild.bind(parent),
};
return (frag as unknown) as Element;
}
class Portal extends Component<{
children: ComponentChildren;
parent: Element;
}> {
root = createPersistentFragment(this.props.parent);
componentWillUnmount() {
render(null, this.root);
}
render() {
render(<Fragment>{this.props.children}</Fragment>, this.root);
return null;
}
}

View File

@@ -1,13 +1,13 @@
.wrap { .wrap {
display: inline;
position: relative; position: relative;
display: flex;
align-items: center;
justify-items: center;
} }
.wrap > aside:last-of-type { .flyout {
display: inline-block; display: inline-block;
position: absolute; position: fixed;
left: 0;
top: 100%;
z-index: 100; z-index: 100;
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
@@ -23,44 +23,14 @@
display: none; display: none;
} }
/* align to the right edge */
&[data-anchor*='right'] {
left: 100%;
}
/* open to the left */
&[data-direction*='left'] { &[data-direction*='left'] {
right: 0;
left: auto;
align-items: flex-end; align-items: flex-end;
&[anchor*='right'] {
right: 100%;
}
} }
/* align to the top edge */
&[data-anchor*='top'] {
top: 0;
}
/* open to the left */
&[data-direction*='up'] { &[data-direction*='up'] {
bottom: 100%;
top: auto;
flex-direction: column-reverse;
--flyout-offset-y: 20px; --flyout-offset-y: 20px;
&[data-anchor*='bottom'] {
bottom: 0;
}
}
/*
@media (min-width: 860px) {
flex-direction: column-reverse; flex-direction: column-reverse;
top: auto;
bottom: 100%;
} }
*/
} }
@keyframes menuOpen { @keyframes menuOpen {

View File

@@ -1,4 +1,4 @@
import { h, Component } from 'preact'; import { h, Component, createRef } from 'preact';
import * as style from './style.css'; import * as style from './style.css';
import 'add-css:./style.css'; import 'add-css:./style.css';
@@ -15,9 +15,10 @@ import {
import Expander from './Expander'; import Expander from './Expander';
import Toggle from './Toggle'; import Toggle from './Toggle';
import Select from './Select'; import Select from './Select';
import Flyout from '../Flyout';
import { Options as QuantOptionsComponent } from 'features/processors/quantize/client'; import { Options as QuantOptionsComponent } from 'features/processors/quantize/client';
import { Options as ResizeOptionsComponent } from 'features/processors/resize/client'; import { Options as ResizeOptionsComponent } from 'features/processors/resize/client';
import { CLIIcon, SwapIcon } from 'client/lazy-app/icons'; import { CLIIcon, MoreIcon, SwapIcon } from 'client/lazy-app/icons';
interface Props { interface Props {
index: 0 | 1; index: 0 | 1;
@@ -64,6 +65,8 @@ export default class Options extends Component<Props, State> {
supportedEncoderMap: undefined, supportedEncoderMap: undefined,
}; };
menu = createRef<Flyout>();
constructor() { constructor() {
super(); super();
supportedEncoderMapP.then((supportedEncoderMap) => supportedEncoderMapP.then((supportedEncoderMap) =>
@@ -110,10 +113,12 @@ export default class Options extends Component<Props, State> {
private onCopyCliClick = () => { private onCopyCliClick = () => {
this.props.onCopyCliClick(this.props.index); this.props.onCopyCliClick(this.props.index);
if (this.menu.current) this.menu.current.hide();
}; };
private onCopyToOtherSideClick = () => { private onCopyToOtherSideClick = () => {
this.props.onCopyToOtherSideClick(this.props.index); this.props.onCopyToOtherSideClick(this.props.index);
if (this.menu.current) this.menu.current.hide();
}; };
render( render(
@@ -136,23 +141,33 @@ export default class Options extends Component<Props, State> {
{!encoderState ? null : ( {!encoderState ? null : (
<div> <div>
<h3 class={style.optionsTitle}> <h3 class={style.optionsTitle}>
<div class={style.titleAndButtons}> Edit
Edit <Flyout
ref={this.menu}
class={style.menu}
direction={['up', 'left']}
anchor="right"
toggle={
<button class={style.titleButton}>
<MoreIcon />
</button>
}
>
<button <button
class={style.cliButton} class={style.menuButton}
title="Copy npx command"
onClick={this.onCopyCliClick} onClick={this.onCopyCliClick}
> >
<CLIIcon /> <CLIIcon />
Copy npx command
</button> </button>
<button <button
class={style.copyOverButton} class={style.menuButton}
title="Copy settings to other side"
onClick={this.onCopyToOtherSideClick} onClick={this.onCopyToOtherSideClick}
> >
<SwapIcon /> <SwapIcon />
Copy settings to other side
</button> </button>
</div> </Flyout>
</h3> </h3>
<label class={style.sectionEnabler}> <label class={style.sectionEnabler}>
Resize Resize

View File

@@ -14,13 +14,21 @@
background-color: var(--main-theme-color); background-color: var(--main-theme-color);
color: var(--header-text-color); color: var(--header-text-color);
margin: 0; margin: 0;
padding: 10px var(--horizontal-padding); height: 38px;
padding: 0 var(--horizontal-padding);
font-weight: bold; font-weight: bold;
font-size: 1.4rem; font-size: 1.4rem;
border-bottom: 1px solid var(--off-black); border-bottom: 1px solid var(--off-black);
transition: all 300ms ease-in-out; transition: all 300ms ease-in-out;
transition-property: background-color, color; transition-property: background-color, color;
display: grid;
align-items: center;
grid-template-columns: 1fr;
grid-auto-columns: max-content;
grid-auto-flow: column;
gap: 0.8rem 0;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
@@ -82,36 +90,63 @@
border-radius: 4px; border-radius: 4px;
} }
.title-and-buttons { .menu {
grid-template-columns: 1fr; transform: translateY(-10px);
grid-auto-columns: max-content;
grid-auto-flow: column;
display: grid;
gap: 0.8rem;
} }
.title-button { .title-button {
position: relative;
left: 10px;
composes: unbutton from global; composes: unbutton from global;
border-radius: 50%;
background: rgba(255, 255, 255, 0);
&:hover,
&:active {
background: rgba(255, 255, 255, 0.3);
}
svg { svg {
--size: 20px; --size: 24px;
fill: var(--header-text-color);
display: block; display: block;
width: var(--size); width: var(--size);
height: var(--size); height: var(--size);
padding: 5px;
} }
} }
.cli-button { .menu-button {
composes: title-button; display: flex;
align-items: center;
box-sizing: border-box;
margin: 8px 0;
background-color: rgba(29, 29, 29, 0.92);
border: 1px solid rgba(0, 0, 0, 0.67);
border-radius: 2rem;
line-height: 1.1;
white-space: nowrap;
height: 39px;
padding: 0 16px;
font-size: 1.2rem;
cursor: pointer;
color: #fff;
svg { &:hover {
stroke: var(--header-text-color); background: rgba(50, 50, 50, 0.92);
} }
}
&:focus {
.copy-over-button { box-shadow: 0 0 0 2px #fff;
composes: title-button; outline: none;
z-index: 1;
svg { }
fill: var(--header-text-color);
& > svg {
position: relative;
width: 18px;
height: 18px;
margin-right: 12px;
color: var(--main-theme-color);
} }
} }

View File

@@ -40,6 +40,7 @@ interface State {
altBackground: boolean; altBackground: boolean;
transform: boolean; transform: boolean;
menuOpen: boolean; menuOpen: boolean;
smallControls: boolean;
} }
const scaleToOpts: ScaleToOpts = { const scaleToOpts: ScaleToOpts = {
@@ -56,6 +57,9 @@ export default class Output extends Component<Props, State> {
altBackground: false, altBackground: false,
transform: false, transform: false,
menuOpen: false, menuOpen: false,
smallControls:
typeof matchMedia === 'function' &&
matchMedia('(max-width: 859px)').matches,
}; };
canvasLeft?: HTMLCanvasElement; canvasLeft?: HTMLCanvasElement;
canvasRight?: HTMLCanvasElement; canvasRight?: HTMLCanvasElement;
@@ -84,6 +88,12 @@ export default class Output extends Component<Props, State> {
if (this.canvasRight && rightDraw) { if (this.canvasRight && rightDraw) {
drawDataToCanvas(this.canvasRight, rightDraw); drawDataToCanvas(this.canvasRight, rightDraw);
} }
if (typeof matchMedia === 'function') {
matchMedia('(max-width: 859px)').addEventListener('change', (e) =>
this.setState({ smallControls: e.matches }),
);
}
} }
componentDidUpdate(prevProps: Props, prevState: State) { componentDidUpdate(prevProps: Props, prevState: State) {
@@ -278,7 +288,7 @@ export default class Output extends Component<Props, State> {
onShowPreprocessorTransforms, onShowPreprocessorTransforms,
onToggleBackground, onToggleBackground,
}: Props, }: Props,
{ scale, editingScale }: State, { scale, editingScale, smallControls }: State,
) { ) {
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable(); const rightDraw = this.rightDrawable();
@@ -370,7 +380,7 @@ export default class Output extends Component<Props, State> {
class={style.menu} class={style.menu}
showing={hidden ? false : undefined} showing={hidden ? false : undefined}
anchor="right" anchor="right"
direction={mobileView ? ['down', 'left'] : 'up'} direction={smallControls ? ['down', 'left'] : 'up'}
toggle={ toggle={
<button class={`${style.button} ${style.moreButton}`}> <button class={`${style.button} ${style.moreButton}`}>
<MoreIcon /> <MoreIcon />

View File

@@ -187,7 +187,7 @@ input.zoom {
} }
} }
[data-flyout-open] { .controls [data-flyout-open] {
.moreButton { .moreButton {
background: rgba(82, 82, 82, 0.92); background: rgba(82, 82, 82, 0.92);

View File

@@ -858,7 +858,7 @@ export default class Compress extends Component<Props, State> {
} }
render( render(
{ onBack }: Props, { onBack, showSnack }: Props,
{ {
loading, loading,
sides, sides,

View File

@@ -1,6 +1,6 @@
import { h } from 'preact'; import { h } from 'preact';
const Icon = (props: preact.JSX.HTMLAttributes) => ( const Icon = (props: preact.JSX.HTMLAttributes | preact.JSX.SVGAttributes) => (
// @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019 // @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019
<svg <svg
width="24" width="24"
@@ -11,9 +11,15 @@ const Icon = (props: preact.JSX.HTMLAttributes) => (
/> />
); );
export const CLIIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M1 2.7H23v18.5H1zm5.5 13l3.7-3.7-3.7-3.7m5.5 7.4h5.6" />
</Icon>
);
export const SwapIcon = (props: preact.JSX.HTMLAttributes) => ( export const SwapIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}> <Icon {...props}>
<path d="M9.01 14H2v2h7.01v3L13 15l-3.99-4zm5.98-1v-3H22V8h-7.01V5L11 9z" /> <path d="M8.5 8.6v6.8L5.1 12l3.4-3.4M10 5l-7 7 7 7V5zm4 0v14l7-7-7-7z" />
</Icon> </Icon>
); );
@@ -77,9 +83,9 @@ export const RotateIcon = (props: preact.JSX.HTMLAttributes) => (
export const MoreIcon = (props: preact.JSX.HTMLAttributes) => ( export const MoreIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}> <Icon {...props}>
<circle cx="12" cy="6" r="2" fill="#fff" /> <circle cx="12" cy="6" r="2" />
<circle cx="12" cy="12" r="2" fill="#fff" /> <circle cx="12" cy="12" r="2" />
<circle cx="12" cy="18" r="2" fill="#fff" /> <circle cx="12" cy="18" r="2" />
</Icon> </Icon>
); );
@@ -124,14 +130,3 @@ export const DownloadIcon = () => (
<path d="M6.6 2.7h-4v13.2h2.7A2.7 2.7 0 018 18.6a2.7 2.7 0 002.6 2.6h2.7a2.7 2.7 0 002.6-2.6 2.7 2.7 0 012.7-2.7h2.6V2.7h-4a1.3 1.3 0 110-2.7h4A2.7 2.7 0 0124 2.7v18.5a2.7 2.7 0 01-2.7 2.7H2.7A2.7 2.7 0 010 21.2V2.7A2.7 2.7 0 012.7 0h4a1.3 1.3 0 010 2.7zm4 7.4V1.3a1.3 1.3 0 112.7 0v8.8L15 8.4a1.3 1.3 0 011.9 1.8l-4 4a1.3 1.3 0 01-1.9 0l-4-4A1.3 1.3 0 019 8.4z" /> <path d="M6.6 2.7h-4v13.2h2.7A2.7 2.7 0 018 18.6a2.7 2.7 0 002.6 2.6h2.7a2.7 2.7 0 002.6-2.6 2.7 2.7 0 012.7-2.7h2.6V2.7h-4a1.3 1.3 0 110-2.7h4A2.7 2.7 0 0124 2.7v18.5a2.7 2.7 0 01-2.7 2.7H2.7A2.7 2.7 0 010 21.2V2.7A2.7 2.7 0 012.7 0h4a1.3 1.3 0 010 2.7zm4 7.4V1.3a1.3 1.3 0 112.7 0v8.8L15 8.4a1.3 1.3 0 011.9 1.8l-4 4a1.3 1.3 0 01-1.9 0l-4-4A1.3 1.3 0 019 8.4z" />
</svg> </svg>
); );
export const CLIIcon = () => (
<svg viewBox="0 0 81.3 68.8">
<path
fill="none"
stroke-miterlimit="15.6"
stroke-width="6.3"
d="M3.1 3.1h75v62.5h-75zm18.8 43.8l12.5-12.5-12.5-12.5m18.7 25h18.8"
/>
</svg>
);