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

View File

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

View File

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

View File

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

View File

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