forked from external-repos/squoosh
Hoist flyouts to <body>
This commit is contained in:
@@ -6,51 +6,119 @@ import {
|
||||
createRef,
|
||||
ComponentChildren,
|
||||
ComponentProps,
|
||||
Fragment,
|
||||
render,
|
||||
} from 'preact';
|
||||
import { ClickOutsideDetector } from '../ClickOutsideDetector';
|
||||
import * as style from './style.css';
|
||||
import 'add-css:./style.css';
|
||||
|
||||
type Anchor = 'left' | 'right' | 'top' | 'bottom';
|
||||
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'> {
|
||||
showing?: boolean;
|
||||
direction?: Direction | Direction[];
|
||||
anchor?: Anchor | Anchor[];
|
||||
anchor?: Anchor;
|
||||
toggle?: VNode;
|
||||
children?: ComponentChildren;
|
||||
}
|
||||
|
||||
interface State {
|
||||
showing: boolean;
|
||||
hasShown: boolean;
|
||||
}
|
||||
|
||||
export default class Flyout extends Component<Props, State> {
|
||||
state = {
|
||||
showing: this.props.showing === true,
|
||||
hasShown: this.props.showing === true,
|
||||
};
|
||||
|
||||
private wrap = createRef<HTMLElement>();
|
||||
|
||||
private menu = createRef<HTMLElement>();
|
||||
|
||||
private hide = () => {
|
||||
this.setState({ showing: false });
|
||||
private resizeObserver?: ResizeObserver;
|
||||
|
||||
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 = () => {
|
||||
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) {
|
||||
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) {
|
||||
if (this.state.showing && !prevState.showing) {
|
||||
const menu = this.menu.current;
|
||||
if (menu) {
|
||||
this.reposition();
|
||||
|
||||
let toFocus = menu.firstElementChild;
|
||||
for (let child of menu.children) {
|
||||
if (child.hasAttribute('autofocus')) {
|
||||
@@ -79,21 +147,56 @@ export default class Flyout extends Component<Props, State> {
|
||||
const anchorText = Array.isArray(anchor) ? anchor.join(' ') : anchor;
|
||||
|
||||
return (
|
||||
<span class={style.wrap} data-flyout-open={showing ? '' : undefined}>
|
||||
<ClickOutsideDetector onClick={this.hide}>
|
||||
{toggle && cloneElement(toggle, toggleProps)}
|
||||
<span
|
||||
class={style.wrap}
|
||||
ref={this.wrap}
|
||||
data-flyout-open={showing ? '' : undefined}
|
||||
>
|
||||
{toggle && cloneElement(toggle, toggleProps)}
|
||||
|
||||
<aside
|
||||
{...props}
|
||||
ref={this.menu}
|
||||
hidden={!showing}
|
||||
data-anchor={anchorText}
|
||||
data-direction={directionText}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
</ClickOutsideDetector>
|
||||
{showing &&
|
||||
createPortal(
|
||||
<aside
|
||||
{...props}
|
||||
class={`${style.flyout} ${props.class || props.className || ''}`}
|
||||
ref={this.menu}
|
||||
data-anchor={anchorText}
|
||||
data-direction={directionText}
|
||||
>
|
||||
{children}
|
||||
</aside>,
|
||||
document.body,
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
|
||||
.wrap > aside:last-of-type {
|
||||
.flyout {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
@@ -25,44 +23,14 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* align to the right edge */
|
||||
&[data-anchor*='right'] {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* open to the left */
|
||||
&[data-direction*='left'] {
|
||||
right: 0;
|
||||
left: auto;
|
||||
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'] {
|
||||
bottom: 100%;
|
||||
top: auto;
|
||||
flex-direction: column-reverse;
|
||||
--flyout-offset-y: 20px;
|
||||
&[data-anchor*='bottom'] {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@media (min-width: 860px) {
|
||||
flex-direction: column-reverse;
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@keyframes menuOpen {
|
||||
|
||||
@@ -139,6 +139,7 @@ export default class Options extends Component<Props, State> {
|
||||
<h3 class={style.optionsTitle}>
|
||||
Edit
|
||||
<Flyout
|
||||
class={style.menu}
|
||||
direction={['up', 'left']}
|
||||
anchor="right"
|
||||
toggle={
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
color: var(--header-text-color);
|
||||
margin: 0;
|
||||
height: 38px;
|
||||
padding: 0 0 0 var(--horizontal-padding);
|
||||
padding: 0 var(--horizontal-padding);
|
||||
font-weight: bold;
|
||||
font-size: 1.4rem;
|
||||
border-bottom: 1px solid var(--off-black);
|
||||
@@ -90,12 +90,13 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.options-title aside {
|
||||
right: 10px;
|
||||
bottom: calc(100% + 10px);
|
||||
.menu {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.title-button {
|
||||
position: relative;
|
||||
left: 10px;
|
||||
composes: unbutton from global;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0);
|
||||
|
||||
@@ -40,6 +40,7 @@ interface State {
|
||||
altBackground: boolean;
|
||||
transform: boolean;
|
||||
menuOpen: boolean;
|
||||
smallControls: boolean;
|
||||
}
|
||||
|
||||
const scaleToOpts: ScaleToOpts = {
|
||||
@@ -56,6 +57,9 @@ export default class Output extends Component<Props, State> {
|
||||
altBackground: false,
|
||||
transform: false,
|
||||
menuOpen: false,
|
||||
smallControls:
|
||||
typeof matchMedia === 'function' &&
|
||||
matchMedia('(max-width: 859px)').matches,
|
||||
};
|
||||
canvasLeft?: HTMLCanvasElement;
|
||||
canvasRight?: HTMLCanvasElement;
|
||||
@@ -84,6 +88,12 @@ export default class Output extends Component<Props, State> {
|
||||
if (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) {
|
||||
@@ -278,7 +288,7 @@ export default class Output extends Component<Props, State> {
|
||||
onShowPreprocessorTransforms,
|
||||
onToggleBackground,
|
||||
}: Props,
|
||||
{ scale, editingScale }: State,
|
||||
{ scale, editingScale, smallControls }: State,
|
||||
) {
|
||||
const leftDraw = this.leftDrawable();
|
||||
const rightDraw = this.rightDrawable();
|
||||
@@ -370,7 +380,7 @@ export default class Output extends Component<Props, State> {
|
||||
class={style.menu}
|
||||
showing={hidden ? false : undefined}
|
||||
anchor="right"
|
||||
direction={mobileView ? ['down', 'left'] : 'up'}
|
||||
direction={smallControls ? ['down', 'left'] : 'up'}
|
||||
toggle={
|
||||
<button class={`${style.button} ${style.moreButton}`}>
|
||||
<MoreIcon />
|
||||
|
||||
Reference in New Issue
Block a user