Hoist flyouts to <body>

This commit is contained in:
Jason Miller
2020-12-09 23:47:43 -05:00
parent 7de8fa9da3
commit a65bbdf811
5 changed files with 142 additions and 59 deletions

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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={

View File

@@ -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);

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 />