// WIP, just finding all the boxes and glue, implementation is woefully incomplete import type { DOMAttributes } from "react"; import { assign, createMachine, interpret } from "@xstate/fsm"; import invariant from "tiny-invariant"; type CustomElement<T> = Partial< T & DOMAttributes<T> & { children: any; class: string } >; type MachineContext = { input?: HTMLInputElement; button?: HTMLButtonElement }; type MachineEvents = | { type: "INIT"; input: HTMLInputElement; button: HTMLButtonElement } | { type: "BUTTON_CLICK" } | { type: "ESCAPE" } | { type: "ARROW_DOWN" } | { type: "ARROW_UP" } | { type: "OUTER_INTERACTION" }; const machine = createMachine<MachineContext, MachineEvents>({ id: "amalgo-box", initial: "idle", states: { idle: { on: { INIT: { target: "closed", actions: assign((_, event) => ({ input: event.input, button: event.button, })), }, }, }, closed: { entry: () => { document.body.style.overflow = ""; }, on: { BUTTON_CLICK: "open", }, }, open: { entry: ctx => { requestAnimationFrame(() => { ctx.input?.focus(); }); document.body.style.overflow = "hidden"; }, on: { BUTTON_CLICK: "closed", OUTER_INTERACTION: "closed", ESCAPE: { target: "closed", actions: ctx => { requestAnimationFrame(() => { ctx.button?.focus(); }); }, }, }, }, }, }); class AmalgoBox extends HTMLElement { context = interpret(machine); connectedCallback() { const service = (this.context = interpret(machine).start()); const input = this.querySelector("input"); const button = this.querySelector("button"); invariant(input, "need an <input />"); invariant(button, "need an <button />"); service.send({ type: "INIT", input, button }); service.subscribe(state => { this.setAttribute("state", state.value); if (state.value === "open") { document.addEventListener("mousedown", this.outerEvent); document.addEventListener("touchstart", this.outerEvent); document.addEventListener("focusin", this.outerEvent); document.addEventListener("keydown", this.keydownEvent); } else if (state.value === "closed") { document.removeEventListener("mousedown", this.outerEvent); document.removeEventListener("touchstart", this.outerEvent); document.removeEventListener("focusin", this.outerEvent); document.removeEventListener("keydown", this.keydownEvent); } }); } keydownEvent = (event: KeyboardEvent) => { if (event.key === "Escape") { this.context.send("ESCAPE"); } }; outerEvent = (event: Event) => { const interactedWithin = event.target instanceof Node && this.contains(event.target); if (!interactedWithin) { this.context.send("OUTER_INTERACTION"); } }; } class AmalgoElement extends HTMLElement { getContext() { let parent = this.closest("amalgo-box") as AmalgoBox | undefined; if (!parent) throw new Error("Must be child of <amalgo-box>"); return parent.context; } } class Button extends AmalgoElement { connectedCallback() { let button = this.childNodes[0]; invariant(button instanceof HTMLButtonElement); button.addEventListener("click", () => { this.getContext().send("BUTTON_CLICK"); }); } } class Input extends AmalgoElement {} class Popover extends AmalgoElement { connectedCallback() { this.getContext().subscribe(state => { if (state.value === "closed") { this.hidden = true; } else if (state.value === "open") { this.hidden = false; } }); } } class Menu extends AmalgoElement {} class Option extends AmalgoElement {} //////////////////////////////////////////////////////////////////////////////// declare global { namespace JSX { interface IntrinsicElements { ["amalgo-box"]: CustomElement<AmalgoBox>; ["amalgo-button"]: CustomElement<Button>; ["amalgo-input"]: CustomElement<Input>; ["amalgo-popover"]: CustomElement<Popover>; ["amalgo-menu"]: CustomElement<Menu>; ["amalgo-option"]: CustomElement<Option>; } } } window.customElements.define("amalgo-box", AmalgoBox); window.customElements.define("amalgo-button", Button); window.customElements.define("amalgo-input", Input); window.customElements.define("amalgo-popover", Popover); window.customElements.define("amalgo-menu", Menu); window.customElements.define("amalgo-option", Option);