diff --git a/src/client/lazy-app/Compress/Flyout/index.tsx b/src/client/lazy-app/Compress/Flyout/index.tsx index 6cecfa18..c52c7099 100644 --- a/src/client/lazy-app/Compress/Flyout/index.tsx +++ b/src/client/lazy-app/Compress/Flyout/index.tsx @@ -6,51 +6,127 @@ 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'; -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, 'ref'> { 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 { state = { showing: this.props.showing === true, + hasShown: this.props.showing === true, }; + private wrap = createRef(); + private menu = createRef(); - 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 toggle = () => { - this.setState({ showing: !this.state.showing }); + hide = () => { + 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) { 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 +155,56 @@ export default class Flyout extends Component { const anchorText = Array.isArray(anchor) ? anchor.join(' ') : anchor; return ( - - - {toggle && cloneElement(toggle, toggleProps)} + + {toggle && cloneElement(toggle, toggleProps)} - - + {showing && + createPortal( + , + document.body, + )} ); } } + +// not worth pulling in compat +function createPortal(children: ComponentChildren, parent: Element) { + return {children}; +} +// 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({this.props.children}, this.root); + return null; + } +} diff --git a/src/client/lazy-app/Compress/Flyout/style.css b/src/client/lazy-app/Compress/Flyout/style.css index f8dcb7c7..ebae67d7 100644 --- a/src/client/lazy-app/Compress/Flyout/style.css +++ b/src/client/lazy-app/Compress/Flyout/style.css @@ -1,13 +1,13 @@ .wrap { - display: inline; position: relative; + display: flex; + align-items: center; + 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; @@ -23,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 { diff --git a/src/client/lazy-app/Compress/Options/index.tsx b/src/client/lazy-app/Compress/Options/index.tsx index 7f531098..021bfb42 100644 --- a/src/client/lazy-app/Compress/Options/index.tsx +++ b/src/client/lazy-app/Compress/Options/index.tsx @@ -1,4 +1,4 @@ -import { h, Component } from 'preact'; +import { h, Component, createRef } from 'preact'; import * as style from './style.css'; import 'add-css:./style.css'; @@ -15,9 +15,10 @@ import { import Expander from './Expander'; import Toggle from './Toggle'; import Select from './Select'; +import Flyout from '../Flyout'; import { Options as QuantOptionsComponent } from 'features/processors/quantize/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 { index: 0 | 1; @@ -64,6 +65,8 @@ export default class Options extends Component { supportedEncoderMap: undefined, }; + menu = createRef(); + constructor() { super(); supportedEncoderMapP.then((supportedEncoderMap) => @@ -110,10 +113,12 @@ export default class Options extends Component { private onCopyCliClick = () => { this.props.onCopyCliClick(this.props.index); + if (this.menu.current) this.menu.current.hide(); }; private onCopyToOtherSideClick = () => { this.props.onCopyToOtherSideClick(this.props.index); + if (this.menu.current) this.menu.current.hide(); }; render( @@ -136,23 +141,33 @@ export default class Options extends Component { {!encoderState ? null : (

-
- Edit + Edit + + + + } + > -
+