mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-12 08:47:31 +00:00
211 lines
5.5 KiB
TypeScript
211 lines
5.5 KiB
TypeScript
import {
|
|
h,
|
|
cloneElement,
|
|
Component,
|
|
VNode,
|
|
createRef,
|
|
ComponentChildren,
|
|
ComponentProps,
|
|
Fragment,
|
|
render,
|
|
} from 'preact';
|
|
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 Omit<ComponentProps<'aside'>, 'ref'> {
|
|
showing?: boolean;
|
|
direction?: Direction | Direction[];
|
|
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 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);
|
|
};
|
|
|
|
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.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')) {
|
|
toFocus = child;
|
|
break;
|
|
}
|
|
}
|
|
// @ts-ignore-next
|
|
if (toFocus) toFocus.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
render(
|
|
{ direction, anchor, toggle, children, ...props }: Props,
|
|
{ showing }: State,
|
|
) {
|
|
const toggleProps = {
|
|
flyoutOpen: showing,
|
|
onClick: this.toggle,
|
|
};
|
|
|
|
const directionText = Array.isArray(direction)
|
|
? direction.join(' ')
|
|
: direction;
|
|
const anchorText = Array.isArray(anchor) ? anchor.join(' ') : anchor;
|
|
|
|
return (
|
|
<span
|
|
class={style.wrap}
|
|
ref={this.wrap}
|
|
data-flyout-open={showing ? '' : undefined}
|
|
>
|
|
{toggle && cloneElement(toggle, toggleProps)}
|
|
|
|
{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;
|
|
}
|
|
}
|