mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-13 01:07:18 +00:00
Hoist flyouts to <body>
This commit is contained in:
@@ -6,51 +6,119 @@ 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';
|
||||||
|
|
||||||
|
const has = (haystack: string | string[] | undefined, needle: string) =>
|
||||||
|
Array.isArray(haystack) ? haystack.includes(needle) : haystack === needle;
|
||||||
|
|
||||||
interface Props extends ComponentProps<'aside'> {
|
interface Props extends ComponentProps<'aside'> {
|
||||||
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 setShowing = (showing?: boolean) => {
|
||||||
|
this.shown = Date.now();
|
||||||
|
if (showing) this.setState({ showing: true, hasShown: true });
|
||||||
|
else this.setState({ showing: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
private toggle = () => {
|
private toggle = () => {
|
||||||
this.setState({ showing: !this.state.showing });
|
this.setShowing(!this.state.showing);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 +147,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,9 @@
|
|||||||
justify-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;
|
||||||
@@ -25,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 {
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export default class Options extends Component<Props, State> {
|
|||||||
<h3 class={style.optionsTitle}>
|
<h3 class={style.optionsTitle}>
|
||||||
Edit
|
Edit
|
||||||
<Flyout
|
<Flyout
|
||||||
|
class={style.menu}
|
||||||
direction={['up', 'left']}
|
direction={['up', 'left']}
|
||||||
anchor="right"
|
anchor="right"
|
||||||
toggle={
|
toggle={
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
color: var(--header-text-color);
|
color: var(--header-text-color);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
padding: 0 0 0 var(--horizontal-padding);
|
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);
|
||||||
@@ -90,12 +90,13 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.options-title aside {
|
.menu {
|
||||||
right: 10px;
|
transform: translateY(-10px);
|
||||||
bottom: calc(100% + 10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-button {
|
.title-button {
|
||||||
|
position: relative;
|
||||||
|
left: 10px;
|
||||||
composes: unbutton from global;
|
composes: unbutton from global;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0);
|
background: rgba(255, 255, 255, 0);
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user